Merge ni/1-libnode-loader into ni/13-libnode-packs

This commit is contained in:
Malte Ubl
2026-10-21 20:12:41 -07:00
269 changed files with 9610 additions and 1262 deletions
+1
View File
@@ -9,6 +9,7 @@ node_modules/
!tests/corpus/*/node_modules/
!tests/fixtures/npm-static/node_modules/
!tests/coverage-fixtures/wasm/node_modules/
!tests/coverage-fixtures/dynamic-tier/package/node_modules/
!tests/fixtures/npm-static/nested/inner/node_modules/
!tests/fixtures/npm-static/node_modules/*/node_modules/
!tests/fixtures/npm/store/*/node_modules/
+2
View File
@@ -16,6 +16,8 @@ CI (`.github/workflows/release.yml`) compares the version in `packages/cli/packa
The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI bundles its TypeScript checker, LLVM helper, runtime pack, declarations, and comptime evaluator. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds.
Release builds of the native CLI optimize the compiler with the committed profile `packages/compiler/native/cli.profdata` on every platform. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. Refresh the profile on a quiet Linux x64 host when compiler code has changed substantially since the last refresh: `node scripts/refresh-cli-profile.mjs --tsc-ts <tsc-ts checkout>` builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Commit the new profile on its own.
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret.
Each published package must have a GitHub Actions trusted publisher for
`release.yml` and the `Release` environment. A missing configuration fails
+10 -7
View File
@@ -109,13 +109,13 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>Primary artifact path. An explicit path is exact. Defaults are <code>.scriptc/&lt;name&gt;.ir.json</code>, <code>.ll</code>, <code>.s</code>, <code>.o</code>, or the platform executable name.</dd>
<dt><code>--emit &lt;ir|llvm|asm|obj|exe&gt;</code></dt>
<dd>Select the primary artifact: serialized typed IR (<code>ir</code>), textual LLVM IR (<code>llvm</code>), assembly (<code>asm</code>), a relocatable object (<code>obj</code>), or an executable (<code>exe</code>, the default). Every kind except <code>exe</code> is produced without an external compiler, linker, or SDK.</dd>
<dd>Select the primary artifact: serialized typed IR (<code>ir</code>), textual LLVM IR (<code>llvm</code>), assembly (<code>asm</code>), a relocatable object (<code>obj</code>), or an executable (<code>exe</code>, the default). Every kind except <code>exe</code> is produced without an external compiler, linker, or SDK. Assembly and object output is compiled as one module; only executables are divided into partitions that compile in parallel (see <a href="/docs/how-it-works#build-parallelism">Build parallelism</a>).</dd>
<dt><code>--print &lt;native-link-info|diagnostics&gt;</code></dt>
<dd><code>native-link-info</code>: build an object (equivalent to <code>--emit=obj</code>) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. <code>diagnostics</code>: for <code>build</code> and <code>coverage</code>, print the versioned JSON diagnostics document described in <a href="/docs/coverage#machine-readable-diagnostics">Coverage Reports</a> instead of the human output.</dd>
<dt><code>--fail-on &lt;blockers|divergences&gt;</code></dt>
<dd>Coverage only. Exit with status 1 when the entry has blockers that would fail a build, or with <code>divergences</code>, also when it has Node divergence warnings. See <a href="/docs/coverage#gating-builds-and-ci">Gating builds and CI</a>.</dd>
<dt><code>--fail-on &lt;blockers|divergences|dynamic&gt;</code></dt>
<dd>Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with <code>divergences</code>, also when it has Node divergence warnings, or with <code>dynamic</code>, also when it has the <code>SC6101</code> warning for untyped code that likely runs often. See <a href="/docs/coverage#gating-builds-and-ci">Gating builds and CI</a>.</dd>
<dt><code>--ffi &lt;file&gt;</code></dt>
<dd>Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See <a href="/docs/ffi">Native FFI</a>.</dd>
@@ -127,7 +127,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.</dd>
<dt><code>--optimization &lt;release|dev|speed&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>), which emits string retains and the retains and releases of dynamic values inline and lets LLVM inline small runtime functions that keep no internal runtime state into executable code; programs large enough to be compiled in parallel partitions skip that inlining to keep build time and size in check. <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/docs/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it lets LLVM inline more small runtime functions into program code, emits the remaining reference counting inline, and on x86-64 starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Runtime inlining needs a runtime pack built with the LLVM helper; where a runtime pack lacks it, programs link the same runtime without it and <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting.</dd>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>), which emits string retains and the retains and releases of dynamic values inline. <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/docs/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it emits the remaining reference counting inline and, on x86-64, starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Where a runtime pack has no speed runtime, <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting. In executables large enough to be divided into partitions, <code>dev</code> compiles each partition on its own, and <code>release</code> and <code>speed</code> add a step that lets partitions import small functions from each other; see <a href="/docs/how-it-works#build-parallelism">Build parallelism</a>.</dd>
<dt><code>--windows-subsystem &lt;console|gui&gt;</code></dt>
<dd>Select the subsystem of a Windows executable. <code>console</code> is the default; <code>gui</code> prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.</dd>
@@ -139,7 +139,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where <code>%p</code> expands to the process ID; the <code>LLVM_PROFILE_FILE</code> environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--profile-use &lt;file.profdata&gt;</code></dt>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--npm-static &lt;pkg[,pkg…]|auto&gt;</code></dt>
<dd>Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing <code>...</code>, as in <code>fs-extra...</code>, also names every package that package depends on. <code>auto</code> uses the default automatic selection. Packages that can't compile produce diagnostics. See <a href="/docs/dependencies#explicit-package-selection">npm Dependencies</a>.</dd>
@@ -156,6 +156,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--explain-dynamic[=&lt;name&gt;]</code></dt>
<dd>Coverage only. After the report, list the functions that already operate on values typed only at runtime and the bindings, fields, functions, classes and modules that unsupported constructs would move to the dynamic tier, each with the diagnostic behind it. With a name such as <code>x</code>, <code>Point</code>, or <code>Point.x</code>, show only the matching units. See <a href="/docs/coverage#dynamic-tier">Dynamic tier</a>.</dd>
<dt><code>--no-dynamic-tier-warning</code></dt>
<dd>Build and coverage. Leave out <code>SC6101</code>, the warning that names functions that likely run often and use untyped values. Not allowed with <code>--fail-on=dynamic</code>. See <a href="/docs/coverage#untyped-code-that-likely-runs-often">Untyped code that likely runs often</a>.</dd>
<dt><code>--wasm &lt;file.wasm&gt;</code></dt>
<dd>Coverage only. Report a WebAssembly module the program loads from a path its sources do not spell out. The report already lists modules whose paths the compiler resolves, modules inlined as base64 literals, and <code>.wasm</code> files shipped by packages that use WebAssembly; a <code>scriptc.wasm</code> array in <code>package.json</code> adds files the same way as this flag. Each module is listed with its size, whether it validates, and the WebAssembly proposals it uses. Builds translate the modules they find to native code; a build reads the <code>scriptc.wasm</code> array but not this flag. Repeatable.</dd>
@@ -182,7 +185,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dl>
<dt><code>SCRIPTC_CACHE_DIR</code></dt>
<dd>Override the persistent build-cache root. By default it is <code>$XDG_CACHE_HOME/scriptc/build</code> when set, <code>$HOME/Library/Caches/scriptc/build</code> on macOS, <code>%LOCALAPPDATA%\scriptc\cache\build</code> on Windows, and <code>$HOME/.cache/scriptc/build</code> elsewhere. Cached frontend results and LLVM artifacts are verified against their source, helper, target, and compilation settings. Native outputs also depend on the selected runtime pack and linker inputs. FFI objects, archives, and system libraries relink against their current dependencies. Sanitizer development builds additionally cache instrumented runtime objects and verify external compiler and header inputs. An existing POSIX override must already be private; otherwise caching is bypassed without changing the directory's permissions.</dd>
<dd>Override the persistent build-cache root. By default it is <code>$XDG_CACHE_HOME/scriptc/build</code> when set, <code>$HOME/Library/Caches/scriptc/build</code> on macOS, <code>%LOCALAPPDATA%\scriptc\cache\build</code> on Windows, and <code>$HOME/.cache/scriptc/build</code> elsewhere. Cached frontend results and LLVM artifacts, including the compiled partitions of large executables, are verified against their source, helper, target, and compilation settings. Native outputs also depend on the selected runtime pack and linker inputs. FFI objects, archives, and system libraries relink against their current dependencies. Sanitizer development builds additionally cache instrumented runtime objects and verify external compiler and header inputs. An existing POSIX override must already be private; otherwise caching is bypassed without changing the directory's permissions.</dd>
<dt><code>SCRIPTC_NO_CACHE</code></dt>
<dd>Set to <code>1</code> to bypass all build-cache reads and writes. An explicitly empty <code>SCRIPTC_CACHE_DIR</code> has the same effect.</dd>
@@ -223,7 +226,7 @@ $ scriptc merge-profiles -o app.profdata profiles/*.profraw
$ scriptc build app.ts --optimization=speed --profile-use app.profdata
```
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed.
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
## LLVM backend
+21 -2
View File
@@ -62,6 +62,25 @@ Code that operates on values whose types are known only at runtime, such as `any
`--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. Today `auto`, the default, behaves the same way.
### Untyped code that likely runs often
When functions of the program's own files use untyped values and likely run often, `scriptc build` and coverage report them as an `SC6101` warning. For the Benchmarks Game n-body program in plain JavaScript:
```console
$ scriptc build nbody.js -o nbody
warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code:
advance (nbody.js:22): untyped JavaScript file, in a loop
energy (nbody.js:40): untyped JavaScript file, in a loop
offsetMomentum (nbody.js:12): untyped JavaScript file, in a loop
hint: add types to compile these statically; see scriptc coverage --explain-dynamic
The program compiled. Adding types to these functions lets scriptc compile them to faster code.
```
Without a profile, a dynamic-tier function counts as likely to run often when a dynamic operation runs on every iteration of one of its loops (a `for` loop's initializer and a `for...of` loop's iterable run once), when a loop calls it, or when it calls itself. A function is named only when its source says why its values are untyped: a value typed `any` (for a `for...of` variable, the value it iterates), an untyped `JSON.parse` result, or an untyped JavaScript file. Dynamic code that runs once, such as setup code that reads an `any` configuration, is not reported, and neither are functions that are dynamic for an operation with no static lowering, which more types would not change.
The warning names at most five functions, most likely to run often first, and gives the total. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it.
## Diagnostic codes
Coverage and `scriptc build` report the same diagnostic codes. Common ones include:
@@ -88,7 +107,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che
$ scriptc coverage src/main.ts --fail-on=blockers
```
`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences. A program that doesn't type-check always exits with status 1.
`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1.
Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off.
@@ -174,7 +193,7 @@ For editors, CI annotations, and other tools, `scriptc coverage --print=diagnost
</tr>
<tr>
<td><code>category</code></td>
<td><code>invalid-source</code> (the program has a TypeScript or JSON error), <code>unsupported</code> (valid code scriptc cannot compile yet), <code>divergence</code> (compiles, but can behave differently from Node.js), <code>environment</code> (project configuration, scriptc's type world, toolchain, or target), or <code>internal</code> (a compiler bug).</td>
<td><code>invalid-source</code> (the program has a TypeScript or JSON error), <code>unsupported</code> (valid code scriptc cannot compile yet), <code>divergence</code> (compiles, but can behave differently from Node.js), <code>performance</code> (compiles and behaves as under Node.js, but runs slower than typed code would), <code>environment</code> (project configuration, scriptc's type world, toolchain, or target), or <code>internal</code> (a compiler bug).</td>
</tr>
<tr>
<td><code>severity</code></td>
+60 -1
View File
@@ -27,11 +27,70 @@ Runtime pack and linker -> executable
1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics. JavaScript files that TypeScript checks (a `// @ts-check` comment, or `checkJs` in `tsconfig.json`) report values that do not match their JSDoc types as errors, and their classes use those types the way TypeScript classes use annotations.
2. **Typed IR:** A validated, serializable intermediate representation connects the frontend and backend. Types are concrete: generic functions are specialized, unions use tags, and closures have explicit captures. `--emit=ir` writes this representation as JSON.
3. **LLVM backend:** `--emit=llvm` writes textual LLVM IR. The matching LLVM 22 helper generates assembly or objects on supported macOS, Linux, and Windows hosts, including output targeting WASI. Executable and library builds use this backend. Large executables, native and WASI alike, are divided into partitions that are optimized and compiled in parallel; optimized builds summarize every partition first so small functions can still be inlined across partitions. The partition count depends only on the program, so output is the same on every host, and a rebuild reuses the compiled partitions that an edit does not affect.
3. **LLVM backend:** `--emit=llvm` writes textual LLVM IR. The matching LLVM 22 helper generates assembly or objects on supported macOS, Linux, and Windows hosts, including output targeting WASI. Executable and library builds use this backend. Large executables, native and WASI alike, are divided into partitions that are optimized and compiled in parallel; see [Build parallelism](#build-parallelism).
4. **Linking:** Runtime packs contain the precompiled C runtime and vendored libraries. A hashed manifest selects the artifacts the program needs for its features, optimization mode, and (for libraries) state model. The target linker and SDK or sysroot produce the executable. Sanitizer builds use an instrumented C runtime.
`comptime` calls run during the frontend stage in an isolated evaluator inside the compiler, based on QuickJS. Their results are embedded in the program as constants.
## Build parallelism
Most of a build runs on one CPU core. The LLVM helper uses more cores only when it compiles an executable in partitions.
### Frontend
Parsing, type checking, lowering, and LLVM IR generation run one after another on one thread. The type checker runs in a separate process that the compiler waits on. When none of a build's inputs has changed since an earlier build, the frontend's results come from the build cache and this stage is skipped. A change to any input runs the whole stage again.
### Partitions
The LLVM helper divides an executable's LLVM IR into partitions by its size: one partition per 4 MiB of LLVM text, rounded up to a power of two, and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets.
<table>
<thead>
<tr><th>LLVM text</th><th>Partitions</th></tr>
</thead>
<tbody>
<tr><td>Up to 4 MiB</td><td>1</td></tr>
<tr><td>Over 4 MiB, up to 8 MiB</td><td>2</td></tr>
<tr><td>Over 8 MiB, up to 16 MiB</td><td>4</td></tr>
<tr><td>Over 16 MiB, up to 32 MiB</td><td>8</td></tr>
<tr><td>Over 32 MiB, up to 64 MiB</td><td>16</td></tr>
<tr><td>Over 64 MiB, up to 128 MiB</td><td>32</td></tr>
<tr><td>Over 128 MiB</td><td>64</td></tr>
</tbody>
</table>
The count depends only on the size of the LLVM text, so a program has the same partitions, and builds the same executable, on every host. A function's partition follows from its name, and a small internal function goes with the one function that calls it, so an edit leaves the other functions in their partitions.
These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler.
The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with two partitions uses at most two cores in the backend. A program of one partition is optimized and compiled on one thread.
How partitions are compiled depends on `--optimization`:
- `dev` compiles each partition on its own, all in parallel.
- `release` and `speed` optimize each partition and write a summary of its functions, all in parallel. Then a serial step reads every summary and decides which small functions each partition imports from the others, so that calls between partitions can still be inlined. Last, the partitions are optimized with their imports and compiled, again in parallel.
Reading the LLVM text and dividing it into partitions, and the import step, run on one thread.
### Rebuilds
With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition.
A program of one partition has no partition cache: a rebuild after an edit compiles the whole program again, and a rebuild that leaves the LLVM text unchanged takes the compiled code from the build cache.
When the LLVM text grows past a partition boundary (4, 8, 16 MiB, and so on), the partition count doubles, functions move to new partitions, and the next build compiles every partition again.
### Profile-guided optimization
Building with a profile adds these costs (see [Profile-guided optimization](/docs/cli#profile-guided-optimization)):
- `--profile-generate` builds an instrumented executable, which runs slower while it records the profile.
- The profile's contents are part of every partition's cache key. A new or changed profile compiles every partition again; builds without a profile keep their own cache entries.
- With a profile, the import step lets a hot call import a function of up to twice the usual size limit. LLVM's default limits for profiled calls are 10 and 100 times as large, and on large programs they tripled the build's time and memory without making the program measurably faster.
- The profile does not change which partition a function is in.
Collecting a profile takes an instrumented build, the training runs, and the merge. With a committed profile, later builds skip those steps and are one optimized build each.
## Inspect compiler output
The following examples use this program:
+2
View File
@@ -82,6 +82,8 @@ title: "Limitations"
<include>../limitations/builtin-apis/filesystem-flags.mdx</include>
<include>../limitations/builtin-apis/filesystem-url-paths.mdx</include>
<include>../limitations/builtin-apis/copy.mdx</include>
<include>../limitations/builtin-apis/glob.mdx</include>
@@ -0,0 +1,3 @@
- **Filesystem URL paths:** Synchronous `fs` functions accept `file:` URL paths and convert them as `fileURLToPath` does.
`existsSync` and the `fs.promises` functions refuse URL paths; pass `fileURLToPath(url)` instead.
A URL path after a string path, or with options that are not literals, is refused, because Node checks those arguments before it converts the URL.
@@ -18,6 +18,7 @@ An unhandled rejection whose reason is not error-like arrives as an `UnhandledPr
A thrown value that does not clone, such as a function, arrives as an `ERR_WORKER_UNSERIALIZABLE_ERROR` Error, where Node sends its `util.inspect` string.
`new MessageChannel()` from `node:worker_threads` makes two entangled MessagePorts, in one thread or across threads once a port is transferred, with `receiveMessageOnPort`.
Port messages arrive in Node's order: each port's queued messages as one batch before `setImmediate` callbacks, a port's own `'close'` after the poll phase, and the other port's `'close'` after its queued messages while the loop keeps running.
A worker's messages to its parent, and the parent's messages in the worker, also arrive as one batch before `setImmediate` callbacks, each followed by its microtasks, as in Node: the messages queued when delivery starts, and those that arrive meanwhile until the batch holds 1000.
`receiveMessageOnPort(parentPort)` in a worker takes the parent's next message.
A port's `onmessage` property and `addEventListener` are unsupported.
`BroadcastChannel` delivers each message to every other open channel with the same name, in any thread, as a `'message'` `MessageEvent` through `onmessage` and `addEventListener`; an open channel keeps the process alive until `close()` or `unref()`.
@@ -68082,9 +68082,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 for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -68289,9 +68289,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 for the documented scriptc module-loader subset.",
"verification": "test-backed"
}
},
{
@@ -95364,7 +95364,9 @@
"tests/corpus/2331-tls-server-options-typed.ts",
"tests/corpus/2500-net-autosel-timeout.ts",
"tests/corpus/2557-tls-ca-store.ts",
"tests/corpus/2596-net-arg-ladders.cjs"
"tests/corpus/2596-net-arg-ladders.cjs",
"tests/corpus/6721-tls-alpn-protocols.ts",
"tests/corpus/6722-tls-min-max-version.ts"
]
},
"anchorSource": "chapter"
@@ -96365,8 +96367,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:tls"
"status": "partial",
"evidence": "compiler-feature:tls.tlsSocket.getProtocol",
"tests": [
"tests/corpus/6722-tls-min-max-version.ts"
]
},
"anchorSource": "exact"
},
@@ -96572,8 +96577,11 @@
"inherited": true
},
"static": {
"status": "not-implemented",
"evidence": "compiler-unmatched:tls"
"status": "partial",
"evidence": "compiler-feature:tls.alpnProtocol",
"tests": [
"tests/corpus/6721-tls-alpn-protocols.ts"
]
},
"anchorSource": "ancestor"
},
@@ -365,6 +365,8 @@
{ "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] },
{ "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] },
{ "chapter": "console", "signatures": ["Class: `Console`"], "status": "partial", "evidence": ["tests/corpus/4900-console-instances.ts", "tests/corpus/4901-console-instance-errors.ts"] },
{ "chapter": "tls", "signatures": ["`alpnProtocol` Type: {string|boolean|null}"], "status": "partial", "evidence": ["tests/corpus/6721-tls-alpn-protocols.ts"] },
{ "chapter": "tls", "signatures": ["`tlsSocket.getProtocol()`"], "status": "partial", "evidence": ["tests/corpus/6722-tls-min-max-version.ts"] },
{ "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] },
{ "chapter": "webstreams", "signatures": ["`streamConsumers.text(stream)`", "`streamConsumers.json(stream)`", "`streamConsumers.buffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/2629-stream-consumers.ts", "tests/corpus/2630-stream-consumers-errors.ts"] },
{ "chapter": "webstreams", "signatures": ["`streamConsumers.arrayBuffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/4875-stream-consumers-array-buffer.ts"] },
+1 -1
View File
@@ -39,7 +39,7 @@ WebAssembly code generation differs from LLVM's default pipeline in two ways tha
`--cache-dir <dir>` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect.
Unsanitized `speed` program builds, and unsanitized `release` builds of programs small enough for one partition, pass `--import-bitcode <unit.bc>` once per runtime-pack unit the program links that ships bitcode. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted.
`--import-bitcode <unit.bc>`, given once per runtime-pack unit that ships bitcode, imports runtime functions for inlining; scriptc's program builds do not pass it. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted.
Runtime-pack builds emit each unit of the executable `speed` flavor through the helper:
+1
View File
@@ -20,6 +20,7 @@
"test:test262": "tsx tests/test262/run.ts",
"test:test262:fetch": "node tests/test262/fetch.mjs",
"test:cache-identity": "node tests/harness/cache-identity.mjs",
"test:lowering-order": "node scripts/lowering-order.mjs --fail-on content",
"test:sandbox": "node scripts/sandbox-test.mjs",
"test:sandbox:image": "node scripts/sandbox-image.mjs",
"test:watch": "vitest",
+5
View File
@@ -163,6 +163,10 @@ async function tryFastPath(): Promise<number | null> {
if (hit === null) return null;
if (!values["keep-llvm"]) await rm(hit.llvmPath, { force: true });
if (command === "build") {
// A cached build repeats its performance warning (SC6101).
const performance = hit.performance ?? [];
if (performance.length > 0 && values["dynamic-tier-warning"] !== false)
process.stderr.write(startup.renderPerformanceWarnings(performance) + "\n");
process.stdout.write(`${outPath}\n`);
return 0;
}
@@ -220,6 +224,7 @@ async function coverageFastPath(
const hit = await startup.readCoverageVerdict(resolve(inputArg), {}).catch(() => null);
if (hit === null) return null;
const { coverage, sourceTexts } = hit;
if (values["dynamic-tier-warning"] === false) delete coverage.performance;
if (print === "diagnostics") {
const envelope = startup.coverageEnvelope(coverage, {
compilerVersion: packageVersion(),
+4
View File
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
analyzeCached,
collectGarbageUnderHeapPressure,
compile,
compileLibrary,
mergeProfiles,
@@ -26,6 +27,9 @@ function version(): string {
return manifest.version ?? "unknown";
}
// The compiler process collects its heap as lowering and LLVM emission grow it.
collectGarbageUnderHeapPressure();
process.exitCode = await runCli(process.argv.slice(2), {
version,
analyze: analyzeCached,
@@ -0,0 +1,120 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const repoRoot = join(import.meta.dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
const fixtures = join(repoRoot, "tests/coverage-fixtures/dynamic-tier");
const HEADER =
"warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code:";
const CLOSING =
"The program compiled. Adding types to these functions lets scriptc compile them to faster code.";
async function cli(
args: string[],
cacheRoot: string,
): Promise<{ code: number; stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
["--import", tsxLoader, cliEntry, ...args],
{
env: { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot, SCRIPTC_TIMING: "1" },
maxBuffer: 4 * 1024 * 1024,
},
);
return { code: 0, stdout, stderr };
} catch (err) {
const e = err as { code?: number; stdout?: string; stderr?: string };
return { code: e.code ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
}
}
test("a build names its likely-hot untyped functions, also when it comes from the cache", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-warning-"));
const cacheRoot = join(dir, "cache");
const outPath = join(dir, process.platform === "win32" ? "program.exe" : "program");
try {
await mkdir(cacheRoot, { mode: 0o700 });
const build = (extra: string[] = []) =>
cli(["build", join(fixtures, "hot.js"), "-o", outPath, ...extra], cacheRoot);
const first = await build();
expect(first.code).toBe(0);
expect(first.stderr).toContain("scriptc lowering");
expect(first.stderr).toContain(
[
HEADER,
" centroid (hot.js:7): untyped JavaScript file, in a loop",
" spread (hot.js:23): untyped JavaScript file, in a loop",
" distance (hot.js:17): untyped JavaScript file, called in a loop",
" hint: add types to compile these statically; see scriptc coverage --explain-dynamic",
"",
CLOSING,
].join("\n"),
);
// The cache hit skips lowering and repeats the warning.
const cached = await build();
expect(cached.code).toBe(0);
expect(cached.stderr).not.toContain("scriptc lowering");
expect(cached.stderr).toContain(HEADER);
expect(cached.stderr).toContain(CLOSING);
const silenced = await build(["--no-dynamic-tier-warning"]);
expect(silenced.code).toBe(0);
expect(silenced.stderr).not.toContain("SC6101");
} finally {
await rm(dir, { recursive: true, force: true });
}
}, 300_000);
test("untyped setup code builds without the warning", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-quiet-"));
try {
await mkdir(join(dir, "cache"), { mode: 0o700 });
const result = await cli(
["build", join(fixtures, "quiet.ts"), "-o", join(dir, "program")],
join(dir, "cache"),
);
expect(result.code).toBe(0);
expect(result.stderr).not.toContain("SC6101");
} finally {
await rm(dir, { recursive: true, force: true });
}
}, 300_000);
test("coverage --fail-on=dynamic fails on the warning, and --no-dynamic-tier-warning cannot hide it", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-fail-on-"));
try {
await mkdir(join(dir, "cache"), { mode: 0o700 });
const coverage = (file: string, extra: string[]) =>
cli(["coverage", join(fixtures, file), ...extra], join(dir, "cache"));
const hot = await coverage("hot.js", ["--fail-on=dynamic"]);
expect(hot.code).toBe(1);
expect(hot.stdout).toContain("untyped and likely hot");
expect((await coverage("hot.js", ["--fail-on=divergences"])).code).toBe(0);
expect((await coverage("quiet.ts", ["--fail-on=dynamic"])).code).toBe(0);
const silenced = await coverage("hot.js", ["--no-dynamic-tier-warning"]);
expect(silenced.code).toBe(0);
expect(silenced.stdout).not.toContain("untyped and likely hot");
const conflict = await coverage("hot.js", ["--fail-on=dynamic", "--no-dynamic-tier-warning"]);
expect(conflict.code).toBe(1);
expect(conflict.stderr).toContain(
"--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
}, 300_000);
+42 -39
View File
@@ -1379,13 +1379,13 @@ interface ImportMeta {
* `recursive`); empty directories go through `rmdirSync`. */
declare module "node:fs" {
import { Readable, Writable } from "stream";
export function readFileSync(path: string | number, encoding: BufferEncoding): string;
export function readFileSync(path: string | number | URL, encoding: BufferEncoding): string;
export function readFileSync(
path: string | number,
path: string | number | URL,
options: { encoding: BufferEncoding },
): string;
export function readFileSync(path: string | number): Buffer;
export function readFileSync(path: string, options: unknown): string | Buffer;
export function readFileSync(path: string | number | URL): Buffer;
export function readFileSync(path: string | URL, options: unknown): string | Buffer;
/* Error-first callbacks select string or Buffer data from their encoding.
* Unknown options retain that union until the caller validates the value. */
export function readFile(
@@ -1397,7 +1397,7 @@ declare module "node:fs" {
* Node — an existing file keeps its permissions) and/or the utf8
* encoding spelling. */
export function writeFileSync(
path: string,
path: string | URL,
data: string,
/* The options-record stance (see http.RequestOptions): flag fences
* by name at the call site; undocumented keys drop like Node. */
@@ -1409,14 +1409,17 @@ declare module "node:fs" {
},
): void;
/* The bare-encoding spelling — the options record's encoding key alone. */
export function writeFileSync(path: string, data: string, encoding: "utf8" | "utf-8"): void;
export function writeFileSync(path: string, data: string | Uint8Array): void;
export function appendFileSync(path: string, data: string | Uint8Array): void;
export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void;
export function writeFileSync(path: string | URL, data: string | Uint8Array): void;
export function appendFileSync(path: string | URL, data: string | Uint8Array): void;
export function existsSync(path: string): boolean;
export function mkdirSync(path: string): void;
export function mkdirSync(path: string, options: { recursive?: boolean; mode?: number }): void;
export function unlinkSync(path: string): void;
export function chmodSync(path: string, mode: number): void;
export function mkdirSync(path: string | URL): void;
export function mkdirSync(
path: string | URL,
options: { recursive?: boolean; mode?: number },
): void;
export function unlinkSync(path: string | URL): void;
export function chmodSync(path: string | URL, mode: number): void;
export function fchmodSync(fd: number, mode: number): void;
export function fsyncSync(fd: number): void;
export function linkSync(
@@ -1514,29 +1517,29 @@ declare module "node:fs" {
path: string | Uint8Array | URL,
options: string | { encoding?: string | null } | null,
): string | Buffer;
export function chownSync(path: string, uid: number, gid: number): void;
export function lchownSync(path: string, uid: number, gid: number): void;
export function chownSync(path: string | URL, uid: number, gid: number): void;
export function lchownSync(path: string | URL, uid: number, gid: number): void;
export function fchownSync(fd: number, uid: number, gid: number): void;
export function truncateSync(path: string, len?: number): void;
export function truncateSync(path: string | URL, len?: number): void;
/* The 2-argument form only (Node's mode flags have no lowering). The
* destination is created or truncated carrying the SOURCE's mode. */
export function copyFileSync(src: string, dest: string): void;
export function copyFileSync(src: string | URL, dest: string | URL): void;
export function rename(
oldPath: string,
newPath: string,
callback: (err: NodeJS.ErrnoException | null) => void,
): void;
export function renameSync(oldPath: string, newPath: string): void;
export function rmSync(path: string): void;
export function renameSync(oldPath: string | URL, newPath: string | URL): void;
export function rmSync(path: string | URL): void;
/* maxRetries/retryDelay are accepted no-ops (Node retries around
* EBUSY-class races; the synchronous lowering has no re-entrancy that
* needs them). */
export function rmSync(
path: string,
path: string | URL,
options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number },
): void;
export function rmdirSync(path: string): void;
export function readdirSync(path: string): string[];
export function rmdirSync(path: string | URL): void;
export function readdirSync(path: string | URL): string[];
/* The withFileTypes form: Dirent rows (name + parentPath + the type
* probes — the honest subset of Node's Dirent surface). The type is
* interned explicitly in type-mapper.ts (the record's hidden %dtype field
@@ -1550,7 +1553,7 @@ declare module "node:fs" {
isSymbolicLink(): boolean;
}
export function readdirSync(
path: string,
path: string | URL,
/* The options-record stance (see http.RequestOptions): recursive
* fences by name at the call site; undocumented keys drop like Node. */
options: { withFileTypes: true; recursive?: boolean; [option: string]: unknown },
@@ -1599,7 +1602,7 @@ declare module "node:fs" {
encoding?: "utf8" | "utf-8";
bufferSize?: number;
}
export function opendirSync(path: string, options?: OpenDirOptions): Dir;
export function opendirSync(path: string | URL, options?: OpenDirOptions): Dir;
/* fs.ReadStream / fs.WriteStream: runtime Readable and Writable streams
* over a file (scr_fs_stream.c). The options are checked at runtime
* with Node's rules; flags and mode are checked when the file opens, so
@@ -1641,8 +1644,8 @@ declare module "node:fs" {
path: string | Buffer | URL,
options?: BufferEncoding | WriteStreamOptions,
): WriteStream;
export function accessSync(path: string, mode?: number): void;
export function mkdtempSync(prefix: string): string;
export function accessSync(path: string | URL, mode?: number): void;
export function mkdtempSync(prefix: string | URL): string;
export const constants: {
readonly F_OK: number;
readonly R_OK: number;
@@ -1745,34 +1748,34 @@ declare module "node:fs" {
bigint?: boolean;
throwIfNoEntry?: boolean;
}
export function statSync(path: string, options?: undefined): Stats;
export function statSync(path: string | URL, options?: undefined): Stats;
export function statSync(
path: string,
path: string | URL,
options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false },
): Stats | undefined;
export function statSync(
path: string,
path: string | URL,
options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true },
): Stats;
/* bigint: true type-checks so the lowering can name it as unsupported. */
export function statSync(path: string, options: StatSyncOptions & { bigint: true }): Stats;
export function statSync(path: string, options: StatSyncOptions): Stats | undefined;
export function lstatSync(path: string, options?: undefined): Stats;
export function statSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats;
export function statSync(path: string | URL, options: StatSyncOptions): Stats | undefined;
export function lstatSync(path: string | URL, options?: undefined): Stats;
export function lstatSync(
path: string,
path: string | URL,
options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false },
): Stats | undefined;
export function lstatSync(
path: string,
path: string | URL,
options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true },
): Stats;
/* bigint: true type-checks so the lowering can name it as unsupported. */
export function lstatSync(path: string, options: StatSyncOptions & { bigint: true }): Stats;
export function lstatSync(path: string, options: StatSyncOptions): Stats | undefined;
export function lstatSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats;
export function lstatSync(path: string | URL, options: StatSyncOptions): Stats | undefined;
/* The fd pair behind spawn's fd-stdio form: openSync(path, flags) →
* the raw fd. Numeric flags use an inline OR of the O_* constants. */
export function openSync(path: string, flags: string): number;
export function openSync(path: string, flags: number, mode?: number): number;
export function openSync(path: string | URL, flags: string): number;
export function openSync(path: string | URL, flags: number, mode?: number): number;
export function closeSync(fd: number): void;
export function fdatasyncSync(fd: number): void;
export function ftruncateSync(fd: number, len?: number): void;
@@ -1853,9 +1856,9 @@ declare module "node:fs" {
export function fstatSync(fd: number): Stats;
/* realpath(3) — resolves symlinks, `.`/`..`, throwing Node's fs error
* shapes for missing paths. */
export function realpathSync(path: string): string;
export function realpathSync(path: string | URL): string;
export namespace realpathSync {
function native(path: string): string;
function native(path: string | URL): string;
}
/* statfs(2)/statvfs(3) — the filesystem-capacity snapshot (the fields
* Node's statfsSync reports; bavail × bsize is the free-space probe). */
Binary file not shown.
@@ -23,6 +23,7 @@
* is little-endian, which the short-string words assume.
*/
import type { IrType } from "../../ir/ir.js";
import { SCR_ARR_FIELD } from "./common.js";
import { FN_ATTRS } from "./shapes.js";
export type ArraySearchKind = "ptr" | "str";
@@ -72,7 +73,7 @@ export function emitArraySearchHelpers(
` %dense = icmp eq ${sz} %f, %len`,
` br i1 %dense, label %scan, label %slow`,
`scan:`,
` %dp = getelementptr inbounds %ScrArr, ptr %a, i32 0, i32 8`,
` %dp = getelementptr inbounds %ScrArr, ptr %a, i32 0, i32 ${SCR_ARR_FIELD.data}`,
` %data = load ptr, ptr %dp`,
);
if (kind === "ptr") {
+1 -3
View File
@@ -21,9 +21,7 @@ export const SCR_ARR_FIELD = {
cyc: 4,
spare: 5,
filled: 6,
ops: 7,
data: 8,
ext: 9,
data: 7,
} as const;
/** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a
@@ -1,4 +1,5 @@
/* LlEmitter's module-level output: module parts, helper definitions, library declarations and FFI callback definitions. */
import { relieveHeapPressure } from "../../heap-pressure.js";
import { emitWasmModules } from "./wasm-modules.js";
import { type LlEmitter, ffiCallbackDummyLl, llStrBytes } from "./emitter.js";
import { JS_IC_WORDS } from "./js-ic.js";
@@ -66,6 +67,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
const def = emitter.emitFunction(fn);
fnDefs.push(def);
emitter.fnDefText.set(fn.name, def);
relieveHeapPressure();
}
emitter.selectStacklessAsync(fnDefs);
const errorViews: string[] = [];
@@ -450,15 +452,17 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
`%ScrFfiTable = type { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, i8, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`,
`%ScrRegex = type { ${emitter.sizeType}, ptr, ptr, ptr, double, ptr, ptr, ptr }`,
// ScrArr mirrors scr_runtime.h field-for-field: { rc, len (i32), cap
// (i32), elem, cyc, spare, filled, ops, data, ext } (field numbers in
// (i32), elem, cyc, spare, filled, data } (field numbers in
// SCR_ARR_FIELD), where the dense states follow the cap value slots at
// data, filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor
// (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse,
// property and metadata storage (NULL until first used). Live dynamic
// stream commits swap its dense, presence and out-of-line storage while
// data, filled bounds the hole-free prefix, and spare holds the storage
// flags and the registry id of the element type's RC descriptor
// (%ScrArrOps, SCR_ELEM_REF only; the runtime assigns ids). An array
// with out-of-line sparse, property or metadata storage keeps a pointer
// to it in the word before its dense allocation. Live dynamic stream
// commits swap its dense, presence and out-of-line storage while
// preserving the target object's identity.
`%ScrArr = type { ${emitter.sizeType}, i32, i32, i8, i8, i16, i32, ptr, ptr, ptr }`,
`%ScrArrOps = type { ptr, ptr, ptr }`,
`%ScrArr = type { ${emitter.sizeType}, i32, i32, i8, i8, i16, i32, ptr }`,
`%ScrArrOps = type { ptr, ptr, ptr, i32 }`,
// The ScrMap prefix { rc, key_kind, val_kind, traced } — the inline
// RC fast paths test the traced byte (shapes.ts).
...(emitter.rcHelpers === null ? [] : [`%ScrMapRc = type { ${emitter.sizeType}, i8, i8, i8 }`]),
@@ -720,7 +724,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
wide ? `ptr ${s}` : `<{ ptr, i32 }> <{ ptr ${s}, i32 0 }>`;
out.push(
`@${inst.sym}_data = internal constant <{ [${n} x ${slot}], [${n} x i8] }> <{ [${n} x ${slot}] [ ${inst.slots.map(slotValue).join(", ")} ], [${n} x i8] ${present} }>, align 8`,
`@${inst.sym} = internal global %ScrArr { ${emitter.sizeType} -1, i32 ${n}, i32 ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr null }`,
`@${inst.sym} = internal global %ScrArr { ${emitter.sizeType} -1, i32 ${n}, i32 ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr @${inst.sym}_data }`,
);
}
if (emitter.templateStringsInstances.size > 0) out.push(``);
+26 -2
View File
@@ -1,3 +1,4 @@
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
import { JsIcSites } from "../../ir/js-ic.js";
@@ -458,6 +459,8 @@ export function emitLlvmModuleSource(
input: IrModule,
options: LlvmTargetOptions = {},
): string | readonly string[] {
// IR validation, contracts and the frontend's disposal run after lowering without checks.
relieveHeapPressure(true);
const mod = unshareNodes(input);
const parts = new LlEmitter(
options.debugSources === undefined
@@ -2493,8 +2496,14 @@ export class LlEmitter {
* releases the dummy (NULL for refcounted kinds) harmlessly. A scalar
* call result of the function's return type may be passed as the unwind's
* dummy value (see emitUnwind). `poll` marks a worker termination poll
* (function entry or loop budget), whose test must stay ordered. */
emitPendingCheck(passThrough?: { name: string; type: IrType }, poll = false): void {
* (function entry or loop budget), whose test must stay ordered. A
* `guard` (an i1) limits the test to the paths where it is true: false
* where the joined call could not have left anything pending. */
emitPendingCheck(
passThrough?: { name: string; type: IrType },
poll = false,
guard?: string,
): void {
const through =
passThrough !== undefined &&
this.llType(passThrough.type) === this.llType(this.currentReturnType) &&
@@ -2503,6 +2512,13 @@ export class LlEmitter {
: undefined;
const B = this.B;
if (B.isTerminated()) return;
let skip: string | undefined;
if (guard !== undefined) {
const test = B.newLabel("exc.t");
skip = B.newLabel("exc.n");
B.condBr(guard, test, skip);
B.startBlock(test);
}
if (this.mod.lib !== undefined) {
this.declare(`declare zeroext i1 @scr_exc_pending()`);
const p = B.tmp();
@@ -2513,6 +2529,10 @@ export class LlEmitter {
B.startBlock(lu);
this.emitUnwind(through);
B.startBlock(lk);
if (skip !== undefined) {
B.br(skip);
B.startBlock(skip);
}
return;
}
// Executables test the active exception cell's kind inline (its first
@@ -2585,6 +2605,10 @@ export class LlEmitter {
B.startBlock(lu);
this.emitUnwind(through);
B.startBlock(lk);
if (skip !== undefined) {
B.br(skip);
B.startBlock(skip);
}
}
/** After a runtime call that ALWAYS leaves an exception pending (the
@@ -921,9 +921,18 @@ export function emitCallExpr(
? `@${mangleBorrowedFunction(known!.name)}`
: `@${mangleBorrowingWrapper(known!.name)}`
: undefined;
// A known target outside the may-throw set leaves nothing pending, so
// only the entry through the closure's own pointer needs the test.
// Worker programs keep it on every path: there it also observes
// termination, which loops count on (callTestsPending).
const quiet =
known !== undefined && !host.mayThrow.has(known.name) && host.mod.workers !== true;
let pending = host.indirectMayThrow;
let pendingGuard: string | undefined;
let t: string;
if (direct && e.callee.kind === "closure") {
t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
if (quiet) pending = false;
} else {
const fnp = B.tmp(),
fn = B.tmp();
@@ -983,17 +992,21 @@ export function emitCallExpr(
B.line(
`${t} = phi ${host.llType(e.type)} [ ${specialized}, %${fastEnd} ], [ ${indirect}, %${fallbackEnd} ]`,
);
if (quiet && pending) {
pendingGuard = B.tmp();
B.line(`${pendingGuard} = phi i1 [ false, %${fastEnd} ], [ true, %${fallbackEnd} ]`);
}
}
}
if (e.type.kind === "void") {
if (host.indirectMayThrow) host.emitPendingCheck();
if (pending) host.emitPendingCheck(undefined, false, pendingGuard);
if (lent) host.releaseTemps(host.frames.pop()!);
return { name: "", type: e.type };
}
// The check runs AFTER the result temp joins the frame: an unwind
// releases it (the dummy is NULL for refcounted returns).
const out = host.own({ name: t, type: e.type });
if (host.indirectMayThrow) host.emitPendingCheck();
if (pending) host.emitPendingCheck(undefined, false, pendingGuard);
if (lent) {
host.toFrame(out);
host.releaseTemps(host.frames.pop()!);
@@ -45,6 +45,8 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js";
import { emitScratchStringSlice, emitStringSliceRead } from "./string-slices.js";
import {
STRING_CHAR_AT_DECL,
STRING_CHAR_AT_INT_MARKER,
STRING_CHAR_CODE_AT_DECL,
STRING_CHAR_CODE_AT_INT_MARKER,
STRING_UTF16_LEN_DECL,
@@ -325,14 +327,21 @@ export function emitStrIntrinsic(
B.line(`${t} = call double @sc_str_char_code_at(ptr ${r.name}, double ${args[0]!.name})`);
return { name: t, type: e.type };
}
case "charAt":
return call(
"scr_str_char_at",
"ptr (ptr, double)",
`ptr ${r.name}, double ${args[0]!.name}`,
"ptr",
true,
);
case "charAt": {
// sc_str_char_at (string-reads.ts) answers proven-ASCII strings inline.
host.declare(STRING_CHAR_AT_DECL);
const t = B.tmp();
const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null;
if (index) {
host.declare(STRING_CHAR_AT_INT_MARKER);
const wide = widenInteger(host, index);
B.line(`${t} = call ptr @sc_str_char_at_int(ptr ${r.name}, i64 ${wide})`);
} else {
const at = args[0]?.name ?? f64Lit(0);
B.line(`${t} = call ptr @sc_str_char_at(ptr ${r.name}, double ${at})`);
}
return host.own({ name: t, type: e.type });
}
case "indexOf":
return call(
"scr_str_index_of",
@@ -638,10 +647,12 @@ export function emitArrIntrinsic(
return { name: t, type: e.type };
}
case "concatSpread": {
// Appends to concat's fresh result: its storage grows to exactly
// the combined length (scr_arr_concat_copy_exact).
const src = emitBorrowedInput(host, e.args[0]!);
host.declare(`declare double @scr_arr_concat_copy(ptr, ptr)`);
host.declare(`declare double @scr_arr_concat_copy_exact(ptr, ptr)`);
const t = B.tmp();
B.line(`${t} = call double @scr_arr_concat_copy(ptr ${r.name}, ptr ${src.name})`);
B.line(`${t} = call double @scr_arr_concat_copy_exact(ptr ${r.name}, ptr ${src.name})`);
return { name: t, type: e.type };
}
case "unshift": {
@@ -524,8 +524,15 @@ export function emitOperatorExpr(
case "seqExpr": {
// Statements mid-expression: each emits in place (its own frame,
// exactly statement position); the result is an ordinary temp of
// the current frame. No jump can leave the expression's region.
for (const s of e.stmts) host.emitStmt(s);
// the current frame. No jump can leave the expression's region. A
// statement can still end the block (a call that always throws):
// what follows then emits in a block with no predecessors, so every
// value it reads is defined there, and LLVM deletes it.
for (const s of e.stmts) {
if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead"));
host.emitStmt(s);
}
if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead"));
return host.emitExpr(e.result);
}
default: {
@@ -855,11 +862,15 @@ export function emitContainerExpr(
const elem = e.type.elem;
const arr = B.tmp();
const small = host.appendedArrayLiterals.has(e);
const capacity = small ? 0 : (e.capacity ?? e.elems.length);
const spreadSet = new Set(e.spreads ?? []);
// With spreads, storage starts at the plain elements before the first
// spread, and each spread reserves exactly its source's length plus
// the plain elements up to the next one (scr_arr_reserve_spread).
const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i));
const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread));
B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
const out = host.own({ name: arr, type: e.type });
const acc = elemAccess(elem);
const spreadSet = new Set(e.spreads ?? []);
let pending: LlValue[] = [];
let buffer: string | undefined;
const flush = (): void => {
@@ -874,6 +885,12 @@ export function emitContainerExpr(
if (spreadSet.has(i)) {
flush();
const v = host.emitExpr(el);
let plain = 0;
for (let j = i + 1; j < e.elems.length && !spreadSet.has(j); j++) plain++;
host.declare(`declare void @scr_arr_reserve_spread(ptr, ptr, ${host.sizeType})`);
B.line(
`call void @scr_arr_reserve_spread(ptr ${arr}, ptr ${v.name}, ${host.sizeType} ${plain})`,
);
host.emitArrayCopyLoop(arr, v.name, acc);
return;
}
@@ -895,6 +912,8 @@ export function emitContainerExpr(
const n = host.emitExpr(e.length);
const arr = B.tmp();
B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, "0")}`);
host.declare(`declare void @scr_arr_reserve_len(ptr, double)`);
B.line(`call void @scr_arr_reserve_len(ptr ${arr}, double ${n.name})`);
const out = host.own({ name: arr, type: e.type });
const acc = elemAccess(elem);
let fill = acc === "f64" ? f64Lit(0) : acc === "bool" ? "false" : "null";
@@ -36,6 +36,7 @@ import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context
import type { NullableRefField } from "./nullable-fields.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { inlineUnionCopyLines } from "./inline-unions.js";
import { recordMayKeepSource } from "./record-sources.js";
/** An owned (+1) union value for a nullable-pointer class field slot `p`
* (the unit arm's immortal instance, or a fresh box around a retained
@@ -176,6 +177,7 @@ export function streamTypedRefCommitAdapter(
const check = host.dyn.dynCheckHelper(t);
host.declare(`declare void @scr_arr_release(ptr)`);
host.declare(`declare void @scr_arr_outline(ptr)`);
host.declare(`declare void @scr_arr_adopt_view_properties(ptr, ptr, ptr)`);
const lines = [
`define internal void @${commit}(ptr %target, ptr %d) ${FN_ATTRS} { ; commit live stream element ${typeKey(t)}`,
`entry:`,
@@ -183,6 +185,8 @@ export function streamTypedRefCommitAdapter(
` %missing = icmp eq ptr %next, null`,
` br i1 %missing, label %done, label %swap`,
`swap:`,
// Properties move with the storage (in its out-of-line part).
` call void @scr_arr_adopt_view_properties(ptr %next, ptr %d, ptr %target)`,
// Inline slots live in each header's block; move them out first.
` call void @scr_arr_outline(ptr %target)`,
` call void @scr_arr_outline(ptr %next)`,
@@ -193,7 +197,10 @@ export function streamTypedRefCommitAdapter(
{ name: "cap", index: SCR_ARR_FIELD.cap, type: "i32" },
{ name: "data", index: SCR_ARR_FIELD.data, type: "ptr" },
{ name: "filled", index: SCR_ARR_FIELD.filled, type: "i32" },
{ name: "ext", index: SCR_ARR_FIELD.ext, type: "ptr" },
// The out-of-line storage's pointer lives before the dense allocation
// and moves with data; its flag is in spare, whose element ops id the
// two arrays share.
{ name: "spare", index: SCR_ARR_FIELD.spare, type: "i16" },
];
for (const member of storageMembers) {
lines.push(
@@ -533,6 +540,12 @@ export function streamTypedRefCommitAdapter(
` store ${member.type} %old${index}, ptr %np${index}`,
);
});
// The conversion kept the view's undeclared properties for %next. Shapes
// with accessor slots keep their accessors in the view instead.
if (recordMayKeepSource(shape) && !shapeHasAccessorSlots(shape)) {
host.declare(`declare void @scr_record_source_move(ptr, ptr)`);
lines.push(` call void @scr_record_source_move(ptr %next, ptr %target)`);
}
lines.push(
` call void ${releaseSym(host.shapeHost, t)}(ptr %next)`,
` br label %done`,
@@ -758,6 +758,14 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const inline = emitInlineAtomicsOp(host, e);
if (inline) return inline;
}
if (e.fn === "bytes.isAbsent") {
// A borrowed read: retaining an absent Buffer would dereference NULL.
const value = host.emitReadReceiver(e.args[0]!);
const t = host.B.tmp();
host.B.line(`${t} = icmp eq ptr ${value.name}, null`);
return { name: t, type: e.type };
}
if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type });
const prefix = e.fn.slice(0, e.fn.indexOf(".")) as LibCallPrefix;
// events.once builds typed listener adapters beside the emitter calls.
if (e.fn === "events.once" || e.fn === "events.onceSignal")
@@ -864,6 +872,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "events":
case "blob":
case "wasm":
case "array":
case "record":
case "disposable":
case "punycode":
+19 -15
View File
@@ -3,6 +3,7 @@ import type { IrType } from "../../ir/ir.js";
import { typeKey } from "../../ir/ir.js";
import type { LibCallExpr, LlValue, LlvmEmitterContext } from "./expr-context.js";
import {
type AbiArgument,
abiValue,
callbackAdapter,
callbackType,
@@ -84,6 +85,23 @@ function sniAnswer(host: LlvmEmitterContext, type: IrType): string {
return symbol;
}
/** The (closure, answer thunk) pair of an SNICallback argument: a function,
* or a function | undefined union whose undefined arm passes no callback. */
export function sniCallbackArgs(host: LlvmEmitterContext, cb: LlValue): AbiArgument[] {
let type = cb.type,
value = cb.name;
if (type.kind === "union") {
const arms = host.unionsById.get(type.unionId)?.arms ?? [];
const tag = arms.findIndex((arm) => arm.kind === "func");
if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm");
type = arms[tag]!;
value = host.unwrapNullableClosure(value, tag);
} else host.toHeap(cb);
if (type.kind !== "func" || type.params[1] === undefined)
throw new InternalCompilerError("SNI callback lacks its answer function");
return [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))];
}
export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const args = e.args.map((arg) => host.emitExpr(arg)),
a = args.map((arg) => abiValue(host, arg));
@@ -160,21 +178,7 @@ export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVa
let handler = [ptr(), ptr()],
sni = [ptr(), ptr()];
if (e.fn === "http2.createSecureServerReq") handler = requestHandler(host, args[2]!);
if (e.fn === "http2.createSecureServerSni") {
const cb = args[2]!;
let type = cb.type,
value = cb.name;
if (type.kind === "union") {
const arms = host.unionsById.get(type.unionId)?.arms ?? [];
const tag = arms.findIndex((arm) => arm.kind === "func");
if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm");
type = arms[tag]!;
value = host.unwrapNullableClosure(value, tag);
} else host.toHeap(cb);
if (type.kind !== "func" || type.params[1] === undefined)
throw new InternalCompilerError("SNI callback lacks its answer function");
sni = [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))];
}
if (e.fn === "http2.createSecureServerSni") sni = sniCallbackArgs(host, args[2]!);
return finishRuntimeCall(host, e, "scr_http2_create_secure_server_allow_http1", [
...rawBytes(host, args[0]!),
...rawBytes(host, args[1]!),
@@ -1495,6 +1495,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.bagGet": "scr_dyn_bag_get",
"dyn.bagSet": "scr_dyn_bag_set",
"dyn.objCreate": "scr_dyn_obj_create",
"dyn.nameClassPrototype": "scr_dyn_name_class_prototype",
"dyn.objCreateWithProperties": "scr_dyn_obj_create_with_properties",
"dyn.getPrototype": "scr_dyn_get_prototype",
"dyn.setPrototype": "scr_dyn_set_prototype",
@@ -1521,6 +1522,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"regex.groupNumber": "scr_regex_group_number",
"regex.groupParseInt": "scr_regex_group_parse_int",
"regex.resultProp": "scr_regex_result_prop",
"array.namedKeys": "scr_arr_named_keys",
"array.hasNamedKey": "scr_arr_has_named_key",
// queueMicrotask's checked-dynamic form (borrowed dyn; a non-function
// throws synchronously), the minted setImmediate value, and the
// timers/promises immediate — all mark the loop live.
@@ -274,10 +274,9 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
.filter((a) => a.tag >= 0);
if (present.length === 0)
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`);
const tagP = B.tmp();
const tag = B.tmp();
B.line(`${tagP} = getelementptr inbounds %ScrUnion, ptr ${args[1]!.name}, i64 0, i32 1`);
B.line(`${tag} = load i32, ptr ${tagP}`);
// A tagged box, or the arm pointer itself for a nullable union
// (`string | null`, `Buffer | null`).
const tag = host.unionTag(args[1]!.name, t.unionId);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca i1`);
const lj = B.newLabel("scu.j");
@@ -290,10 +289,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
B.line(`store i1 ${r}, ptr ${slot}`);
return;
}
const pk = B.tmp();
const pv = B.tmp();
B.line(`${pk} = getelementptr inbounds %ScrUnion, ptr ${args[1]!.name}, i64 0, i32 5`);
B.line(`${pv} = load ptr, ptr ${pk} ; borrowed payload`);
const pv = host.unionPeek(args[1]!.name, t.unionId); // borrowed payload
const r = B.tmp();
if (pushing) {
host.declare(`declare zeroext i1 @${entry}(ptr, ptr)`);
@@ -11,6 +11,7 @@ import {
rawBytes,
requestHandler,
} from "./lib-abi.js";
import { sniCallbackArgs } from "./lib-http2.js";
import { emitAlwaysThrowLibCall } from "./lib-shared.js";
export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
@@ -43,6 +44,27 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu
[...rawBytes(host, args[0]!), ...rawBytes(host, args[1]!), ...handler],
);
}
case "tls.createServerSni":
case "tls.createServerSniCb": {
const cb = args[3];
const handler =
cb === undefined
? [ptr(), ptr()]
: callbackAdapter(
host,
cb,
callbackType(cb).params.length === 0
? "scr_net_conn_thunk0"
: "scr_net_conn_thunk_sock",
["ptr", "ptr"],
);
return finishRuntimeCall(host, e, "scr_tls_create_server_sni", [
...rawBytes(host, args[0]!),
...rawBytes(host, args[1]!),
...handler,
...sniCallbackArgs(host, args[2]!),
]);
}
case "tls.createServerDyn":
case "tls.createServerDynCb":
case "https.createServerDyn":
@@ -105,6 +105,8 @@ const INERT_LIB_CALLS = new Set([
"regex.groupNumber",
"regex.groupParseInt",
"regex.resultProp",
"array.namedKeys",
"array.hasNamedKey",
"process.cwd",
"process.argv",
"process.execArgv",
+4 -3
View File
@@ -1074,15 +1074,16 @@ export function arrNewCall(
return `call ptr @scr_arr_new_ops(ptr ${arrOps(host, elem)}, ${host.sizeType} ${capText})`;
}
/** The constant %ScrArrOps descriptor (retain, release, trace) of a REF
* element type, defined once per module and shared by all its arrays. */
/** The %ScrArrOps descriptor (retain, release, trace, registry id) of a REF
* element type, defined once per module and shared by all its arrays. It is
* writable: the runtime stores its registry id on the first array. */
export function arrOps(host: ShapeHost, elem: IrType): string {
const v = vAdapters(host, elem);
const trace = traceArg(host, elem);
const part = (sym: string): string => sym.replace(/^@/, "").replace(/[^A-Za-z0-9_.]/g, "_");
const name = `@sc_arrops.${part(v.retain)}.${part(v.release)}.${part(trace)}`;
host.declare(
`${name} = internal constant %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace} }`,
`${name} = internal global %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace}, i32 0 }`,
);
return name;
}
@@ -311,7 +311,8 @@ const methodCases: {
// enters through the double ABI.
{ method: "charCodeAt", args: [num(1)], result: F64, target: "char_code_at_int", inline: true },
{ method: "charCodeAt", args: [num(1.5)], result: F64, target: "char_code_at", inline: true },
{ method: "charAt", args: [num(1)], result: STRING, target: "char_at" },
{ method: "charAt", args: [num(1)], result: STRING, target: "char_at_int", inline: true },
{ method: "charAt", args: [num(1.5)], result: STRING, target: "char_at", inline: true },
{ method: "indexOf", args: [ref("argument"), num(1)], result: F64, target: "index_of" },
{ method: "includes", args: [ref("argument")], result: BOOL, target: "includes" },
{
@@ -18,6 +18,8 @@
*/
import { exactIndexLines, sizeToDouble } from "./common.js";
import { wtf8Bytes } from "../literals.js";
import { literalCapWord, literalCapWord32 } from "./string-key-hash.js";
export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)";
export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)";
@@ -31,6 +33,9 @@ const READ_CALL_ATTRS = "cold nocallback nounwind willreturn";
/** Requests the integer-index helper below; an LLVM comment line among the
* declarations, so it never declares anything itself. */
export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int";
export const STRING_CHAR_AT_DECL = "declare ptr @scr_str_char_at(ptr, double)";
/** Requests the integer-index charAt helper, as the marker above does. */
export const STRING_CHAR_AT_INT_MARKER = "; uses @sc_str_char_at_int";
/** Load the proven-ASCII flag of `%s` into `%ascii`. */
function asciiTest(): string[] {
@@ -85,6 +90,34 @@ function scanRead(): string[] {
];
}
/** The one-character strings of the ASCII range as immortal literals
* ({ rc = SIZE_MAX, len = 1, capacity word, char, NUL }), indexed by
* character code: the inline charAt result. Their bytes, capacity words
* and key hashes equal those of the runtime's single-character statics, so
* they are interchangeable with the strings the runtime returns. */
function asciiCharTable(sz: string): string[] {
const type = `{ ${sz}, ${sz}, ${sz}, [2 x i8] }`;
const entries: string[] = [];
for (let c = 0; c < 128; c++) {
const bytes = wtf8Bytes(String.fromCharCode(c));
const word = sz === "i64" ? literalCapWord(bytes) : literalCapWord32(bytes);
entries.push(`${type} { ${sz} -1, ${sz} 1, ${sz} ${word}, [2 x i8] [i8 ${c}, i8 0] }`);
}
return [`@sc_ascii_chars = internal constant [128 x ${type}] [${entries.join(", ")}]`];
}
/** The table entry for byte `%byte` of a proven-ASCII string. */
function asciiCharRead(sz: string): string[] {
return [
` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
` %p = getelementptr inbounds i8, ptr %data, i64 %k`,
` %byte = load i8, ptr %p`,
` %code = zext i8 %byte to i64`,
` %char = getelementptr inbounds [128 x { ${sz}, ${sz}, ${sz}, [2 x i8] }], ptr @sc_ascii_chars, i64 0, i64 %code`,
` ret ptr %char`,
];
}
export interface StringReadHelper {
decl: string;
define(sz: string, attrs: string, scan: boolean): string[];
@@ -200,4 +233,57 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
`}`,
],
},
{
// charAt of a proven-ASCII string at an exact in-range index is the
// table entry for its byte; every other case calls the runtime, which
// keeps the complete semantics. The table is defined with the helper.
decl: STRING_CHAR_AT_DECL,
define: (sz, attrs) => {
let n = 0;
const exactIndex = exactIndexLines("%i", "%len", sz, () => `%ix${n++}`);
return [
...asciiCharTable(sz),
``,
`define internal ptr @sc_str_char_at(ptr %s, double %i) ${attrs} {`,
`entry:`,
...asciiTest(),
` br i1 %ascii, label %bounds, label %slow`,
`bounds:`,
...loadLength(sz),
...exactIndex.lines.map((line) => ` ${line}`),
` br i1 ${exactIndex.ok}, label %read, label %slow`,
`read:`,
` %k = add i64 ${exactIndex.wide}, 0`,
...asciiCharRead(sz),
`slow:`,
` %r = call ptr @scr_str_char_at(ptr %s, double %i) ${READ_CALL_ATTRS}`,
` ret ptr %r`,
`}`,
];
},
},
{
// An exact integer index, as for sc_str_char_code_at_int. Requested
// together with STRING_CHAR_AT_DECL, which defines the table.
decl: STRING_CHAR_AT_INT_MARKER,
define: (sz, attrs) => [
`define internal ptr @sc_str_char_at_int(ptr %s, i64 %i) ${attrs} {`,
`entry:`,
...asciiTest(),
` br i1 %ascii, label %bounds, label %slow`,
`bounds:`,
...loadLength(sz),
...(sz === "i64" ? [] : [` %len64 = zext ${sz} %len to i64`]),
` %ok = icmp ult i64 %i, ${sz === "i64" ? "%len" : "%len64"}`,
` br i1 %ok, label %read, label %slow`,
`read:`,
` %k = add i64 %i, 0`,
...asciiCharRead(sz),
`slow:`,
` %d = sitofp i64 %i to double`,
` %r = call ptr @scr_str_char_at(ptr %s, double %d) ${READ_CALL_ATTRS}`,
` ret ptr %r`,
`}`,
],
},
];
@@ -168,30 +168,6 @@ test("resolves a package helper, emits atomically, and caches by all native inpu
expect(secondArtifact.dependencies).toEqual(firstArtifact.dependencies);
});
test("runtime bitcode imports reach the helper and key the native cache", async () => {
const pkg = await fakePackage();
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
await emitNativeArtifact(base);
const imported = {
...request(pkg.root, pkg.packageJson, join(pkg.root, "imported.o")),
importBitcode: {
paths: ["/pack/a.bc", "/pack/b.bc"],
digests: ["a".repeat(64), "b".repeat(64)],
},
};
await emitNativeArtifact(imported);
await emitNativeArtifact({
...imported,
outputPath: join(pkg.root, "changed.o"),
importBitcode: { ...imported.importBitcode, digests: ["a".repeat(64), "c".repeat(64)] },
});
await emitNativeArtifact({ ...imported, outputPath: join(pkg.root, "again.o") });
const calls = (await readFile(pkg.log, "utf8")).trim().split("\n");
expect(calls).toHaveLength(3);
expect(calls[0]).not.toContain("--import-bitcode");
expect(calls[1]).toContain("--import-bitcode /pack/a.bc --import-bitcode /pack/b.bc");
});
test("profiles reach the helper and key the native cache by their bytes", async () => {
const pkg = await fakePackage();
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
@@ -111,8 +111,6 @@ export interface NativeCodegenOptions {
cacheRoot?: string | null;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
/** Runtime bitcode whose small functions the helper imports for inlining. */
importBitcode?: { paths: readonly string[]; digests: readonly string[] };
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
}
@@ -339,11 +337,6 @@ function cacheKey(
.update("\0")
.update(options.sourcePath)
.update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "")
.update(
options.importBitcode === undefined
? ""
: `\0import:${options.importBitcode.digests.join(",")}`,
)
.update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`)
.digest("hex");
}
@@ -482,7 +475,6 @@ export async function emitNativeArtifact(
"json",
"--source-path",
options.sourcePath,
...(options.importBitcode?.paths ?? []).flatMap((path) => ["--import-bitcode", path]),
...(partitionCache === null ? [] : ["--cache-dir", partitionCache]),
...nativeProfileHelperArgs(options.profile),
]);
@@ -3,8 +3,8 @@
* - `release` (default): -O2 program code against the release runtime flavor.
* - `dev`: -O0, source line tables, and cached object shards.
* - `speed`: -O2 plus optimizations that trade binary size and build time for
* run-time speed (a size-for-speed runtime flavor, runtime bitcode import,
* inline reference counting). Release output never depends on them.
* run-time speed (a size-for-speed runtime flavor, inline reference
* counting). Release output never depends on them.
*
* Speed is an optimized posture everywhere release is: it shares -O2, release
* linker flags, and release-only program partitioning. Cache keys spell it
@@ -9,7 +9,6 @@ import type { NativeLinkFeatures } from "./native-link-info.js";
import {
effectiveRuntimeFeatures,
evaluateRuntimePredicate,
loadRuntimeBitcode,
loadRuntimePack,
parseRuntimePackManifest,
RuntimePackError,
@@ -262,80 +261,10 @@ describe("runtime pack manifests", () => {
expect(fileHandle.archives.map((path) => path.split("/").at(-1))).toEqual(["zlib.a"]);
});
test("runtime bitcode follows the selected flavor variant and is verified", async () => {
const { root, packagePath, manifest } = await fixture();
const bitcode = async (path: string, bytes: string) => {
await writeFile(join(root, path), bytes);
return {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
size: Buffer.byteLength(bytes),
};
};
const unit = manifest.flavors.release!.runtime_units[0]!;
const withBitcode: RuntimePackManifest = {
...manifest,
flavors: {
...manifest.flavors,
speed: {
optimization: "-O2",
runtime_units: [
{
...unit,
variants: [
{ ...unit.variants[0]!, bitcode: await bitcode("artifacts/base.bc", "base-bc") },
{
...unit.variants[1]!,
bitcode: await bitcode("artifacts/legacy.bc", "legacy-bc"),
},
unit.variants[2]!,
],
},
],
},
},
};
const resolver = () => packagePath;
const speed = { target: MACOS_ARM64_TARGET, optimization: "speed" } as const;
// Release programs import their own units' bitcode. Speed programs
// never import release bitcode: without a speed flavor they import none.
await writeFile(
join(root, "runtime-pack.json"),
JSON.stringify({
...withBitcode,
flavors: { ...withBitcode.flavors, release: withBitcode.flavors.speed, speed: undefined },
}),
);
expect(await loadRuntimeBitcode({ ...speed, features: BASE, resolver })).toBeNull();
const release = await loadRuntimeBitcode({
target: MACOS_ARM64_TARGET,
optimization: "release",
features: BASE,
resolver,
});
expect(release?.paths.map((path) => basename(path))).toEqual(["base.bc"]);
await writeFile(join(root, "runtime-pack.json"), JSON.stringify(withBitcode));
const legacy = await loadRuntimeBitcode({
...speed,
features: { ...BASE, textDecoderLegacy: true },
resolver,
});
expect(legacy?.paths.map((path) => basename(path))).toEqual(["legacy.bc"]);
expect(legacy?.digests).toEqual([createHash("sha256").update("legacy-bc").digest("hex")]);
// A variant without bitcode imports nothing.
expect(
await loadRuntimeBitcode({
...speed,
features: { ...BASE, fileHandle: true },
resolver,
}),
).toBeNull();
await writeFile(join(root, "artifacts/base.bc"), "damaged");
await expect(loadRuntimeBitcode({ ...speed, features: BASE, resolver })).rejects.toThrow(
"hash mismatch",
);
const malformed = structuredClone(withBitcode);
malformed.flavors.speed!.runtime_units[0]!.variants[0]!.bitcode = {
test("runtime unit bitcode must stay inside the pack", async () => {
const { manifest } = await fixture();
const malformed = structuredClone(manifest);
malformed.flavors.release!.runtime_units[0]!.variants[0]!.bitcode = {
path: "../escape.bc",
sha256: "0".repeat(64),
size: 1,
@@ -46,8 +46,6 @@ export interface RuntimePackSelection {
sourceDependencies: NativeArtifactDependency[];
selectedRuntimeArtifacts: RuntimePackArtifact[];
selectedArchiveArtifacts: RuntimePackArtifact[];
/** Bitcode of the selected runtime units that ship it, in unit order. */
selectedRuntimeBitcode: RuntimePackArtifact[];
}
async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise<string> {
@@ -249,49 +247,5 @@ export async function loadRuntimePack(options: {
sourceDependencies,
selectedRuntimeArtifacts: selectedVariants,
selectedArchiveArtifacts: selectedArchives,
selectedRuntimeBitcode: selectedVariants.flatMap((variant) =>
variant.bitcode === undefined ? [] : [variant.bitcode],
),
};
}
export interface RuntimeBitcodeImport {
paths: string[];
/** Content digests, in path order, for cache keys. */
digests: string[];
dependencies: NativeArtifactDependency[];
}
/** Verified bitcode for the runtime units of the given flavor that a program
* links, for the helper's available_externally import. Release units carry
* their compiled bitcode reduced to the bodies that need no unit-local
* state; speed units carry their promoted bitcode. Null when the selected
* units ship no bitcode (a pack built without the helper, or no speed
* flavor). */
export async function loadRuntimeBitcode(options: {
target: NativeTargetSpec;
features: NativeLinkFeatures;
optimization: "release" | "speed";
env?: NodeJS.ProcessEnv;
resolver?: (specifier: string) => string;
}): Promise<RuntimeBitcodeImport | null> {
const pack = await loadRuntimePack(options);
// Without a speed flavor, speed programs link release units; only the
// speed flavor's promoted objects match speed bitcode.
if (options.optimization === "speed" && pack.manifest.flavors.speed === undefined) return null;
if (pack.selectedRuntimeBitcode.length === 0) return null;
const paths = await Promise.all(
pack.selectedRuntimeBitcode.map((artifact) => verifyArtifact(pack.root, artifact)),
);
let dependencies: NativeArtifactDependency[];
try {
dependencies = await snapshotDependencies([pack.manifestPath, ...paths]);
} catch {
throw new RuntimePackError("runtime pack bitcode changed during selection", "invalid");
}
return {
paths,
digests: pack.selectedRuntimeBitcode.map((artifact) => artifact.sha256),
dependencies,
};
}
+32 -8
View File
@@ -13,6 +13,7 @@ import {
import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js";
import { buildEnvelope } from "../diagnostics/envelope.js";
import { renderWarnings } from "../diagnostics/render.js";
import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js";
import { isNativeOptimization } from "../backend/optimization.js";
import { setProvenanceSources } from "../frontend/provenance-registry.js";
import type { CliHost, NativeCacheWarmProfile } from "./host.js";
@@ -284,8 +285,20 @@ async function main(args: string[], host: CliHost): Promise<number> {
fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`);
}
const failOnRaw = values["fail-on"];
if (failOnRaw !== undefined && failOnRaw !== "blockers" && failOnRaw !== "divergences") {
fail(`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences)\n\n${USAGE}`);
if (
failOnRaw !== undefined &&
failOnRaw !== "blockers" &&
failOnRaw !== "divergences" &&
failOnRaw !== "dynamic"
) {
fail(
`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`,
);
}
// --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts).
const dynamicTierWarning = values["dynamic-tier-warning"] !== false;
if (failOnRaw === "dynamic" && !dynamicTierWarning) {
fail(`--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out\n\n${USAGE}`);
}
if (failOnRaw !== undefined && command !== "coverage") {
fail(`--fail-on is a coverage option (a build already fails on blockers)\n\n${USAGE}`);
@@ -430,6 +443,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
...(explainDynamic !== undefined ? { explainDynamic: true } : {}),
...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}),
});
if (!dynamicTierWarning) delete coverage.performance;
if (printDiagnostics) {
// Keep stdout pure JSON for tooling.
const envelope = coverageEnvelope(coverage, {
@@ -525,7 +539,9 @@ async function main(args: string[], host: CliHost): Promise<number> {
process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`);
throw new CliExit(1);
}
const warnings = result.warnings ?? [];
const warnings = (result.warnings ?? []).filter(
(d) => dynamicTierWarning || !isPerformanceCode(d.code),
);
if (printDiagnostics) {
diagnosticsEnvelope = buildEnvelope({
compilerVersion: host.version(),
@@ -538,12 +554,20 @@ async function main(args: string[], host: CliHost): Promise<number> {
});
} else if (warnings.length > 0 && command === "build") {
// Divergence warnings never fail a build; they explain where the
// native program can behave differently from Node. `run` keeps
// stderr for the program's own output.
// native program can behave differently from Node. The performance
// warning names likely-hot functions that use untyped values. `run`
// keeps stderr for the program's own output.
const color = process.stderr.isTTY ?? false;
process.stderr.write(
renderWarnings(warnings, result.sourceTexts ?? new Map(), { color }) + "\n",
);
const divergences = warnings.filter((d) => !isPerformanceCode(d.code));
const performance = warnings.filter((d) => isPerformanceCode(d.code));
if (divergences.length > 0)
process.stderr.write(
renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n",
);
if (performance.length > 0) {
if (divergences.length > 0) process.stderr.write("\n");
process.stderr.write(renderPerformanceWarnings(performance) + "\n");
}
}
if (result.artifact.kind === "exe") {
if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true });
+7 -2
View File
@@ -31,10 +31,11 @@ Options:
(native-link-info implies --emit=obj and never links;
diagnostics prints the versioned JSON diagnostics
envelope for build and coverage)
--fail-on <blockers|divergences>
--fail-on <blockers|divergences|dynamic>
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences
divergences, or also when it has likely-hot functions
that use untyped values (SC6101)
--backend <b> code generator (llvm)
--optimization <release|dev|speed>
native optimization posture (default: release/-O2). dev
@@ -105,6 +106,9 @@ Options:
values and the bindings, fields, functions, classes and
modules the dynamic tier would take over, with the
refusal behind each; a name shows only that unit's chain
--no-dynamic-tier-warning
build and coverage: leave out SC6101, the warning that
names likely-hot functions that use untyped values
--wasm <file.wasm>
coverage only: report a WebAssembly module the program
loads whose path its sources do not spell (repeatable;
@@ -137,6 +141,7 @@ export const CLI_OPTIONS = {
"external-types": { type: "string", multiple: true },
tier: { type: "string" },
"explain-dynamic": { type: "string" },
"dynamic-tier-warning": { type: "boolean", default: true },
wasm: { type: "string", multiple: true },
lib: { type: "boolean", default: false },
profile: { type: "string" },
+22 -2
View File
@@ -42,6 +42,10 @@ export interface CoverageInput {
* the program compiles, but these sites can behave differently natively.
* Never blockers; `--fail-on=divergences` opts into failing on them. */
divergences?: ScrDiagnostic[];
/** The performance warning (SC6101): likely-hot functions the source
* leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing
* on it. */
performance?: ScrDiagnostic[];
/** Each dependency's native compilation outcome, with the first refusal
* reason for a refused package. */
npmStatic?: NpmStaticStatus[];
@@ -333,6 +337,17 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
);
renderRows(groupBlockers(divergences), YELLOW);
};
// The performance warning: its message names the functions.
const performance = input.performance ?? [];
const renderPerformance = () => {
for (const d of performance) {
out.push("");
const [head, ...rest] = d.message.split("\n");
out.push(` ${c(YELLOW, "untyped and likely hot")} ${head}`);
for (const line of rest) out.push(` ${line}`);
if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`);
}
};
const unreachedDiags = [...(un?.diagnostics ?? []), ...(un?.runtimeFences ?? [])];
if (failed === 0 && input.diagnostics.length === 0 && unreachedDiags.length === 0) {
@@ -341,6 +356,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
renderDivergences();
}
renderPerformance();
return out.join("\n");
}
@@ -381,6 +397,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions
out.push("");
renderDivergences();
}
renderPerformance();
return out.join("\n");
}
@@ -449,8 +466,9 @@ function displayPath(file: string, root: string | undefined): string {
}
/** What `coverage --fail-on` checks: reached blockers (they fail a build)
* and, optionally, divergences. Unreached blockers never fail. */
export type CoverageFailOn = "blockers" | "divergences";
* and, optionally, divergences or the performance warning. Unreached
* blockers never fail. */
export type CoverageFailOn = "blockers" | "divergences" | "dynamic";
/** Whether the analysis passes the selected gate. Preflight failures
* always fail; `failOn` undefined means only preflight fails (the report's
@@ -462,6 +480,7 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b
// runtime fences build (the statement throws only if it executes).
if (input.diagnostics.length > 0) return false;
if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false;
if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false;
return true;
}
@@ -492,6 +511,7 @@ export function coverageEnvelope(
add(input.unreached?.diagnostics, "unreached");
add(input.unreached?.runtimeFences, "deferred");
add(input.divergences, "reached");
add(input.performance, "reached");
}
const un = input.unreached;
const total = input.stats.statementsTotal + (un?.stats.statementsTotal ?? 0);
@@ -38,6 +38,7 @@ export interface CachedCoverage {
runtimeFences?: ScrDiagnostic[];
unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] };
divergences?: ScrDiagnostic[];
performance?: ScrDiagnostic[];
npmStatic?: NpmStaticStatus[];
wasmModules?: WasmModuleReport[];
preflightFailed: boolean;
@@ -149,6 +150,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null {
if (coverage.runtimeFences !== undefined) out.runtimeFences = coverage.runtimeFences;
if (coverage.unreached !== undefined) out.unreached = coverage.unreached;
if (coverage.divergences !== undefined) out.divergences = coverage.divergences;
if (coverage.performance !== undefined) out.performance = coverage.performance;
if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic;
if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules;
return out;
@@ -166,6 +168,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult
if (cached.runtimeFences !== undefined) coverage.runtimeFences = cached.runtimeFences;
if (cached.unreached !== undefined) coverage.unreached = cached.unreached;
if (cached.divergences !== undefined) coverage.divergences = cached.divergences;
if (cached.performance !== undefined) coverage.performance = cached.performance;
if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic;
if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules;
return { coverage, sourceTexts };
@@ -7,6 +7,9 @@ import { rebaseSourceLocations, semanticallyEqualSource } from "../library/seman
import type { SemanticReplay } from "./verdict-cache.js";
export const commentOnlyReplay: SemanticReplay = (probes, sources, coverage) => {
// The performance warning's text names file:line positions, which moving
// its locations would not update: such a verdict is analyzed again.
if (coverage.performance !== undefined) return null;
const previous = new Map<string, string>();
for (const source of sources) previous.set(source.path, source.text);
const match = frontendInputsSemanticallyMatch(probes, previous, semanticallyEqualSource);
@@ -61,7 +61,10 @@
* collation does not cover (SC6003), a call feeding an instance
* to a cast to an unrelated class (SC6004); SC6005 (Date strings
* outside the native parser's grammar) is retired — the runtime
* parses date strings with V8's grammar
* parses date strings with V8's grammar. SC61xx are performance
* notes, also warnings (coverage --fail-on=dynamic opts in):
* likely-hot functions that use untyped values (SC6101,
* diagnostics/dynamic-tier.ts)
* SC8xxx Node islands: the program calls into Node.js, and the build
* finds no usable libnode pack (SC8005)
* SC9xxx internal compiler errors (still source-anchored)
@@ -0,0 +1,99 @@
/* SC6101, the dynamic-tier performance warning: its text and how it prints.
* All of the warning's wording is here. The functions it names come from
* frontend/lowering/dynamic-tier.ts; this module only formats them, and it
* imports nothing heavy so the CLI's routed cache hit can print a stored
* warning without loading the compiler. */
import type { SrcLoc } from "../ir/ir.js";
import type { ScrDiagnostic } from "./diagnostic.js";
/** The code of the warning; SC61xx are performance notes, SC60xx divergences. */
export const DYNAMIC_TIER_CODE = "SC6101";
/** At most this many functions are named; the count covers all of them. */
export const DYNAMIC_TIER_SHOWN = 5;
/** Why a function counts as likely to run often. */
export type HotReason = "loop" | "called-in-loop" | "recursive";
const HOT_PHRASE: Record<HotReason, string> = {
loop: "in a loop",
"called-in-loop": "called in a loop",
recursive: "recursive",
};
/** Why a function's values are untyped, as the warning words it. */
export type UntypedCause =
| { kind: "any"; name: string }
| { kind: "json-binding"; name: string }
| { kind: "json" }
| { kind: "javascript" };
export function causePhrase(cause: UntypedCause): string {
switch (cause.kind) {
case "any":
return `\`${cause.name}\` is \`any\``;
case "json-binding":
return `\`${cause.name}\` holds an untyped \`JSON.parse\` result`;
case "json":
return "untyped `JSON.parse` result";
case "javascript":
return "untyped JavaScript file";
}
}
export interface DynamicTierEntry {
/** Display name (`advance`, `Parser.next`). */
name: string;
/** `file:line`, relative to the entry's directory. */
where: string;
cause: UntypedCause;
hot: HotReason;
}
export interface DynamicTierPackageCount {
name: string;
functions: number;
}
/** The SC6101 diagnostic for likely-hot functions with untyped values, or
* null when there are none. `loc` is the first function's declaration. */
export function dynamicTierDiag(
entries: readonly DynamicTierEntry[],
packages: readonly DynamicTierPackageCount[],
loc: SrcLoc,
): ScrDiagnostic | null {
const n = entries.length;
if (n === 0) return null;
const shown = entries.slice(0, DYNAMIC_TIER_SHOWN);
const lines = [
`${n} function${n === 1 ? "" : "s"} that likely run${n === 1 ? "s" : ""} often use${n === 1 ? "s" : ""} untyped values, which runs slower than typed code:`,
...shown.map((e) => ` ${e.name} (${e.where}): ${causePhrase(e.cause)}, ${HOT_PHRASE[e.hot]}`),
...(n > shown.length ? [` and ${n - shown.length} more`] : []),
...(packages.length === 0
? []
: [
` untyped code in npm packages: ${packages
.map((p) => `${p.name} (${p.functions} function${p.functions === 1 ? "" : "s"})`)
.join(", ")}`,
]),
];
return {
code: DYNAMIC_TIER_CODE,
message: lines.join("\n"),
loc,
hint: "add types to compile these statically; see scriptc coverage --explain-dynamic",
};
}
export function isPerformanceCode(code: string): boolean {
return /^SC61\d{2}$/.test(code);
}
/** The warning as a build prints it: no source excerpt (it names several
* functions), then its closing line. */
export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): string {
const blocks = diags.map(
(d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`,
);
return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`;
}
@@ -16,6 +16,7 @@
* bumps it. */
import { readFileSync } from "node:fs";
import type { ScrDiagnostic } from "./diagnostic.js";
import { isPerformanceCode } from "./dynamic-tier.js";
import type { WasmModuleReport } from "../wasm/discover.js";
export const DIAGNOSTICS_SCHEMA = "scriptc-diagnostics";
@@ -25,12 +26,14 @@ export const DIAGNOSTICS_SCHEMA_VERSION = 1;
* - invalid-source: the program itself is wrong (TypeScript errors, bad JSON modules)
* - unsupported: valid TypeScript/JavaScript scriptc cannot compile yet
* - divergence: compiles, but the native program behaves differently from Node
* - performance: compiles and behaves as under Node, but runs slower than typed code would
* - environment: project configuration, scriptc's type world, toolchain or target
* - internal: a compiler bug */
export type DiagnosticCategory =
| "invalid-source"
| "unsupported"
| "divergence"
| "performance"
| "environment"
| "internal";
@@ -149,12 +152,13 @@ export interface DiagnosticsEnvelope {
/** The divergence band: compiles, but the program can behave differently
* from Node. Warnings, never build failures. */
export function isDivergenceCode(code: string): boolean {
return /^SC6\d{3}$/.test(code);
return /^SC60\d{2}$/.test(code);
}
export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
const code = diag.code;
if (isDivergenceCode(code)) return "divergence";
if (isPerformanceCode(code)) return "performance";
if (code === "SC0001") return diag.typeWorld === true ? "environment" : "invalid-source";
if (code === "SC0003") return "invalid-source";
if (code === "SC0002" || code === "SC0004") return "environment";
@@ -274,7 +278,7 @@ export function locateDiagnostic(
return {
code: diag.code,
category: categoryOf(diag),
severity: isDivergenceCode(diag.code) ? "warning" : "error",
severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error",
scope,
file: diag.loc.file,
line: start.line,
@@ -11,6 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { FrontendInputSnapshot } from "../frontend/input-tracker.js";
import { frontendInputsStillMatch, validFrontendInputSnapshot } from "../frontend/input-tracker.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
@@ -113,6 +114,8 @@ interface EarlyExecutableCacheStamp {
};
nativeDependencies: NativeArtifactDependency[] | null;
native: EarlyExecutableNativeFeatures;
/** The build's performance warnings (SC61xx), which a cache hit repeats. */
performance?: ScrDiagnostic[];
integrity: string;
}
@@ -185,6 +188,8 @@ export interface EarlyExecutableCacheHit {
executableRestored: boolean;
native: EarlyExecutableNativeFeatures;
frontend: FrontendInputSnapshot;
/** The performance warnings the build reported (SC61xx), repeated on a hit. */
performance?: ScrDiagnostic[];
}
export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit {
@@ -616,6 +621,9 @@ async function readExecutableCache(
native: stamp.native,
executableRestored,
frontend: stamp.frontend,
...(Array.isArray(stamp.performance) && stamp.performance.length > 0
? { performance: stamp.performance }
: {}),
...(ir === null ? {} : { irPath: paths.irPath }),
};
} catch {
@@ -799,6 +807,9 @@ export async function publishEarlyExecutableCache(
},
nativeDependencies: executable === null ? null : result.nativeDependencies!,
native: result.native,
...(result.performance === undefined || result.performance.length === 0
? {}
: { performance: result.performance }),
};
const stamp: EarlyExecutableCacheStamp = { ...unsigned, integrity: stampIntegrity(unsigned) };
await writeFile(join(stage, "stamp.json"), `${JSON.stringify(stamp)}\n`, { mode: 0o600 });
+1 -1
View File
@@ -122,6 +122,6 @@ export function prepareExecutableModule(
mod: lowered.module!,
sourceTexts,
stats: lowered.stats,
warnings: lowered.divergences ?? [],
warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])],
};
}
+5 -2
View File
@@ -8,11 +8,14 @@ try {
} catch {
// Bytecode caching only speeds up loading.
}
const { runFrontendProcess } = await import("./index.js");
const { collectGarbageUnderHeapPressure, runFrontendProcess } = await import("./index.js");
let input = "";
for await (const chunk of process.stdin) input += String(chunk);
const [requestPath, resultPath] = input.split("\n");
if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined)
if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined) {
// As in the CLI process, lowering and LLVM emission collect the heap as they grow it.
collectGarbageUnderHeapPressure();
await runFrontendProcess(requestPath, resultPath);
}
// The frontend's own helpers (the checker process, timers) must not keep this process alive.
process.exit(0);
@@ -106,6 +106,7 @@ export function analyzeWithFrontend(
diagnostics: [...preflight, ...lowered.diagnostics],
...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}),
...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}),
...(lowered.performance !== undefined ? { performance: lowered.performance } : {}),
...(lowered.unreached ? { unreached: lowered.unreached } : {}),
...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}),
// --provenance-sources: the per-package attribution inputs (the
@@ -20,6 +20,7 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { lowerAbsenceProbe } from "../lower-exprs.js";
import { bodyReadsArguments } from "../../arguments-usage.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { voidizedCallback } from "../lower-server.js";
export function optionalStringTags(
@@ -238,6 +239,25 @@ export function errorFirstBytesCallback(
);
}
/** Whether `type` is an error-first callback's error parameter type:
* Error | null, optionally with undefined, or dyn. */
export function isErrorFirstErrorType(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "dyn") return true;
if (type.kind !== "union") return false;
const def = lowerer.unions.get(type.unionId);
return (
!!def &&
def.arms.some((arm) => arm.kind === "nullT") &&
def.arms.some((arm) => arm.kind === "object" && arm.className === "%Error") &&
def.arms.every(
(arm) =>
arm.kind === "nullT" ||
arm.kind === "undefinedT" ||
(arm.kind === "object" && arm.className === "%Error"),
)
);
}
/** An error-first callback (error, value) whose error parameter is
* Error | null or dyn and whose value parameter is dyn or accepted by
* `acceptValue`; `values` names the accepted values in the refusal. */
@@ -248,6 +268,17 @@ export function errorFirstCallback(
acceptValue: (value: IrType) => boolean,
values: string,
): IrExpr {
// A literal here is called only by the callback adapter, which passes
// null as the error whenever it passes a result (lowerUndefinedResultBody).
// A named function expression can also call itself, and a function
// expression can reach itself through `arguments.callee`.
let literal: ts.Expression = node;
while (ts.isParenthesizedExpression(literal)) literal = literal.expression;
if (
ts.isArrowFunction(literal) ||
(ts.isFunctionExpression(literal) && literal.name === undefined && !bodyReadsArguments(literal))
)
lowerer.errorFirstLiterals.add(literal);
let callback = lowerer.lowerExpr(node);
if (callback.type.kind === "dyn") {
callback = {
@@ -261,24 +292,8 @@ export function errorFirstCallback(
lowerer.unsupported("SC1090", node, `${api} callbacks must accept at most (error, buffer)`);
}
const error = callback.type.params[0];
if (error !== undefined && error.kind !== "dyn") {
if (error.kind !== "union") {
lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`);
}
const def = lowerer.unions.get(error.unionId);
const valid =
!!def &&
def.arms.some((arm) => arm.kind === "nullT") &&
def.arms.some((arm) => arm.kind === "object" && arm.className === "%Error") &&
def.arms.every(
(arm) =>
arm.kind === "nullT" ||
arm.kind === "undefinedT" ||
(arm.kind === "object" && arm.className === "%Error"),
);
if (!valid)
lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`);
}
if (error !== undefined && !isErrorFirstErrorType(lowerer, error))
lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`);
const value = callback.type.params[1];
if (value !== undefined && value.kind !== "dyn" && !acceptValue(value)) {
lowerer.unsupported(
@@ -1,6 +1,7 @@
import { dynUndefinedExpr, boolLit, numLit, varRef } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { lowerFsPath } from "./fs-paths.js";
import { locOf } from "../../program.js";
import {
type BuiltinModuleFn,
@@ -231,7 +232,7 @@ export function lowerFsNumericOpenCall(
"use an inline bitwise OR of fs.constants.O_RDONLY/O_WRONLY/O_RDWR/O_CREAT/O_EXCL/O_NOFOLLOW/O_NONBLOCK/O_TRUNC/O_APPEND",
);
}
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const mode = expr.arguments.nodes[2]
? lowerer.lowerExprExpecting(expr.arguments.nodes[2]!, F64)
: ({ kind: "numLit", value: 0o666, type: F64, loc } satisfies IrExpr);
@@ -640,7 +641,9 @@ export function lowerFsEncodedRead(
return null;
}
const sourceType = descriptor ? F64 : STRING;
const source = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, sourceType);
const source = descriptor
? lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, sourceType)
: lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const encoding: IrExpr =
optionsType.kind === "string"
? lowerer.lowerExprExpecting(options, STRING)
@@ -742,7 +745,7 @@ export function lowerFsOpendirCall(
) {
lowerer.noLowering(`${member} with ${expr.arguments.nodes.length} arguments`, expr);
}
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
let bufferSize: IrExpr = { kind: "numLit", value: 32, type: F64, loc };
const optsNode = expr.arguments.nodes[1];
if (optsNode && !(ts.isIdentifier(optsNode) && optsNode.text === "undefined")) {
@@ -812,7 +815,7 @@ export function lowerFsMkdirSyncCall(
);
}
const { recursive, modeNode } = options;
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const mode: IrExpr | null = modeNode ? lowerer.lowerExprExpecting(modeNode, F64) : null;
if (!recursive) {
return mode
@@ -891,7 +894,7 @@ export function lowerFsRemoveCall(
"the recursive/force flags must be boolean literals; maxRetries/retryDelay are numbers; other options have no lowering",
);
}
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const recursive = boolLit(opts.bools["recursive"] === true, loc);
const force = boolLit(opts.bools["force"] === true, loc);
@@ -949,7 +952,7 @@ export function lowerFsWriteBytesCall(
"byte writes take Uint8Array/Buffer data",
);
}
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const data = lowerer.lowerExprExpecting(expr.arguments.nodes[1]!, BYTES_U8);
if (bi.module === "fs/promises") {
return {
@@ -989,7 +992,7 @@ export function lowerFsWriteOptionsCall(
// values in object-literal order before issuing the write; otherwise a
// call/getter statically typed as the accepted utf8 literal can vanish.
const finishWrite = (optionValues: WriteOptionValue[]): IrExpr => {
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const data = lowerer.lowerExprExpecting(expr.arguments.nodes[1]!, STRING);
const pathLocal = lowerer.declareHiddenLocal("%writePath", STRING);
const dataLocal = lowerer.declareHiddenLocal("%writeData", STRING);
@@ -1,6 +1,7 @@
import { dynUndefinedExpr, boolLit, numLit, varRef } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer, ladderFenceExpr } from "../lowerer.js";
import { lowerFsPath } from "./fs-paths.js";
import { isJsSourceFile, locOf } from "../../program.js";
import {
builtinModuleFnOf,
@@ -513,7 +514,7 @@ export function lowerStringOrBytesWrite(
kind: "call",
callee: helper,
args: [
lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING),
lowerFsPath(lowerer, call.arguments.nodes[0]!),
lowerer.lowerExprExpecting(call.arguments.nodes[1]!, dataType),
],
type: VOID,
@@ -1376,7 +1377,7 @@ export function lowerFsReaddirTypesCall(
) {
fence();
}
const path = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, call.arguments.nodes[0]!);
return {
kind: "libCall",
fn: promiseForm ? "fsp.readdirTypes" : "fs.readdirTypesSync",
@@ -1405,7 +1406,7 @@ export function lowerFsStatOptionsCall(
const spelling = noFollow ? "lstatSync" : "statSync";
const plainFn: IrLibFn = noFollow ? "fs.lstatSync" : "fs.statSync";
const optsNode = call.arguments.nodes[1]!;
const path = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, call.arguments.nodes[0]!);
// The path binds first, then the options' effects, then the stat runs.
const plainAfter = (effect: IrExpr | null): IrExpr => {
if (effect === null) return { kind: "libCall", fn: plainFn, args: [path], type: STATS_T, loc };
@@ -0,0 +1,225 @@
/* fs path arguments given as file: URLs. Node converts a URL path with
* fileURLToPath inside each function (getValidatedPath), after every
* argument has been evaluated, and validates some arguments before the
* path and others after it. A URL-typed path lowers to the same
* conversion (url.fileURLToPathUrl). The later arguments through the last
* one with side effects are evaluated into hidden locals before the
* conversion, in source order, so they run even when it throws and each
* reads what it reads in Node. The conversion keeps Node's error order when every
* earlier path is a URL too and each argument Node validates before the
* path is absent or a literal that passes that validation; other URL
* forms fence with SC2020, as do the fs.promises functions, whose
* conversion errors are rejections rather than throws, and existsSync,
* which answers false for a URL it cannot convert. */
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { sideEffectFreeOptionValue } from "../surfaces.js";
import { stripTypeWrappers } from "../typed-arguments.js";
import { type IrExpr, type IrStmt, STRING, URL_T } from "../../../ir/ir.js";
/** The fs functions whose path parameters accept a URL: the path
* parameter indexes, and whether Node validates the paths before every
* other argument (otherwise the options come first). */
const FS_URL_PATHS: Readonly<
Record<string, { readonly paths: readonly number[]; readonly pathsFirst: boolean }>
> = {
accessSync: { paths: [0], pathsFirst: true },
appendFileSync: { paths: [0], pathsFirst: false },
chmodSync: { paths: [0], pathsFirst: true },
chownSync: { paths: [0], pathsFirst: true },
copyFileSync: { paths: [0, 1], pathsFirst: true },
lchownSync: { paths: [0], pathsFirst: true },
lstatSync: { paths: [0], pathsFirst: true },
mkdirSync: { paths: [0], pathsFirst: false },
mkdtempSync: { paths: [0], pathsFirst: false },
openSync: { paths: [0], pathsFirst: true },
opendirSync: { paths: [0], pathsFirst: true },
readFileSync: { paths: [0], pathsFirst: false },
readdirSync: { paths: [0], pathsFirst: false },
realpathSync: { paths: [0], pathsFirst: false },
"realpathSync.native": { paths: [0], pathsFirst: false },
renameSync: { paths: [0, 1], pathsFirst: true },
rmSync: { paths: [0], pathsFirst: false },
rmdirSync: { paths: [0], pathsFirst: true },
statSync: { paths: [0], pathsFirst: true },
truncateSync: { paths: [0], pathsFirst: true },
unlinkSync: { paths: [0], pathsFirst: true },
writeFileSync: { paths: [0], pathsFirst: false },
};
/** The fs.promises functions with path parameters, by path indexes. */
const FSP_PATHS: Readonly<Record<string, readonly number[]>> = {
access: [0],
appendFile: [0],
chmod: [0],
chown: [0],
copyFile: [0, 1],
lchown: [0],
lstat: [0],
mkdir: [0],
mkdtemp: [0],
open: [0],
opendir: [0],
readFile: [0],
readdir: [0],
realpath: [0],
rename: [0, 1],
rm: [0],
rmdir: [0],
stat: [0],
truncate: [0],
unlink: [0],
writeFile: [0],
};
function isUrlArgument(lowerer: Lowerer, node: ts.Expression | undefined): boolean {
return node !== undefined && lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "url";
}
/** A literal that Node's option validation accepts before it reads the
* path: a string (the checker admits only the declared encodings, flags
* and type names), a boolean, an integer in [0, 2^31 - 1] (every numeric
* option of these functions accepts that range), undefined or null, or an
* object literal of those. */
function preValidatedLiteral(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isStringLiteralLike(e)) return true;
if (
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword
)
return true;
if (ts.isIdentifier(e)) return e.text === "undefined";
if (ts.isNumericLiteral(e)) {
const value = Number(e.text);
return Number.isInteger(value) && value >= 0 && value <= 2147483647;
}
if (ts.isObjectLiteralExpression(e)) {
return e.properties.nodes.every(
(p) =>
ts.isPropertyAssignment(p) &&
!ts.isComputedPropertyName(p.name) &&
preValidatedLiteral(p.initializer),
);
}
return false;
}
/** SC2020 when the call passes a URL path in a form whose conversion
* order would differ from Node's (see the file comment). */
function checkUrlPaths(
lowerer: Lowerer,
call: ts.CallExpression,
module: string,
member: string,
): void {
const args = call.arguments.nodes;
const name = module === "fs/promises" ? `fs.promises.${member}` : member;
const hint = "convert the URL first: fileURLToPath(url) from node:url";
if (module === "fs/promises") {
if ((FSP_PATHS[member] ?? []).some((i) => isUrlArgument(lowerer, args[i])))
lowerer.noLowering(`${name} with a URL path`, call, hint);
return;
}
if (member === "existsSync" && isUrlArgument(lowerer, args[0]))
lowerer.noLowering(`${name} with a URL path`, call, hint);
const row = FS_URL_PATHS[member];
if (!row) return;
const first = row.paths.find((i) => isUrlArgument(lowerer, args[i]));
if (first === undefined) return;
if (args.some(ts.isSpreadElement))
lowerer.noLowering(`${name} with spread arguments and a URL path`, call);
// The runtime checks a string path after the conversion, so a string
// path Node checks first cannot precede the URL.
if (row.paths.some((i) => i < first && args[i] !== undefined))
lowerer.noLowering(`${name} with a string path before a URL path`, call, hint);
// Every argument from the URL through the last one with effects is
// evaluated ahead of the conversion, in source order: a later argument's
// effect (an assignment, say) must not change what an earlier one read.
let last = first;
for (let i = first + 1; i < args.length; i++) if (!sideEffectFreeOptionValue(args[i]!)) last = i;
const staged: ts.Expression[] = [];
for (let i = first + 1; i <= last; i++) {
// Keyed without type-only wrappers (`satisfies`, `as`, `!`), so a
// lowering that reads through them finds the saved value.
const e = stripTypeWrappers(args[i]!);
// Literals are read by their members, so a whole-value slot cannot
// hold them.
if (ts.isObjectLiteralExpression(e) || ts.isArrayLiteralExpression(e)) {
lowerer.noLowering(
sideEffectFreeOptionValue(e)
? `${name} with a URL path and a literal before a later argument that has effects`
: `${name} with a URL path and a later literal that has effects`,
e,
hint,
);
}
staged.push(e);
}
if (staged.length > 0) {
lowerer.stagedAfterPath.set(args[first]!, staged);
for (const e of staged) lowerer.stagedArguments.set(e, null);
}
if (row.pathsFirst) return;
// writeFileSync/appendFileSync's data is checked after the path.
const data = member === "writeFileSync" || member === "appendFileSync" ? 1 : -1;
for (let i = 0; i < args.length; i++) {
if (row.paths.includes(i) || i === data || preValidatedLiteral(args[i]!)) continue;
lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[i]!, hint);
}
}
/** Fences URL paths that cannot convert in Node's order: the check every
* fs and fs.promises call runs before lowering its arguments. */
export function checkFsUrlPaths(
lowerer: Lowerer,
call: ts.CallExpression,
bi: { module: string; member: string },
): void {
if (bi.module === "fs" || bi.module === "fs/promises")
checkUrlPaths(lowerer, call, bi.module, bi.member);
}
/** An fs path argument as a string: a URL converts with fileURLToPath's
* conversion (url.fileURLToPathUrl), which throws Node's errors for other
* schemes, hosts and encoded separators. */
export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr {
if (!isUrlArgument(lowerer, node)) return lowerer.lowerExprExpecting(node, STRING);
const loc = locOf(node);
const url = lowerer.lowerExprExpecting(node, URL_T);
const staged = lowerer.stagedAfterPath.get(node);
if (staged === undefined)
return { kind: "libCall", fn: "url.fileURLToPathUrl", args: [url], type: STRING, loc };
// The URL and the later arguments with effects, in source order, then
// the conversion.
const saved = lowerer.declareHiddenLocal("%fsUrl", URL_T);
const stmts: IrStmt[] = [{ kind: "varDecl", localId: saved.id, init: url, loc }];
for (const arg of staged) {
lowerer.stagedArguments.delete(arg);
const value = lowerer.lowerExpr(arg);
const slot = lowerer.declareHiddenLocal("%fsArg", value.type);
stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(arg) });
lowerer.stagedArguments.set(arg, { localId: slot.id, type: value.type });
}
return {
kind: "seqExpr",
stmts,
result: {
kind: "libCall",
fn: "url.fileURLToPathUrl",
args: [{ kind: "varRef", localId: saved.id, type: URL_T, loc }],
type: STRING,
loc,
},
type: STRING,
loc,
};
}
/** Whether `index` is a path parameter of the fs function `member`. */
export function isFsPathParam(member: string, index: number): boolean {
return FS_URL_PATHS[member]?.paths.includes(index) === true;
}
@@ -25,6 +25,7 @@ import {
lowerFsWriteOptionsCall,
lowerFsReadPathCall,
} from "./filesystem-calls.js";
import { checkFsUrlPaths, isFsPathParam, lowerFsPath } from "./fs-paths.js";
import { lowerBufferEncodingCall } from "./text-codecs.js";
import { lowerUtilTypeCall } from "./util-types.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -58,6 +59,7 @@ export function lowerBuiltinModuleCall(
loc: SrcLoc,
): IrExpr {
const name = ts.getTextOfNode(expr.expression);
checkFsUrlPaths(lowerer, expr, bi);
if (bi.module === "util/types") {
return lowerUtilTypeCall(lowerer, expr, bi, loc, name);
}
@@ -240,7 +242,7 @@ export function lowerBuiltinModuleCall(
expr.arguments.nodes.length === 1 &&
lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind !== "f64"
) {
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
return { kind: "libCall", fn: "fs.readFileSyncBytes", args: [path], type: BYTES_U8, loc };
}
if (
@@ -308,7 +310,7 @@ export function lowerBuiltinModuleCall(
if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) {
lowerer.noLowering(`${bi.member} with ${expr.arguments.nodes.length} arguments`, expr);
}
const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING);
const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const second: IrExpr = expr.arguments.nodes[1]
? lowerer.lowerExprExpecting(expr.arguments.nodes[1], F64)
: { kind: "numLit", value: 0, type: F64, loc };
@@ -470,7 +472,11 @@ export function lowerBuiltinModuleCall(
);
args = lowerer.completeArgs(expr.arguments.nodes, shapes, loc, expr);
} else {
args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, fn.params[i]));
args = expr.arguments.nodes.map((a, i) =>
bi.module === "fs" && isFsPathParam(bi.member, i)
? lowerFsPath(lowerer, a)
: lowerer.lowerExprExpecting(a, fn.params[i]),
);
for (let i = args.length; i < fn.params.length; i++) {
const dflt = fn.defaults![i - required]!;
args.push({ kind: "strLit", value: dflt, type: STRING, loc });
@@ -3,7 +3,7 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { lowerOptionalArgument, lowerStringSearchArgument } from "../optional-arguments.js";
import { lowerToNumberArgument } from "../lower-exprs.js";
import { lowerToNumberArguments } from "../lower-exprs.js";
import {
BOOL,
DYN,
@@ -115,11 +115,19 @@ export function lowerStringStaticCall(
const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64));
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
}
// Each code converts with ToNumber: a missing value is NaN (code unit 0
// for fromCharCode, a RangeError for fromCodePoint), exactly Node.
const elems = call.arguments.nodes.map((a) => lowerToNumberArgument(lowerer, a));
// Each code converts with ToNumber after every argument is evaluated: a
// missing value is NaN (code unit 0 for fromCharCode, a RangeError for
// fromCodePoint), exactly Node.
const { stmts, values: elems } = lowerToNumberArguments(lowerer, call.arguments.nodes);
const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc };
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
const result: IrExpr = {
kind: "libCall",
fn: `string.${member}`,
args: [packed],
type: STRING,
loc,
};
return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: STRING, loc };
}
/** `String.fromCharCode.apply(thisArg, codes)`, the btoa chunking idiom
@@ -546,9 +546,17 @@ export function paramShapes(
/** A declared type that is (or is a union over) a bare type parameter
* or an instantiation of a generic alias/interface over type arguments. */
function mentionsTypeParameter(lowerer: Lowerer, t: ts.Type): boolean {
function mentionsTypeParameter(
lowerer: Lowerer,
t: ts.Type,
seen: Set<ts.Type> = new Set(),
): boolean {
if (t.flags & ts.TypeFlags.TypeParameter) return true;
const mentions = (part: ts.Type): boolean => mentionsTypeParameter(lowerer, part);
// A recursive alias (`type Child = string | Child[]`) reaches itself
// through its own type arguments; a type already on the walk adds nothing.
if (seen.has(t)) return false;
seen.add(t);
const mentions = (part: ts.Type): boolean => mentionsTypeParameter(lowerer, part, seen);
if (t.isUnionType()) return ts.constituentTypes(t).some(mentions);
if (t.getAliasTypeArguments().some(mentions)) return true;
return (
@@ -5,6 +5,7 @@ import {
DYN,
type IrExpr,
type IrFunction,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
@@ -59,28 +60,48 @@ export class CaughtValueDispatch {
loc,
};
lowerer.liftedFns.push(this.helper);
lowerer.shareHelper(this.helper);
changed = true;
}
const helper = this.helper;
let added = false;
for (const className of this.thrownClasses) {
if (this.convertedClasses.has(className)) continue;
this.convertedClasses.add(className);
added = true;
}
if (added) {
// A subclass is tested before its ancestors, and classes of one depth
// by name, so the tests do not depend on the order the census met them.
const depth = (className: string): number => {
let count = 0;
for (let info = lowerer.classes.get(className)?.base; info; info = info.base) count++;
return count;
};
const ordered = [...this.convertedClasses].sort(
(left, right) => depth(right) - depth(left) || (left < right ? -1 : left > right ? 1 : 0),
);
const loc = helper.loc;
const value = varRef("caught", CAUGHT, loc);
const type: IrType = { kind: "object", className };
helper.body.unshift({
kind: "if",
cond: { kind: "caughtTest", value, test: "instanceof", className, type: BOOL, loc },
then: [
{
kind: "return",
value: lowerer.coerceToExpected({ kind: "caughtNarrow", value, type, loc }, DYN),
helper.body = [
...ordered.map((className): IrStmt => {
const type: IrType = { kind: "object", className };
return {
kind: "if",
cond: { kind: "caughtTest", value, test: "instanceof", className, type: BOOL, loc },
then: [
{
kind: "return",
value: lowerer.coerceToExpected({ kind: "caughtNarrow", value, type, loc }, DYN),
loc,
},
],
else_: null,
loc,
},
],
else_: null,
loc,
});
};
}),
helper.body[helper.body.length - 1]!,
];
changed = true;
}
for (const fn of consumers) {
@@ -33,6 +33,7 @@ function context() {
classes: new Map(),
liftedFns,
liftedFnNamed: (name: string | undefined) => liftedFns.find((fn) => fn.name === name),
shareHelper: () => {},
subclassLinks: 0,
constructedClassCount: () => -1,
diags: [],
@@ -475,3 +476,85 @@ test("normalizes base capsules before dispatching each derived member once", ()
],
});
});
test("class branches read their exact class's bag, also for classes with subclasses", () => {
const { lowerer, liftedFns } = context();
const base = {
def: { name: "Base", fields: [{ name: "value", type: F64 }], tracksOwnFields: true },
fields: new Map([["value", F64]]),
methods: new Map(),
base: null,
subclasses: [],
} as unknown as ClassInfo;
const child = {
def: {
name: "Child",
base: "Base",
fields: [{ name: "value", type: F64 }],
tracksOwnFields: true,
},
fields: new Map([["value", F64]]),
methods: new Map(),
base,
subclasses: [],
} as unknown as ClassInfo;
base.subclasses.push(child);
lowerer.classes.set("Base", base);
lowerer.classes.set("Child", child);
lowerer.classCanBeConstructed = () => true;
lowerer.isSubclassOf = (name, parent) => name === "Child" && parent === "Base";
lowerer.coerceToExpected = (value, type) =>
type.kind === "dyn" ? { kind: "dynFrom", value, type, loc } : value;
const boxes = fn(
"boxes",
[base, child].map((info) =>
statement({
kind: "dynFrom",
value: {
kind: "varRef",
localId: info.def.name,
type: { kind: "object", className: info.def.name },
loc,
},
type: DYN,
loc,
}),
),
);
const read = fn("read", [
statement({
kind: "dynKeyGet",
value: variable("value"),
key: literal("value"),
type: DYN,
loc,
}),
]);
const dispatch = new ClassDynamicDispatch();
for (let pass = 0; pass < 4 && dispatch.process(lowerer, [boxes, read, ...liftedFns]); pass++);
dispatch.finalize(lowerer);
const property = liftedFns.find((helper) => helper.name.startsWith("%dyn.class.property."))!;
// The class branches; the fallback for other receivers keeps the
// dispatch over every class.
const branches = property.body.filter(
(statement) =>
statement.kind === "if" &&
statement.cond.kind === "libCall" &&
statement.cond.fn === "dyn.typedRefIs",
);
expect(branches).toHaveLength(2);
const text = JSON.stringify(branches);
expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Base"');
expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Child"');
expect(text).not.toContain('"callee":"%dyn.class.readPropertiesOrEmpty"');
const bag = liftedFns.find((helper) => helper.name === "%class.branchPropertiesOrEmpty:Base")!;
// The bag is the receiver's own field; an absent bag reads as a fresh,
// unstored empty object.
expect(bag.params[0]!.type).toEqual({ kind: "object", className: "Base" });
expect(JSON.stringify(bag.body)).toContain('"className":"Base","field":"%dynProperties"');
expect(JSON.stringify(bag.body)).not.toContain('"kind":"fieldSet"');
expect(bag.body[0]).toMatchObject({
kind: "if",
then: [{ kind: "return", value: { kind: "dynObjLit", fields: [] } }],
});
});
@@ -281,6 +281,7 @@ export function classInstanceOf(
classValue?: IrExpr,
): IrExpr {
const name = `%dyn.class.${classValue ? "valueInstanceof" : "instanceof"}:${info.def.name}`;
lowerer.shareHelper(name);
const params = [
{ localId: "value", name: "value", type: DYN },
...(classValue ? [{ localId: "class", name: "class", type: classValue.type }] : []),
@@ -386,8 +387,36 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] {
return body;
}
/** `value` with the class branch's checked receiver (`p.0` as a dynamic
* value, possibly cast to the branch's class) replaced by `receiver`, the
* typed receiver of a per-class helper. */
function withTypedReceiver(value: IrExpr, receiver: IrExpr): IrExpr {
const checked = (e: IrExpr): boolean =>
e.kind === "dynCheck" && e.value.kind === "varRef" && e.value.localId === "p.0";
return transformExpr(value, {
stmt: (stmt) => stmt,
expr: (expr) =>
(expr.kind === "downcast" &&
checked(expr.value) &&
expr.type.kind === "object" &&
receiver.type.kind === "object" &&
expr.type.className === receiver.type.className) ||
checked(expr)
? receiver
: expr,
});
}
/** Re-adds the entries of `set` after the first `from` sorted. */
function sortAdded(set: Set<string>, from: number): void {
const added = [...set].slice(from).sort();
for (const name of added) set.delete(name);
for (const name of added) set.add(name);
}
export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction {
const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties";
lowerer.shareHelper(name);
const existing = lowerer.liftedFnNamed(name);
if (existing) return existing;
const helper: IrFunction = {
@@ -427,6 +456,8 @@ export function classForInHasKey(
loc: SrcLoc,
): IrExpr {
const name = "%dyn.class.forInHasKey";
lowerer.shareHelper(name);
lowerer.shareHelper("%dyn.class.forInBagHas");
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
// The read-only table: a recheck never creates an instance's table, which
// is undefined while the instance has no own dynamic properties.
@@ -728,7 +759,11 @@ export class ClassDynamicDispatch {
private readonly readPropertyBags: {
helper: IrFunction;
initialize: Extract<IrStmt, { kind: "fieldSet" }>;
orEmpty?: boolean;
}[] = [];
/** Read-only bags of exactly one class (exactPropertyBag), by class and
* whether an absent bag reads as a fresh empty object. */
private readonly exactPropertyBags = new Map<string, IrFunction>();
private readonly generated = new Set<IrFunction>();
/** Protocol dispatch helpers and callee reads whose receivers normalize. */
private readonly protocols: IrFunction[] = [];
@@ -801,6 +836,8 @@ export class ClassDynamicDispatch {
else if (type.kind === "func") discover(type.ret);
else if (type.kind === "promise") discover(type.inner);
};
const boxedBefore = this.boxed.size;
const boxedConstructorsBefore = this.boxedConstructors.size;
for (const fn of functions)
everyStmtList(fn.body, {
stmt: (statement) => {
@@ -895,6 +932,10 @@ export class ClassDynamicDispatch {
return true;
},
});
// The classes this pass found join the tables by name, not in the order
// the scan met them.
sortAdded(this.boxed, boxedBefore);
sortAdded(this.boxedConstructors, boxedConstructorsBefore);
changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed;
if (this.boxed.size === 0 && rewrite.size === 0) return changed;
const converting = this.convertingHints(lowerer);
@@ -2557,7 +2598,6 @@ export class ClassDynamicDispatch {
if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue;
dispatch.classes.add(plan.key);
const loc = dispatch.source.loc;
const value = varRef("p.0", DYN, loc);
const { receiver, condition } = this.propertyReceiver(plan, loc);
if (isClassCallback(lowerer, info, dispatch.source.method)) {
const prototypeMethod = observedPrototypeMethod(
@@ -2567,7 +2607,28 @@ export class ClassDynamicDispatch {
loc,
receiver,
);
const bag = this.readPropertyBagOrEmpty(lowerer, value, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const methodKey: IrExpr = {
kind: "strLit",
value: dispatch.source.method,
type: STRING,
loc,
};
const bagHas: IrExpr = {
kind: "libCall",
fn: "dyn.hasKey",
args: [bag, methodKey],
type: BOOL,
loc,
};
// An observed prototype slot answers through the bag when the bag
// holds the name (own or through its prototype). A bag read as a
// fresh empty object (an instance with no own dynamic properties
// whose bag initializer predates the class's prototype data) does
// not, so the method then comes from the class's prototype data.
const prototype = prototypeMethod
? classPrototypeData(lowerer, info, loc, receiver)
: null;
dispatch.callback.body.unshift({
kind: "if",
cond: condition,
@@ -2576,18 +2637,7 @@ export class ClassDynamicDispatch {
kind: "return",
value: {
kind: "ternary",
cond: prototypeMethod
? { kind: "boolLit", value: true, type: BOOL, loc }
: {
kind: "libCall",
fn: "dyn.hasKey",
args: [
bag,
{ kind: "strLit", value: dispatch.source.method, type: STRING, loc },
],
type: BOOL,
loc,
},
cond: prototype ? { kind: "boolLit", value: true, type: BOOL, loc } : bagHas,
then: {
kind: "dynObjLit",
fields: [
@@ -2595,8 +2645,17 @@ export class ClassDynamicDispatch {
key: { kind: "strLit", value: "value", type: STRING, loc },
value: {
kind: "dynKeyGet",
value: bag,
key: { kind: "strLit", value: dispatch.source.method, type: STRING, loc },
value: prototype
? {
kind: "ternary",
cond: bagHas,
then: bag,
else_: prototype,
type: DYN,
loc,
}
: bag,
key: methodKey,
type: DYN,
loc,
},
@@ -2806,10 +2865,19 @@ export class ClassDynamicDispatch {
dispatch.classes.add(plan.key);
const loc = dispatch.fn.loc;
const { receiver, condition } = this.propertyReceiver(plan, loc);
// The branch checks its receiver once: each use of the checked
// receiver would repeat the check.
const local = `receiver.${dispatch.fn.locals.length}`;
dispatch.fn.locals.push({
id: local,
name: "receiver",
type: receiver.type,
mutable: false,
});
const before = lowerer.diags.length;
let branch: IrStmt[];
try {
branch = this.propertyBody(lowerer, dispatch, info, receiver);
branch = this.propertyBody(lowerer, dispatch, info, varRef(local, receiver.type, loc));
} catch (error) {
if (!(error instanceof PoisonError) || !info.decl) throw error;
const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" });
@@ -2819,7 +2887,7 @@ export class ClassDynamicDispatch {
dispatch.fn.body.unshift({
kind: "if",
cond: condition,
then: branch,
then: [{ kind: "varDecl", localId: local, init: receiver, loc }, ...branch],
else_: null,
loc,
});
@@ -2919,11 +2987,7 @@ export class ClassDynamicDispatch {
!info.fields.has(name) && observedPrototypeMethod(lowerer, info, name, loc, receiver);
if (info.fields.has(name) || (dispatch.probe === "in" && !prototypeMethod)) {
if (info.fields.has(name) && info.def.tracksOwnFields) {
const bag = this.readPropertyBagOrEmpty(
lowerer,
lowerer.coerceToExpected(receiver, DYN),
loc,
);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const symbol = symbolMemberKey(lowerer, info, name, loc);
const fn =
dispatch.probe === "in"
@@ -2949,11 +3013,7 @@ export class ClassDynamicDispatch {
{ kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc },
];
}
const bag = this.readPropertyBagOrEmpty(
lowerer,
lowerer.coerceToExpected(receiver, DYN),
loc,
);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const symbol = name.startsWith("sym:") || name.startsWith("%symbol:");
const fn =
dispatch.probe === "in"
@@ -3023,9 +3083,8 @@ export class ClassDynamicDispatch {
}
const branches: IrStmt[] = [];
if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) {
const value = varRef("p.0", DYN, loc);
const key = varRef("p.key", DYN, loc);
const bag = this.readPropertyBagOrEmpty(lowerer, value, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
branches.push({
kind: "if",
cond: {
@@ -4051,6 +4110,98 @@ export class ClassDynamicDispatch {
return helper;
}
/** The read-only bag a class branch reads for its receiver: the
* receiver's own field when the branch selected the receiver's exact
* class, otherwise the dispatch over every class (readPropertyBag, or
* readPropertyBagOrEmpty with `orEmpty`). */
private branchPropertyBag(
lowerer: Lowerer,
info: ClassInfo,
receiver: IrExpr,
orEmpty: boolean,
loc: SrcLoc,
): IrExpr {
const helper = this.exactPropertyBag(lowerer, info, receiver, orEmpty, loc);
if (helper) return { kind: "call", callee: helper.name, args: [receiver], type: DYN, loc };
return orEmpty
? this.readPropertyBagOrEmpty(lowerer, lowerer.coerceToExpected(receiver, DYN), loc)
: this.readPropertyBag(lowerer, receiver, loc);
}
/** `%class.branchProperties[OrEmpty]:<class>(receiver)`: the read-only bag of
* an instance of exactly `info`. A class branch of the dispatch helpers
* selects a canonical receiver (normalizeDispatches gives an instance its
* actual class's capsule), so its branch for `info` sees only instances
* of `info`; instances of its subclasses take their own branches. Such a
* receiver needs neither the dispatch over every class nor a boxed copy of
* itself. Null
* when `receiver` is not typed as `info`, when a runtime or builtin class
* in the family keeps its own property contract, or before the class has
* a bag. */
private exactPropertyBag(
lowerer: Lowerer,
info: ClassInfo,
receiver: IrExpr,
orEmpty: boolean,
loc: SrcLoc,
): IrFunction | null {
const type = receiver.type;
if (type.kind !== "object" || type.className !== info.def.name) return null;
for (let current: ClassInfo | null = info; current; current = current.base)
if (
current.def.runtime ||
current.builtinEmitter ||
current.builtinStream ||
current.builtinError
)
return null;
const key = `${type.className}:${orEmpty}`;
const existing = this.exactPropertyBags.get(key);
if (existing) return existing;
const initializers = this.bagInitializers.get(type.className);
if (!initializers) return null;
const self = varRef("p.0", type, loc);
const bag: IrExpr = {
kind: "fieldGet",
obj: self,
className: type.className,
field: PROPERTY_BAG,
type: DYN,
loc,
};
const initialize: Extract<IrStmt, { kind: "fieldSet" }> = {
...initializers[0]!,
obj: self,
value: withTypedReceiver(initializers[0]!.value, self),
loc,
};
const helper: IrFunction = {
name: `%class.branchProperties${orEmpty ? "OrEmpty" : ""}:${type.className}`,
params: [{ localId: "p.0", name: "value", type }],
returnType: DYN,
locals: [{ id: "p.0", name: "value", type, mutable: false }],
loc,
body: [
{
kind: "if",
cond: { kind: "dynTest", test: "undefined", value: bag, type: BOOL, loc },
then: [initialize],
else_: null,
loc,
},
{ kind: "return", value: bag, loc },
],
};
// Initializers gain their prototype as subclasses become constructible;
// finalize then turns the lazy store into the read's result.
initializers.push(initialize);
this.exactPropertyBags.set(key, helper);
this.readPropertyBags.push({ helper, initialize, orEmpty });
this.generated.add(helper);
lowerer.liftedFns.push(helper);
return helper;
}
/** The prototype a new property bag of `current` (or of the subclass the
* receiver is) starts from, or null when `current` has no prototype data.
* A class with constructible subclasses that have prototype data selects
@@ -4130,9 +4281,11 @@ export class ClassDynamicDispatch {
* prototype-backed bag is produced for this read only. A published object
* cannot take the store, and Node's reads never write. */
finalize(lowerer?: Lowerer): void {
for (const { helper, initialize } of this.readPropertyBags) {
// Each table serves every site that reaches it.
if (lowerer) for (const fn of this.generated) lowerer.shareHelper(fn);
for (const { helper, initialize, orEmpty } of this.readPropertyBags) {
const absent = helper.body[0];
if (isEmptyBag(initialize.value)) helper.body.shift();
if (isEmptyBag(initialize.value) && !orEmpty) helper.body.shift();
else if (absent?.kind === "if")
absent.then = [{ kind: "return", value: initialize.value, loc: initialize.loc }];
}
@@ -4714,7 +4867,7 @@ export class ClassDynamicDispatch {
cond: this.readBagHas(
lowerer,
"dyn.hasKey",
this.readPropertyBag(lowerer, receiver, loc),
this.branchPropertyBag(lowerer, info, receiver, false, loc),
key,
loc,
),
@@ -4729,7 +4882,7 @@ export class ClassDynamicDispatch {
{ kind: "return", value: null, loc },
];
}
const bag = this.readPropertyBag(lowerer, receiver, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
return [
{
kind: "return",
@@ -4797,7 +4950,7 @@ export class ClassDynamicDispatch {
) {
this.constructors.set(info.def.name, info);
const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN);
const bag = this.readPropertyBag(lowerer, receiver, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
return [
{
@@ -4875,7 +5028,7 @@ export class ClassDynamicDispatch {
if (symbol || (isClassOwnEnumerableFieldName(name) && !name.startsWith("sym:"))) {
const prototypeMethod = observedPrototypeMethod(lowerer, info, name, loc, receiver);
// Selecting the method must not give the object own storage.
const bag = this.readPropertyBag(lowerer, receiver, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc);
const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc };
body.push({
kind: "if",
@@ -4943,7 +5096,7 @@ export class ClassDynamicDispatch {
}
if (!field && !accessor) {
const instance = lowerer.coerceToExpected(receiver, DYN);
const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
const own: IrExpr = {
kind: "libCall",
@@ -4990,11 +5143,7 @@ export class ClassDynamicDispatch {
(t.kind === "union" && (getUnion(t.unionId)?.arms.some(mutable) ?? false));
if (boxed.kind === "dynFrom" && mutable(boxed.value.type)) boxed.liveRef = true;
if (accessor) {
const bag = this.readPropertyBagOrEmpty(
lowerer,
lowerer.coerceToExpected(receiver, DYN),
loc,
);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc };
return [
{
@@ -5021,7 +5170,7 @@ export class ClassDynamicDispatch {
}
if (field && info.def.tracksOwnFields) {
const instance = lowerer.coerceToExpected(receiver, DYN);
const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc);
const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc);
const symbol = symbolMemberKey(lowerer, info, name, loc);
const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc };
return [
@@ -98,7 +98,11 @@ export class ClassProtocols {
* could read a stale value. A view of readonly data that nothing
* observes stays a native record. */
private markIdentityFlows(protocols: Set<ts.Symbol>): void {
const sites: { source: ts.Expression; target: () => ts.Type | undefined }[] = [];
const sites: {
source: ts.Expression;
target: () => ts.Type | undefined;
assertion?: true;
}[] = [];
/** Expressions whose values are observed as whole objects. */
const observed: ts.Expression[] = [];
/** Receivers whose type arguments or elements are observed (Set and Map
@@ -233,6 +237,7 @@ export class ClassProtocols {
sites.push({
source: node.expression,
target: () => this.checker.getTypeFromTypeNode(typeNode),
assertion: true,
});
}
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
@@ -325,11 +330,29 @@ export class ClassProtocols {
}
this.facts = facts;
const pending: { sourceType: ts.Type; target: () => ts.Type | undefined }[] = [];
/** Views a value of unknown origin is asserted to and that expose a
* method of a class the view accepts (see methodViewSources). */
const methodViews = new Set<ts.Symbol>();
for (let i = 0; i < sites.length; i++) {
const site = sites[i]!;
const source = sourceNodes[i]!;
const sourceType = attempt(() => this.checker.getTypeAtLocation(source));
if (sourceType !== undefined) pending.push({ sourceType, target: site.target });
if (sourceType === undefined) continue;
if (
site.assertion &&
(sourceType.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) !== 0 &&
this.methods.size > 0
) {
const target = attempt(site.target);
if (target !== undefined)
for (const { classType, view } of attempt(() => this.methodViewSources(target)) ?? []) {
pending.push({ sourceType: classType, target: () => view });
const symbol = view.getSymbol();
if (symbol !== undefined) methodViews.add(symbol);
}
continue;
}
pending.push({ sourceType, target: site.target });
}
for (const { fn, index, name } of callbackParams) {
attempt(() => {
@@ -372,7 +395,12 @@ export class ClassProtocols {
// Keep the marks a copy would be told apart from, and the marks whose
// values flow into a kept view or another live protocol.
const added = [...marked].filter((symbol) => !before.has(symbol));
const kept = new Set(added.filter((symbol) => this.needsView(symbol, marked, facts)));
// A copy of an instance drops the methods its class defines, so a view
// that exposes one of them stays live wherever such an instance may
// arrive.
const kept = new Set(
added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
);
let grew = true;
while (grew) {
grew = false;
@@ -391,6 +419,33 @@ export class ClassProtocols {
this.facts = null;
}
/** The program classes a value asserted from `unknown` or `any` to
* `target` may be an instance of while `target` exposes one of their
* methods: for each structural part of `target` with a callable member,
* the instance type of every class that declares a method of that name
* and is assignable to the part. A record copy of such an instance would
* drop the method, which Node reads from the instance's prototype. */
private methodViewSources(target: ts.Type): { classType: ts.Type; view: ts.Type }[] {
const parts = target.isUnionType() ? ts.constituentTypes(target) : [target];
const out: { classType: ts.Type; view: ts.Type }[] = [];
for (const part of parts) {
if (!this.isStructuralTarget(part)) continue;
for (const property of this.checker.getPropertiesOfType(part)) {
const memberType = this.checker.getNonNullableType(this.checker.getTypeOfSymbol(property));
if (this.checker.getCallSignatures(memberType).length === 0) continue;
for (const declaration of this.methods.get(ts.symbolName(property)) ?? []) {
const classSymbol = declaration.name
? this.checker.getSymbolAtLocation(declaration.name)
: undefined;
if (classSymbol === undefined) continue;
const classType = this.checker.getDeclaredTypeOfSymbol(classSymbol);
if (this.checker.isTypeAssignableTo(classType, part)) out.push({ classType, view: part });
}
}
}
return out;
}
/** The symbols of `type`'s object parts: union and intersection members,
* and to `depth` levels the elements and type arguments they hold. */
private collectSymbols(type: ts.Type, out: Set<ts.Symbol>, depth: number): void {
@@ -6,6 +6,7 @@ import {
DYN,
STRING,
SYMBOL_T,
VOID,
isUnitType,
type IrExpr,
type IrFunction,
@@ -121,6 +122,7 @@ export function classPrototypeData(
const globalId = `%g.${name}`;
const readyId = `${globalId}.ready`;
info.def.prototypeDataHelper = name;
lowerer.shareHelper(name);
const className = info.def.jsName ?? info.def.name;
lowerer.globalsList.push({
id: globalId,
@@ -155,6 +157,18 @@ export function classPrototypeData(
: { kind: "dynObjLit", fields: [], type: DYN, loc },
loc,
},
// util.inspect prints an instance with its class's name.
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.nameClassPrototype",
args: [value, { kind: "strLit", value: className, type: STRING, loc }],
type: VOID,
loc,
},
loc,
},
{
kind: "assign",
localId: readyId,
@@ -1972,7 +1972,8 @@ function lowerBufferStaticValue(
const srcNode = args[0].expression;
const srcIr = lowerer.mapTypeOf(lowerer.typeOf(srcNode));
if (srcIr?.kind === "bytes") {
const receiver = lowerer.lowerExpr(srcNode);
// A JavaScript field can hold the typed array as a dynamic value.
const receiver = lowerer.lowerExprExpecting(srcNode, srcIr);
const idxArgs = args.slice(1).map((a) => lowerer.lowerExprExpecting(a, F64));
return {
kind: "bytesIntrinsic",
@@ -0,0 +1,333 @@
/* Hot dynamic-tier functions (SC6101, diagnostics/dynamic-tier.ts): the
* functions of a program that compile to the dynamic tier (ir/tiers.ts) and
* likely run often, so a build can name them. Without a profile, a function
* counts as likely hot when a dynamic operation runs on every iteration of
* one of its loops, when a loop calls it, or when it calls itself. Dynamic code that
* runs once (setup code that reads an `any` configuration, for example)
* stays quiet.
*
* The tier assignment is the one lowering made (Lowerer.dynamicFunctions);
* this module ranks its functions and says why each one is dynamic, from
* the source at its dynamic operations. A function is named only when the
* source says why (an `any`, an untyped `JSON.parse` result, an untyped
* JavaScript file): functions dynamic for operations without a static
* lowering would stay dynamic with more types. */
import { relative } from "node:path";
import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js";
import {
dynamicTierDiag,
type DynamicTierPackageCount,
type HotReason,
type UntypedCause,
} from "../../diagnostics/dynamic-tier.js";
import type { IrExpr, IrFunction, IrModule, IrStmt, SrcLoc } from "../../ir/ir.js";
import {
dynamicOperationOf,
dynamicOperationSites,
type DynamicOperationSite,
} from "../../ir/tiers.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import * as ts from "../ts7/adapter.js";
import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js";
import { isNodeModulesPath } from "../resolve.js";
import type { Lowerer } from "./lowerer.js";
export interface HotDynamicFunction {
/** The IR function name (`%Point.norm`, `parse`, ...). */
name: string;
loc: SrcLoc;
hot: HotReason;
/** Dynamic operations inside the function's loops. */
loopOperations: number;
cause: UntypedCause;
}
export interface DynamicTierReport {
/** Likely-hot dynamic functions in the program's own files, most likely
* hot first. */
hot: HotDynamicFunction[];
/** Dynamic-tier functions in npm packages, by package. */
packages: DynamicTierPackageCount[];
}
interface FunctionFacts {
/** Dynamic operations in the parts of the function's loops that run on
* every iteration. */
loopSites: DynamicOperationSite[];
/** Callee names of direct calls, and whether each runs on every
* iteration of a loop. */
calls: { callee: string; inLoop: boolean }[];
}
/** Walks a function body by structure: a loop's condition, update and body
* repeat, while a `for` loop's initializer and a `for...of` loop's iterable
* run once. (Locations cannot tell them apart: lowering may give a loop's
* repeated parts the whole statement's span.) */
function factsOf(fn: IrFunction): FunctionFacts {
const facts: FunctionFacts = { loopSites: [], calls: [] };
const expr = (e: IrExpr, inLoop: boolean): void => {
if (inLoop) {
const operation = dynamicOperationOf(e);
if (operation !== null) facts.loopSites.push({ operation, loc: e.loc });
}
if (e.kind === "call") facts.calls.push({ callee: e.callee, inLoop });
everyExprChild(
e,
(child) => {
expr(child, inLoop);
return true;
},
(child) => {
stmt(child, inLoop);
return true;
},
);
};
const children = (s: IrStmt, inLoop: boolean): void => {
everyStmtChild(
s,
(child) => {
expr(child, inLoop);
return true;
},
(child) => {
stmt(child, inLoop);
return true;
},
);
};
const stmt = (s: IrStmt, inLoop: boolean): void => {
switch (s.kind) {
case "for":
if (s.init !== null) stmt(s.init, inLoop);
if (s.cond !== null) expr(s.cond, true);
if (s.update !== null) stmt(s.update, true);
for (const b of s.body) stmt(b, true);
return;
case "forOf":
expr(s.iterable, inLoop);
for (const b of s.body) stmt(b, true);
return;
case "while":
case "doWhile":
children(s, true);
return;
default:
children(s, inLoop);
}
};
for (const s of fn.body) stmt(s, false);
return facts;
}
/** The package directory name of a node_modules path (`@scope/name` or `name`). */
function packageOf(file: string): string {
const parts = file.split("/");
const at = parts.lastIndexOf("node_modules");
const first = parts[at + 1] ?? "";
return first.startsWith("@") ? `${first}/${parts[at + 2] ?? ""}` : first;
}
/** The innermost node spanning [start, end). */
function nodeAt(sf: ts.SourceFile, start: number, end: number): ts.Node {
let node: ts.Node = sf;
for (;;) {
let next: ts.Node | undefined;
ts.forEachChild(node, (child) => {
if (ts.tokenStartOfNode(child) <= start && end <= child.end) {
next = child;
return true;
}
return undefined;
});
if (next === undefined) return node;
node = next;
}
}
/** The identifier an operand reads through (`items` in `items[i].x`). */
function rootIdentifier(node: ts.Node): ts.Identifier | null {
let n: ts.Node = node;
for (;;) {
if (ts.isIdentifier(n)) return n;
if (
ts.isPropertyAccessExpression(n) ||
ts.isElementAccessExpression(n) ||
ts.isCallExpression(n)
)
n = n.expression;
else if (ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) n = n.expression;
else return null;
}
}
function isJsonParse(node: ts.Node | undefined): boolean {
if (node === undefined || !ts.isCallExpression(node)) return false;
const callee = node.expression;
return (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.expression) &&
callee.expression.text === "JSON" &&
callee.name.text === "parse"
);
}
/** Why an identifier holds a value without a static type, or null. */
function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): UntypedCause | null {
const symbol = lowerer.checker.getSymbolAtLocation(id);
const decl = symbol?.declarations?.[0];
// An unannotated `for (const item of items)` variable: name `items`.
if (
depth < 4 &&
decl !== undefined &&
ts.isVariableDeclaration(decl) &&
decl.type === undefined &&
ts.isVariableDeclarationList(decl.parent) &&
ts.isForOfStatement(decl.parent.parent)
) {
const iterated = rootIdentifier(decl.parent.parent.expression);
if (iterated !== null) {
const cause = identifierCause(lowerer, iterated, depth + 1);
if (cause !== null) return cause;
}
}
if (decl !== undefined && ts.isVariableDeclaration(decl) && isJsonParse(decl.initializer))
return { kind: "json-binding", name: id.text };
const type = lowerer.checker.getTypeAtLocation(id);
if ((type.flags & ts.TypeFlags.Any) !== 0) return { kind: "any", name: id.text };
return null;
}
/** Why a node's value has no static type, or null. */
function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
if (isJsonParse(n)) return { kind: "json" };
const id = rootIdentifier(n);
return id === null ? null : identifierCause(lowerer, id);
}
/** Why the dynamic operation at `loc` has no static type, or null when
* the source does not say. The site's own subexpressions are asked first
* (the operand of a dynamic `for...of`), then its ancestors. */
function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
const sf = lowerer.program.getSourceFile(loc.file);
if (sf === undefined) return null;
if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs))
return { kind: "javascript" };
const node = nodeAt(sf, loc.start, loc.end);
let found: UntypedCause | null = null;
const descend = (n: ts.Node, depth: number): void => {
if (found !== null || depth > 3) return;
found = nodeCause(lowerer, n);
if (found === null)
ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined));
};
descend(node, 0);
for (
let n: ts.Node | undefined = node.parent;
found === null && n !== undefined && n !== sf;
n = n.parent
)
found = nodeCause(lowerer, n);
return found;
}
/** The first cause the function's dynamic operations name, loop sites
* first, or null when none names one. */
function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCause | null {
for (const site of sites) {
const cause = causeAt(lowerer, site.loc);
if (cause !== null) return cause;
}
return null;
}
/** The likely-hot dynamic-tier functions of `module` (see the header). */
export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport {
const facts = new Map<string, FunctionFacts>();
for (const fn of module.functions) facts.set(fn.name, factsOf(fn));
// Functions some loop calls directly.
const calledInLoop = new Set<string>();
for (const fn of module.functions) {
const f = facts.get(fn.name)!;
for (const call of f.calls) if (call.inLoop) calledInLoop.add(call.callee);
}
const hot: HotDynamicFunction[] = [];
const packages = new Map<string, number>();
for (const fn of module.functions) {
// Generated `%dyn.*` helpers are dynamic operations lowered out of line,
// and `%init.*` is a module's top level: neither is a function to name.
if (fn.tier !== "dynamic" || fn.name.startsWith("%dyn.") || fn.name.startsWith("%init."))
continue;
if (isNodeModulesPath(fn.loc.file)) {
const name = packageOf(fn.loc.file);
packages.set(name, (packages.get(name) ?? 0) + 1);
continue;
}
const f = facts.get(fn.name)!;
const sites = dynamicOperationSites(fn);
const inLoops = f.loopSites;
const recursive = f.calls.some((c) => c.callee === fn.name);
const reason: HotReason | null =
inLoops.length > 0
? "loop"
: calledInLoop.has(fn.name)
? "called-in-loop"
: recursive
? "recursive"
: null;
if (reason === null) continue;
// Only functions the source makes dynamic are named: ones dynamic for
// operations without a static lowering would stay dynamic with more types.
const looped = new Set(inLoops.map((s) => `${s.loc.file}:${s.loc.start}:${s.loc.end}`));
const rest = sites.filter((s) => !looped.has(`${s.loc.file}:${s.loc.start}:${s.loc.end}`));
const cause = causeOf(lowerer, [...inLoops, ...rest]);
if (cause === null) continue;
hot.push({
name: fn.name,
loc: fn.loc,
hot: reason,
loopOperations: inLoops.length,
cause,
});
}
const rank = { loop: 0, "called-in-loop": 1, recursive: 2 } as const;
hot.sort((a, b) => rank[a.hot] - rank[b.hot] || b.loopOperations - a.loopOperations);
return {
hot,
packages: [...packages]
.map(([name, functions]) => ({ name, functions }))
.sort((a, b) => b.functions - a.functions || (a.name < b.name ? -1 : 1)),
};
}
/** `%%m144.Session.finish` → `Session.finish`, `m163.find` → `find`. */
function displayName(name: string): string {
return name.replace(/^%+/, "").replace(/^m\d+\./, "");
}
/** SC6101 for the program lowering produced, or null (see the header).
* Paths are relative to `root`, the entry's directory. */
export function dynamicTierWarning(
lowerer: Lowerer,
module: IrModule,
root: string,
): ScrDiagnostic | null {
const report = scanDynamicTier(lowerer, module);
const first = report.hot[0];
if (first === undefined) return null;
const where = (loc: SrcLoc): string => {
const sf = lowerer.program.getSourceFile(loc.file);
const line = sf === undefined ? 0 : ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1;
return `${relative(root, loc.file) || loc.file}:${line}`;
};
return dynamicTierDiag(
report.hot.map((f) => ({
name: displayName(f.name),
where: where(f.loc),
cause: f.cause,
hot: f.hot,
})),
report.packages,
first.loc,
);
}
@@ -24,6 +24,7 @@ export function errorPropertyRead(
): IrExpr {
const name = `%error.${property}.read`,
loc = receiver.loc;
lowerer.shareHelper(name);
if (!lowerer.liftedFns.some((fn) => fn.name === name))
lowerer.liftedFns.push({
name,
@@ -50,6 +51,7 @@ export function errorPropertyWrite(
): IrStmt {
const name = `%error.${property}.write`,
loc = receiver.loc;
lowerer.shareHelper(name);
if (!lowerer.liftedFns.some((fn) => fn.name === name))
lowerer.liftedFns.push({
name,
@@ -392,6 +394,7 @@ export function errorToStringMethod(
receiver: IrExpr,
): Extract<IrExpr, { kind: "call" }> {
const loc = receiver.loc;
lowerer.shareHelper(helperName);
if (!lowerer.liftedFns.some((fn) => fn.name === helperName)) {
lowerer.liftedFns.push({
name: helperName,
@@ -415,6 +418,7 @@ export function errorToStringCall(lowerer: Lowerer, receiver: IrExpr): IrExpr {
const loc = receiver.loc;
const method = errorToStringMethod(lowerer, receiver);
const name = "%error.toString.virtual";
lowerer.shareHelper(name);
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const value = varRef("error.0", errorType, loc);
lowerer.liftedFns.push({
@@ -30,6 +30,7 @@ import {
} from "../../../ir/ir.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { classToNumberConversion } from "../class-to-primitive.js";
import { lowerPrimitiveUnionNumber } from "../lower-calls.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js";
import { fenceNodeModuleMutation } from "../lower-node-module.js";
import { staticFieldWriteTarget } from "../lower-classes.js";
@@ -132,8 +133,12 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus");
if (classNumber) return classNumber;
const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand);
if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]);
return operand;
if (operand.type.kind === "f64") return operand;
// `+process.argv[2]`: a `string | undefined` (or other primitive
// union) converts as Number(value) does.
const unionNumber = lowerPrimitiveUnionNumber(lowerer, raw, expr.operand, loc);
if (unionNumber) return unionNumber;
operatorUnsupported(lowerer, expr, "+", [raw.type]);
}
case ts.SyntaxKind.ExclamationToken: {
// `!x` is ToBoolean-then-negate: f64/string operands go through toBool.
@@ -68,8 +68,10 @@ export class GeneratorDynamicDispatch {
this.properties.set(method, { fn, types: new Set() });
this.generated.add(fn);
lowerer.liftedFns.push(fn);
lowerer.shareHelper(fn);
// The runtime's own helper functions answer the helper methods.
if (HELPER_METHODS.has(method)) return fn;
lowerer.shareHelper(`%dyn.generator.method.${method}`);
lowerer.liftedFns.push({
name: `%dyn.generator.method.${method}`,
params: [{ localId: "arg", name: "arg", type: DYN }],
@@ -209,6 +211,7 @@ export class GeneratorDynamicDispatch {
this.dispatches.set(key, dispatch);
this.generated.add(helper);
lowerer.liftedFns.push(helper);
lowerer.shareHelper(helper);
changed = true;
}
return {
@@ -168,7 +168,6 @@ import { lowerReflectCall } from "./builtins/reflection.js";
import {
lowerAbsenceProbe,
v8CalleeText,
lowerOptionalNumber,
lowerPromiseAllTupleCall,
lowerPromiseRejectCall,
lowerToNumberArgument,
@@ -1016,10 +1015,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// excludes shadowed names. parseInt's omitted radix completes
// to 0 — the spec's "undefined" (base 10 with the 0x hex escape);
// parseFloat lowers the STRING form only (Node would ToString other
// values — no static story); isNaN/isFinite's arguments are
// checker-pinned (or checked) to number, where the global's ToNumber
// coercion is the identity and the tests are Number.isNaN /
// Number.isFinite exactly (ms's `isFinite(val)` guard).
// values — no static story); isNaN/isFinite apply the global's
// ToNumber to any argument, then test like Number.isNaN /
// Number.isFinite.
if (
(expr.expression.text === "parseInt" ||
expr.expression.text === "isNaN" ||
@@ -1138,18 +1136,14 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
}
if (islFn && expr.arguments.nodes.length === islFn.args.length) {
// The static forms above already took the exactly-typed argument
// (a string for parseFloat, a number for isFinite): name the
// argument type that refused the call, so the message states the
// condition rather than reading as if the global itself were
// unsupported.
// (a string for parseFloat): name the argument type that refused
// the call, so the message states the condition rather than
// reading as if the global itself were unsupported.
const argText = loweredArgumentText(lowerer, expr.arguments.nodes[0]!);
const exact = expr.expression.text === "isFinite" ? "number" : "string";
lowerer.refuseUnsupportedApi(
`'${expr.expression.text}' with a '${argText}' argument`,
expr,
exact === "string"
? "only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)"
: "only a number argument compiles statically: narrow the argument to a number first",
"only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)",
);
}
}
@@ -3215,6 +3209,20 @@ function lowerStringPrototypeCall(
);
}
/** ToNumber of a union of strings, numbers, booleans, null and undefined, as
* `Number(value)` converts it; null for other types. Unary `+` takes it too:
* the two conversions differ only for BigInt, which such unions don't hold. */
export function lowerPrimitiveUnionNumber(
lowerer: Lowerer,
arg: IrExpr,
node: ts.Expression,
loc: SrcLoc,
): IrExpr | null {
return (
lowerOptionalStringNumber(lowerer, arg, loc) ?? lowerScalarUnionNumber(lowerer, arg, node, loc)
);
}
function lowerScalarUnionNumber(
lowerer: Lowerer,
arg: IrExpr,
@@ -7682,6 +7690,10 @@ function lowerObjectStaticCall(
const target = lowerer.lowerExpr(call.arguments.nodes[0]!);
const rawSource = lowerer.lowerExpr(call.arguments.nodes[1]!);
fenceSymbolFieldCopy(lowerer, call.arguments.nodes[1]!, rawSource.type);
let targetNode: ts.Expression = call.arguments.nodes[0]!;
while (ts.isParenthesizedExpression(targetNode)) targetNode = targetNode.expression;
if (ts.isObjectLiteralExpression(targetNode))
fenceClassCopyIntoFreshObject(lowerer, call, call.arguments.nodes[1]!, rawSource);
const source = lowerer.coerceToExpected(rawSource, DYN);
if (target.type.kind === "dyn" && source.type.kind === "dyn") {
return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc };
@@ -7835,25 +7847,8 @@ function lowerObjectStaticCall(
const srcNode = spread ? argNode.expression : argNode;
const rawSource = lowerer.lowerExpr(srcNode);
fenceSymbolFieldCopy(lowerer, srcNode, rawSource.type, spread);
// A class instance copied into a fresh object
// (`Object.assign({}, instance)`): the result is a plain
// object holding the instance's own fields, never an
// instance, so a result the checker types as the class has
// no representation (the checked cast would throw).
if (
rawSource.type.kind === "object" &&
freshLiteralTarget &&
!ts.isExpressionStatement(call.parent)
) {
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
if (resultT !== null && resultT.kind === "object") {
lowerer.unsupported(
"SC1090",
srcNode,
`Object.assign copying a '${lowerer.fmt(rawSource.type)}' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need)`,
);
}
}
if (freshLiteralTarget)
fenceClassCopyIntoFreshObject(lowerer, call, srcNode, rawSource);
const src = lowerer.coerceToExpected(rawSource, DYN);
if (src.type.kind !== "dyn") {
ok = false;
@@ -10322,7 +10317,7 @@ export function lowerStaticNumberGlobal(
args: readonly ts.Expression[],
loc: SrcLoc,
): IrExpr | null {
if (name === "parseInt" || name === "isNaN") {
if (name === "parseInt" || name === "isNaN" || name === "isFinite") {
const maxArgs = name === "parseInt" ? 2 : 1;
if (args.length < 1 || args.length > maxArgs) {
lowerer.noLowering(
@@ -10330,10 +10325,12 @@ export function lowerStaticNumberGlobal(
call,
);
}
if (name === "isNaN") {
// The global's ToNumber turns a missing value into NaN.
if (name === "isNaN" || name === "isFinite") {
// The global's ToNumber turns a missing value into NaN, converts
// strings, booleans and null, and calls valueOf/toString on objects.
const x = lowerToNumberArgument(lowerer, args[0]!);
return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc };
const fn = name === "isNaN" ? "num.isNaN" : "number.isFinite";
return { kind: "libCall", fn, args: [x], type: BOOL, loc };
}
const radix: IrExpr = args[1]
? lowerer.lowerExprExpecting(args[1], F64)
@@ -10469,32 +10466,28 @@ export function lowerStaticNumberGlobal(
: lowerer.lowerExprExpecting(args[0]!, STRING);
return { kind: "libCall", fn: "num.parseInt", args: [s, radix], type: F64, loc };
}
// STATIC parseFloat/isFinite over exactly-typed arguments —
// parseInt's siblings (num.parseFloat is ECMA 19.2.4's decimal-
// literal prefix parse in scr_string.c; a number-typed isFinite IS
// Number.isFinite — the global's ToNumber coercion is the identity
// there, ms's `isFinite(val)` guard). Other argument types retain
// their native coercion checks or the SC2012 refusal. The probe never
// emits — lowering is IR construction.
if ((name === "parseFloat" || name === "isFinite") && args.length === 1) {
if (name === "parseFloat") {
const optional = optionalCallValue(lowerer, args[0]!);
if (optional) {
const parsed = lowerOptionalStringNumber(
lowerer,
optional,
loc,
"num.parseFloat",
"parseFloat",
);
if (parsed) return parsed;
}
// STATIC parseFloat over string arguments — parseInt's sibling
// (num.parseFloat is ECMA 19.2.4's decimal-literal prefix parse in
// scr_string.c). Other argument types retain their native coercion
// checks or the SC2012 refusal. The probe never emits — lowering is IR
// construction.
if (name === "parseFloat" && args.length === 1) {
const optional = optionalCallValue(lowerer, args[0]!);
if (optional) {
const parsed = lowerOptionalStringNumber(
lowerer,
optional,
loc,
"num.parseFloat",
"parseFloat",
);
if (parsed) return parsed;
}
const probed = tryLowerExpression(lowerer, args[0]!);
if (name === "parseFloat" && probed?.type.kind === "string") {
if (probed?.type.kind === "string") {
return { kind: "libCall", fn: "num.parseFloat", args: [probed], type: F64, loc };
}
if (name === "parseFloat" && probed?.type.kind === "dyn") {
if (probed?.type.kind === "dyn") {
return {
kind: "libCall",
fn: "num.parseFloat",
@@ -10503,20 +10496,6 @@ export function lowerStaticNumberGlobal(
loc,
};
}
if (name === "isFinite" && probed?.type.kind === "f64") {
return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc };
}
// A number the checker trusts but storage may hold missing (an
// out-of-range read): the global's ToNumber makes undefined NaN.
if (
name === "isFinite" &&
probed?.type.kind === "union" &&
lowerer.armTag(probed.type.unionId, UNDEFINED_T) >= 0 &&
lowerer.stripUndefinedArm(probed.type).kind === "f64"
) {
const x = lowerOptionalNumber(lowerer, probed, loc, args[0]);
return { kind: "libCall", fn: "number.isFinite", args: [x], type: BOOL, loc };
}
}
return null;
}
@@ -10715,3 +10694,23 @@ function functionIntegrity(
loc,
};
}
/** A class instance copied into a fresh object (`Object.assign({},
* instance)`): the result is a plain object holding the instance's own
* fields, never an instance, so a result the checker types as the class has
* no representation (the checked cast would throw). */
function fenceClassCopyIntoFreshObject(
lowerer: Lowerer,
call: ts.CallExpression,
srcNode: ts.Expression,
rawSource: IrExpr,
): void {
if (rawSource.type.kind !== "object" || ts.isExpressionStatement(call.parent)) return;
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
if (resultT === null || resultT.kind !== "object") return;
lowerer.unsupported(
"SC1090",
srcNode,
`Object.assign copying a '${lowerer.fmt(rawSource.type)}' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need)`,
);
}
@@ -688,6 +688,9 @@ export interface ClassInfo {
path: InstantiationPath;
family: ClassInfo;
bindings: Map<ts.Symbol, IrType>;
/** The type arguments as checker types, for the resolutions that need
* literal identity (a conditional type over a literal argument). */
tsBindings: Map<ts.Symbol, ts.Type>;
typeArgsText: string;
ordinal: number;
};
@@ -767,6 +770,9 @@ export interface GenericClassInfo {
typeParams: ts.Symbol[];
family: ClassInfo;
instances: Map<string, { name: string; info: ClassInfo | null; poisoned?: boolean }>;
/** The type parameters a conditional type in the class depends on
* (literalSensitiveTypeParams), computed on first instantiation. */
literalSensitive?: Set<ts.Symbol>;
}
/** The KEY symbol behind a LATE-BOUND (`__@name@id`) property: resolved
@@ -1772,6 +1778,7 @@ export function collectClassShapeInner(
family: ClassInfo;
name: string;
bindings: Map<ts.Symbol, IrType>;
tsBindings: Map<ts.Symbol, ts.Type>;
typeArgsText: string;
ordinal: number;
},
@@ -4448,6 +4455,7 @@ export function collectClassShapeInner(
path: inst.path,
family: inst.family,
bindings: inst.bindings,
tsBindings: inst.tsBindings,
typeArgsText: inst.typeArgsText,
ordinal: inst.ordinal,
};
@@ -4591,8 +4599,10 @@ export function genericClassInstanceType(
.getTypeArguments(ref as ts.TypeReference)
.slice(0, gci.typeParams.length);
const mapped: IrType[] = [];
const tsArgs: ts.Type[] = [];
if (args.length === gci.typeParams.length) {
for (const a of args) {
tsArgs.push(lowerer.typeParamTsResolver(a) ?? a);
const m = lowerer.mapTypeOf(a, mapArgument);
if (!m || m.kind === "void") {
// An UNBOUND type parameter argument (`Box<T>` outside any
@@ -4611,9 +4621,32 @@ export function genericClassInstanceType(
const b = lowerer.typeParamBindings?.get(tp);
if (!b) return null;
mapped.push(b);
const bound = lowerer.typeParamTsBindings?.get(tp);
if (bound) tsArgs.push(bound);
}
}
const key = mapped.map(typeKey).join(",");
// A type parameter that a conditional type in the class depends on
// (`K extends "required" ? T : T | undefined`) is bound to its checker
// type for the members, and that type keys the instance: mapType alone
// widens a literal, so Field<string, "required"> and Field<string,
// "optional"> would otherwise share one layout. The other parameters get
// no checker binding, so nothing in the members depends on a literal
// the key does not carry.
gci.literalSensitive ??= literalSensitiveTypeParams(lowerer, gci);
const sensitive = (i: number): boolean =>
tsArgs.length === gci.typeParams.length && gci.literalSensitive!.has(gci.typeParams[i]!);
const checkerKey = gci.typeParams
.map((_, i) => (sensitive(i) ? `#${tsArgs[i]!.id}` : "*"))
.join(",");
const key =
mapped.map(typeKey).join(",") + (gci.literalSensitive.size > 0 ? `@${checkerKey}` : "");
const literalText = (i: number): string | null => {
const arg = sensitive(i) ? tsArgs[i] : undefined;
if (arg?.isStringLiteralType()) return JSON.stringify(arg.value);
if (arg?.isNumberLiteralType()) return String(arg.value);
if (arg && arg.flags & ts.TypeFlags.BooleanLiteral) return lowerer.checker.typeToString(arg);
return null;
};
const existing = gci.instances.get(key);
if (existing) {
if (existing.info) lowerer.noteGenericClassInstanceDemand(existing.info);
@@ -4635,13 +4668,19 @@ export function genericClassInstanceType(
gci.instances.set(key, entry);
const bindings = new Map<ts.Symbol, IrType>();
gci.typeParams.forEach((tp, i) => bindings.set(tp, mapped[i]!));
const rendered = mapped.map((m) => lowerer.fmt(m)).join(", ");
const tsBindings = new Map<ts.Symbol, ts.Type>();
gci.typeParams.forEach((tp, i) => {
if (sensitive(i)) tsBindings.set(tp, tsArgs[i]!);
});
const rendered = mapped.map((m, i) => literalText(i) ?? lowerer.fmt(m)).join(", ");
const typeArgsText = `<${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered}>`;
const prevBindings = lowerer.typeParamBindings;
const prevTsBindings = lowerer.typeParamTsBindings;
const prevContext = lowerer.instantiationContext;
const previousPath = lowerer.genericInstantiationPath;
lowerer.genericInstantiationPath = path;
lowerer.typeParamBindings = bindings;
lowerer.typeParamTsBindings = tsBindings;
lowerer.instantiationContext = `instantiating class '${gci.baseName}' with ${typeArgsText}`;
try {
lowerer.collectClassShapeInner(decl, undefined, {
@@ -4649,6 +4688,7 @@ export function genericClassInstanceType(
family: gci.family,
name,
bindings,
tsBindings,
typeArgsText,
ordinal,
});
@@ -4660,6 +4700,7 @@ export function genericClassInstanceType(
return null;
} finally {
lowerer.typeParamBindings = prevBindings;
lowerer.typeParamTsBindings = prevTsBindings;
lowerer.instantiationContext = prevContext;
lowerer.genericInstantiationPath = previousPath;
}
@@ -4675,6 +4716,63 @@ export function genericClassInstanceType(
return { kind: "object", className: name };
}
/** The class's type parameters that a conditional type inside it depends
* on: the parameters named within a type node that is a conditional type
* or reaches one through the type aliases and interfaces it refers to
* (`get(): Value<T, K>` with `type Value<T, K> = K extends "required" ? T
* : T | undefined`, also when the alias wraps the conditional in a union
* or another alias). Their arguments pick the conditional's branch. */
function literalSensitiveTypeParams(lowerer: Lowerer, gci: GenericClassInfo): Set<ts.Symbol> {
const params = new Set(gci.typeParams);
const found = new Set<ts.Symbol>();
const reaches = new Map<ts.Symbol, boolean>();
const declarationsReach = (symbol: ts.Symbol): boolean => {
const target =
symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol;
const known = reaches.get(target);
if (known !== undefined) return known;
reaches.set(target, false); // a recursive alias reaches through its other parts
const result = target.declarations.some((d) =>
ts.isTypeAliasDeclaration(d)
? reachesConditional(d.type)
: ts.isInterfaceDeclaration(d)
? d.members.nodes.some(reachesConditional)
: false,
);
reaches.set(target, result);
return result;
};
const reachesConditional = (node: ts.Node): boolean => {
if (ts.isConditionalTypeNode(node)) return true;
if (ts.isTypeReferenceNode(node)) {
const name = ts.isIdentifier(node.typeName) ? node.typeName : node.typeName.right;
const symbol = lowerer.checker.getSymbolAtLocation(name);
if (symbol && !params.has(symbol) && declarationsReach(symbol)) return true;
}
let found = false;
ts.forEachChild(node, (child) => {
if (!found && reachesConditional(child)) found = true;
});
return found;
};
const collect = (node: ts.Node): void => {
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
const symbol = lowerer.checker.getSymbolAtLocation(node.typeName);
if (symbol && params.has(symbol)) found.add(symbol);
}
ts.forEachChild(node, collect);
};
const visit = (node: ts.Node): void => {
if (ts.isTypeNode(node) && reachesConditional(node)) {
collect(node);
return;
}
ts.forEachChild(node, visit);
};
for (const member of gci.decl.members.nodes) visit(member);
return found;
}
/** Runs a member-lowering thunk under an INSTANTIATION's type-parameter
* bindings (the generic-fn typeParamResolver mechanism) — the checker
* keeps reporting the unsubstituted `T`s inside the shared body AST.
@@ -4708,17 +4806,20 @@ function withInstanceBindings<T>(lowerer: Lowerer, info: ClassInfo, fn: () => T)
}
}
const prevBindings = lowerer.typeParamBindings;
const prevTsBindings = lowerer.typeParamTsBindings;
const prevContext = lowerer.instantiationContext;
const prevSuppress = lowerer.suppressStats;
const previousPath = lowerer.genericInstantiationPath;
lowerer.genericInstantiationPath = gi.path;
lowerer.typeParamBindings = gi.bindings;
lowerer.typeParamTsBindings = gi.tsBindings;
lowerer.instantiationContext = `instantiating class '${gi.family.generic?.baseName ?? info.def.jsName ?? ""}' with ${gi.typeArgsText}`;
lowerer.suppressStats = prevSuppress || gi.ordinal > 0;
try {
return fn();
} finally {
lowerer.typeParamBindings = prevBindings;
lowerer.typeParamTsBindings = prevTsBindings;
lowerer.instantiationContext = prevContext;
lowerer.suppressStats = prevSuppress;
lowerer.genericInstantiationPath = previousPath;
@@ -8437,6 +8538,10 @@ function superCallInStatementPosition(call: ts.CallExpression): boolean {
for (;;) {
const parent = node.parent;
if (ts.isExpressionStatement(parent)) return true;
if (ts.isIfStatement(parent)) {
const lead = superConditionLead(parent.expression);
return lead !== null && call.pos >= lead.left.pos && call.end <= lead.left.end;
}
const comma =
ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.CommaToken;
if (!comma && !ts.isParenthesizedExpression(parent)) return false;
@@ -8444,6 +8549,29 @@ function superCallInStatementPosition(call: ts.CallExpression): boolean {
}
}
/** The comma chain of an `if` condition whose leading operands hold a
* super() call (`if (super(), r === 0) throw ...`, as minifiers join a
* super statement with the next test), or null. The if statement runs the
* chain's left operand as a statement first and tests its right operand,
* which is what the comma evaluates. */
export function superConditionLead(cond: ts.Expression): ts.BinaryExpression | null {
let expr = cond;
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.CommaToken)
return null;
const holdsSuperCall = (operand: ts.Expression): boolean => {
let e = operand;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (ts.isCallExpression(e)) return e.expression.kind === ts.SyntaxKind.SuperKeyword;
return (
ts.isBinaryExpression(e) &&
e.operatorToken.kind === ts.SyntaxKind.CommaToken &&
(holdsSuperCall(e.left) || holdsSuperCall(e.right))
);
};
return holdsSuperCall(expr.left) ? expr : null;
}
/** Super statements initialize the base and then the derived fields on
* whichever control-flow path executes. Repeated calls are explicitly
* refused until native layouts can allocate a second receiver. */
@@ -2128,8 +2128,12 @@ function ensureEmitSpec(lowerer: Lowerer, info: ClassInfo, event: string, tuple:
params: tuple.map((t) => ({ type: t, mode: "required" as const })),
ret: BOOL,
});
if (info.def.methods) info.def.methods.push(mName);
else info.def.methods = [mName];
// Specializations sit in the method list sorted by event name, so the
// class table does not depend on which body asked for one first.
const methods = (info.def.methods ??= []);
const after = methods.findIndex((m) => m.startsWith("emit:") && m > mName);
if (after < 0) methods.push(mName);
else methods.splice(after, 0, mName);
}
const ordinal = lowerer.emitSpecQueue.filter((q) => q.info === info).length;
lowerer.emitSpecQueue.push({ info, event, tuple, ordinal });
@@ -1420,9 +1420,83 @@ export function lowerOptionalNumber(
* out-of-range read, a local or field holding one, or a hole — converts to
* NaN instead of trusting the checker's number type. */
export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): IrExpr {
const operand = numberOperand(lowerer, node);
return operand.convert ? operand.convert(operand.value) : operand.value;
}
/** The ToNumber arguments of one call (the Math functions,
* String.fromCharCode): Node evaluates every argument before it converts
* any. When an argument's conversion can run program code (valueOf or
* toString on a dynamic value) and a later argument follows, the arguments
* evaluate into temporaries first and convert in order after. `lowerOther`
* lowers an argument that takes no conversion (a spread's array), or returns
* null for a ToNumber argument; with temporaries, such an array is copied
* where it is evaluated, since JavaScript reads a spread's elements then,
* before a later conversion's valueOf could change them. Returns the
* temporaries' statements (empty when none are needed) and the converted
* values. */
export function lowerToNumberArguments(
lowerer: Lowerer,
nodes: readonly ts.Expression[],
lowerOther?: (node: ts.Expression) => IrExpr | null,
): { stmts: IrStmt[]; values: IrExpr[] } {
const operands = nodes.map((node): NumberOperand => {
const other = lowerOther?.(node);
return other
? { value: other, convert: null, runsCode: false, spread: true }
: numberOperand(lowerer, node);
});
const late = operands.slice(0, -1).some((operand) => operand.runsCode);
if (!late) {
const values = operands.map((o) => (o.convert ? o.convert(o.value) : o.value));
return { stmts: [], values };
}
const stmts: IrStmt[] = [];
const values = operands.map((operand) => {
if (operand.value.kind === "numLit") return operand.value;
const temp = lowerer.declareHiddenLocal("%numberArgument", operand.value.type);
const loc = operand.value.loc;
const init: IrExpr =
operand.spread && operand.value.type.kind === "array"
? {
kind: "arrIntrinsic",
method: "slice",
receiver: operand.value,
args: [numLit(0, loc), numLit(Infinity, loc)],
type: operand.value.type,
loc,
}
: operand.value;
stmts.push({ kind: "varDecl", localId: temp.id, init, loc });
const read = varRef(temp.id, operand.value.type, loc);
return operand.convert ? operand.convert(read) : read;
});
return { stmts, values };
}
/** An argument a ToNumber site lowers: its evaluated value and the
* conversion that makes it a number (null when the value already is one).
* `runsCode`: the conversion can call program code. `spread`: the value is
* a spread's array (lowerToNumberArguments' `lowerOther`). */
interface NumberOperand {
value: IrExpr;
convert: ((value: IrExpr) => IrExpr) | null;
runsCode: boolean;
spread?: boolean;
}
function numberOperand(lowerer: Lowerer, node: ts.Expression): NumberOperand {
let inner = node;
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
const loc = locOf(inner);
const number = (value: IrExpr): NumberOperand => ({ value, convert: null, runsCode: false });
const dynToNumber = (value: IrExpr): IrExpr => ({
kind: "libCall",
fn: "dyn.toNumberCoerce",
args: [value],
type: F64,
loc,
});
const isOptionalNumber = (value: IrExpr): boolean =>
value.type.kind === "union" &&
lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 &&
@@ -1433,12 +1507,54 @@ export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): Ir
? lowerAbsenceProbe(lowerer, inner)
: null;
if (optional && isOptionalNumber(optional))
return lowerOptionalNumber(lowerer, optional, loc, inner);
// A conditional builds each branch for the destination.
if (ts.isConditionalExpression(inner)) return lowerer.lowerExprExpecting(node, F64);
return number(lowerOptionalNumber(lowerer, optional, loc, inner));
// A conditional with number branches builds each branch as a number;
// other branches box, and the value converts like any dynamic value.
if (ts.isConditionalExpression(inner)) {
const branch = (b: ts.Expression) => lowerer.mapTypeOf(lowerer.typeOf(b))?.kind;
if (branch(inner.whenTrue) === "f64" && branch(inner.whenFalse) === "f64")
return number(lowerer.lowerExprExpecting(node, F64));
return { value: lowerer.lowerExprExpecting(node, DYN), convert: dynToNumber, runsCode: true };
}
const value = lowerer.lowerExpr(node);
if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner);
return lowerer.coerceInto(node, value, F64);
if (isOptionalNumber(value)) return number(lowerOptionalNumber(lowerer, value, loc, inner));
// Other values convert with ToNumber: strings parse as numeric literals,
// booleans and null are numbers, and checked-dynamic values call
// valueOf/toString or throw Node's TypeError for a Symbol or BigInt.
const num = (n: number): IrExpr => ({ kind: "numLit", value: n, type: F64, loc });
const pure = (convert: (value: IrExpr) => IrExpr): NumberOperand => ({
value,
convert,
runsCode: false,
});
switch (value.type.kind) {
case "string":
return pure((v) => ({ kind: "libCall", fn: "num.fromString", args: [v], type: F64, loc }));
case "bool":
return pure((v) => ({
kind: "ternary",
cond: v,
then: num(1),
else_: num(0),
type: F64,
loc,
}));
case "nullT":
return pure((v) => defaultAfterUndefined(v, num(0)));
case "undefinedT":
return pure((v) => defaultAfterUndefined(v, num(NaN)));
case "dyn":
return { value, convert: dynToNumber, runsCode: true };
case "union":
if (lowerer.dynConvertible(value.type))
return {
value: lowerer.coerceToExpected(value, DYN),
convert: dynToNumber,
runsCode: true,
};
break;
}
return number(lowerer.coerceInto(node, value, F64));
}
/** The stored value of an identifier whose storage holds an undefined arm
@@ -1,14 +1,20 @@
import { declareTypedArguments } from "./typed-arguments.js";
import { varRef } from "../../ir/build.js";
import { dynUndefinedExpr, varRef } from "../../ir/build.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { bindingNeverReassigned } from "./binding-analysis.js";
import { declareContextLocal } from "./function-context.js";
import { isErrorFirstErrorType } from "./builtins/arguments.js";
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
import { isRebindableFunctionSymbol } from "./rebindable-functions.js";
import { bodyReadsArguments } from "../arguments-usage.js";
import type { Lowerer } from "./lowerer.js";
import {
BOOL,
DYN,
type IrExpr,
type IrFunction,
type IrLocal,
type IrParam,
type IrStmt,
type SrcLoc,
@@ -253,6 +259,14 @@ export function lowerLambda(
shapes,
funcType,
);
// An error-first callback that reads its Buffer result: undefined on the
// error path, as in Node.
const undefinedResult = undefinedResultParam(
lowerer,
node,
shapes,
lowerer.errorFirstLiterals.has(node),
);
const nameIdent =
!ts.isArrowFunction(node) && node.name && ts.isIdentifier(node.name) ? node.name : null;
const baseName = nameIdent ? nameIdent.text : "";
@@ -340,78 +354,84 @@ export function lowerLambda(
!shapes.some((s) => s.mode === "dynRest"),
);
let body: IrStmt[];
if (ts.isBlock(node.body!)) {
body = lowerer.lowerStmts(node.body!.statements.nodes);
} else {
// Bare-expression arrow body: `x => e` is `x => { return e; }`
// (or an expression statement when the signature returns void — or
// when a union-returning signature wraps a void expression, whose
// value is the implicit undefined arm appended below).
const bodyExpr = node.body as ts.Expression;
if (bodyReturn.kind === "void") {
// `() => undefined` — the return type maps to void (standalone
// undefined IS void in the type mapping) and the body value is a
// bare unit literal: a pure no-op, dropped rather than tripping
// the validator's bare-unitLit rule (typeCheckReturnExpression).
// A `void e` body rides the statement lowering (the value is
// discarded here, so the operand evaluates for effect alone —
// `(name) => void doThing(name)`, the fire-and-forget arrow). A
// conditional body does the same so its void arms become lazy
// statement branches instead of a forbidden void-valued ternary.
let stripped: ts.Expression = bodyExpr;
while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression;
if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) {
body = [lowerer.lowerExprStatement(stripped)];
const lowerBody = (): IrStmt[] => {
let body: IrStmt[];
if (ts.isBlock(node.body!)) {
body = lowerer.lowerStmts(node.body!.statements.nodes);
} else {
// Bare-expression arrow body: `x => e` is `x => { return e; }`
// (or an expression statement when the signature returns void — or
// when a union-returning signature wraps a void expression, whose
// value is the implicit undefined arm appended below).
const bodyExpr = node.body as ts.Expression;
if (bodyReturn.kind === "void") {
// `() => undefined` — the return type maps to void (standalone
// undefined IS void in the type mapping) and the body value is a
// bare unit literal: a pure no-op, dropped rather than tripping
// the validator's bare-unitLit rule (typeCheckReturnExpression).
// A `void e` body rides the statement lowering (the value is
// discarded here, so the operand evaluates for effect alone —
// `(name) => void doThing(name)`, the fire-and-forget arrow). A
// conditional body does the same so its void arms become lazy
// statement branches instead of a forbidden void-valued ternary.
let stripped: ts.Expression = bodyExpr;
while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression;
if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) {
body = [lowerer.lowerExprStatement(stripped)];
} else {
const value = lowerer.lowerExpr(bodyExpr);
// `async () => p` with a void result still resolves the
// function's promise with p (its settlement, rejections included);
// a maybe-promise value adopts only its promise arm.
const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null;
body = flattened
? [flattened]
: value.kind === "unitLit"
? []
: isAsync && value.type.kind === "promise"
? [lowerer.adoptingReturn(value, VOID, (e) => e)]
: [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }];
}
} else {
const value = lowerer.lowerExpr(bodyExpr);
// `async () => p` with a void result still resolves the
// function's promise with p (its settlement, rejections included);
// a maybe-promise value adopts only its promise arm.
const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null;
// Concise and block bodies use the same destination layout for
// fresh literals. Async bodies must inspect promises first.
let value =
isAsync || fnCtx.inferReturn
? lowerer.lowerExpr(bodyExpr)
: lowerer.lowerExprExpecting(bodyExpr, bodyReturn);
// An async concise body whose value is itself a promise
// (`async () => p`): the async machinery RESOLVES the returned
// thenable into the function's own promise — lowerReturnValue's
// await-through, applied to the implicit return.
if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") {
value = lowerer.returnedPromiseAwait(value);
}
const flattened =
isAsync && !fnCtx.inferReturn
? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn)
: null;
body = flattened
? [flattened]
: value.kind === "unitLit"
? []
: isAsync && value.type.kind === "promise"
? [lowerer.adoptingReturn(value, VOID, (e) => e)]
: [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }];
: value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!))
? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]
: [
{
kind: "return",
value: fnCtx.inferReturn
? value
: lowerer.coerceInto(bodyExpr, value, bodyReturn),
loc: locOf(node.body!),
},
];
if (fnCtx.inferReturn && body[0]?.kind === "return")
fnCtx.inferReturn.entries.push({ stmt: body[0], node: bodyExpr });
}
} else {
// Concise and block bodies use the same destination layout for
// fresh literals. Async bodies must inspect promises first.
let value =
isAsync || fnCtx.inferReturn
? lowerer.lowerExpr(bodyExpr)
: lowerer.lowerExprExpecting(bodyExpr, bodyReturn);
// An async concise body whose value is itself a promise
// (`async () => p`): the async machinery RESOLVES the returned
// thenable into the function's own promise — lowerReturnValue's
// await-through, applied to the implicit return.
if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") {
value = lowerer.returnedPromiseAwait(value);
}
const flattened =
isAsync && !fnCtx.inferReturn
? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn)
: null;
body = flattened
? [flattened]
: value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!))
? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]
: [
{
kind: "return",
value: fnCtx.inferReturn
? value
: lowerer.coerceInto(bodyExpr, value, bodyReturn),
loc: locOf(node.body!),
},
];
if (fnCtx.inferReturn && body[0]?.kind === "return")
fnCtx.inferReturn.entries.push({ stmt: body[0], node: bodyExpr });
}
}
return body;
};
let body = undefinedResult
? lowerUndefinedResultBody(lowerer, node, undefinedResult, lowerBody)
: lowerBody();
if (fnCtx.inferReturn) {
bodyReturn = resolveInferredReturn(
lowerer,
@@ -537,7 +557,14 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I
"function declarations whose block body the frontend cannot locate",
);
}
const body = [...prologue, ...lowerer.lowerStmts(bodyBlock.statements.nodes)];
const undefinedResult = undefinedResultParam(lowerer, decl, sig.params, false);
const lowerBody = (): IrStmt[] => lowerer.lowerStmts(bodyBlock.statements.nodes);
const body = [
...prologue,
...(undefinedResult
? lowerUndefinedResultBody(lowerer, decl, undefinedResult, lowerBody)
: lowerBody()),
];
appendImplicitUndefinedReturn(lowerer, body, bodyReturn, locOf(decl));
const fn: IrFunction = {
name: sig.name,
@@ -594,3 +621,240 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I
lowerer.fnStack.pop();
}
}
/** The parameters of an error-first callback whose Buffer result its body
* reads: `(err, out)` with err typed Error | null (or any) and out a
* Buffer. The callback adapters of zlib and crypto pass a NULL Buffer
* after an error, where Node passes undefined. `error` is the error
* parameter's symbol when nothing writes it; `onlyAdapterCalls` marks a
* literal passed straight to such a function, which nothing else calls. */
interface UndefinedResultParam {
symbol: ts.Symbol;
name: ts.Identifier;
error: ts.Symbol | null;
onlyAdapterCalls: boolean;
}
function undefinedResultParam(
lowerer: Lowerer,
node: ts.SignatureDeclaration,
shapes: readonly ParamShape[],
onlyAdapterCalls: boolean,
): UndefinedResultParam | null {
const params = node.parameters.nodes.filter((p) => !isThisParameter(p));
if (params.length !== 2 || shapes.length !== 2 || node.asteriskToken !== undefined) return null;
const [errorDecl, decl] = params as [ts.ParameterDeclaration, ts.ParameterDeclaration];
const [errorShape, shape] = shapes as [ParamShape, ParamShape];
if (
!ts.isIdentifier(decl.name) ||
decl.initializer !== undefined ||
decl.questionToken !== undefined ||
decl.dotDotDotToken !== undefined ||
shape.mode !== "required" ||
shape.type.kind !== "bytes" ||
shape.type.elem !== "u8" ||
errorShape.mode !== "required" ||
!isErrorFirstErrorType(lowerer, errorShape.type)
)
return null;
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
if (
symbol === undefined ||
node.body === undefined ||
!readsSymbol(lowerer, node.body, symbol, decl.name)
)
return null;
const errorSymbol = ts.isIdentifier(errorDecl.name)
? lowerer.checker.getSymbolAtLocation(errorDecl.name)
: undefined;
const error =
errorSymbol !== undefined &&
errorDecl.initializer === undefined &&
bindingNeverReassigned(lowerer, errorSymbol, errorDecl)
? errorSymbol
: null;
return { symbol, name: decl.name, error, onlyAdapterCalls };
}
/** Whether `body` reads `symbol` (other than at its declaration `name`). */
function readsSymbol(
lowerer: Lowerer,
body: ts.Node,
symbol: ts.Symbol,
name: ts.Identifier,
): boolean {
let read = false;
const visit = (n: ts.Node): void => {
if (read) return;
if (ts.isShorthandPropertyAssignment(n)) {
if (lowerer.checker.getShorthandAssignmentValueSymbol(n) === symbol) read = true;
} else if (ts.isIdentifier(n)) {
if (n !== name && lowerer.checker.getSymbolAtLocation(n) === symbol) read = true;
} else {
ts.forEachChild(n, visit);
}
};
visit(body);
return read;
}
/** The body of an error-first callback whose Buffer result may be absent.
* With a result, the body lowers as written. Without one (the adapter's
* NULL Buffer after an error), the body lowers again with the result read
* as `any` holding undefined and the error parameter holding an Error.
* Conditions on the error parameter fold in that copy (staticCondition),
* and in the first copy too, as null, for a literal only the adapter
* calls: a body that returns after `if (err)` then keeps the rest of its
* statements in the first copy only.
*
* Inside an error-path copy, a nested callback lowers once with the result
* read as `any`, so nested callbacks do not double at every level. An
* error-path copy with no lowering throws a coded error when it runs. */
function lowerUndefinedResultBody(
lowerer: Lowerer,
node: ts.SignatureDeclaration,
result: UndefinedResultParam,
lowerBody: () => IrStmt[],
): IrStmt[] {
const loc = locOf(node);
const param = lowerer.bindingIn(lowerer.ctx, result.symbol);
if (param === null) throw new InternalCompilerError("lowerer bug: callback result unbound");
const absent = (): IrExpr => ({
kind: "libCall",
fn: "bytes.isAbsent",
args: [varRef(param.id, param.type, loc)],
type: BOOL,
loc,
});
const fence: IrStmt = {
kind: "runtimeFence",
code: "SC2020",
message: `the callback's '${result.name.text}' is undefined after an error, and this callback has no lowering that reads it as undefined`,
loc,
};
const withKnownError = (error: boolean | null, lower: () => IrStmt[]): IrStmt[] => {
if (result.error === null || error === null) return lower();
lowerer.knownErrors.set(result.error, error);
try {
return lower();
} finally {
lowerer.knownErrors.delete(result.error);
}
};
/** The body with the result read as `any` from `slot`, or null when it
* has no such lowering or converts the result into a static slot. */
const lowerDynamic = (slot: IrLocal, knownError: boolean | null): IrStmt[] | null => {
const diags = lowerer.diags.length;
const stats = lowerer.suppressStats;
lowerer.scopes.push(new Map([[result.symbol, slot]]));
lowerer.anySymbols.add(result.symbol);
lowerer.suppressStats = true;
lowerer.errorPathCopies++;
try {
const lowered = withKnownError(knownError, lowerBody);
if (lowerer.diags.length > diags || !readsSlotDynamically(lowered, slot.id))
throw new PoisonError();
// Passed on into a Buffer slot, the result is the Buffer it holds
// (the adapter's, or one the body assigned), or, while it holds
// undefined, the absent Buffer, which a callee with this entry test
// reads as undefined.
const guarded = new Set<IrExpr>();
return transformStmtList(lowered, {
expr: (e) => {
if (!isBufferCheckOf(e, slot.id) || guarded.has(e)) return e;
guarded.add(e);
return {
kind: "ternary",
cond: {
kind: "dynTest",
test: "undefined",
value: varRef(slot.id, DYN, e.loc),
type: BOOL,
loc: e.loc,
},
then: { kind: "libCall", fn: "bytes.absent", args: [], type: e.type, loc: e.loc },
else_: e,
type: e.type,
loc: e.loc,
};
},
stmt: (stmt) => stmt,
});
} catch (error) {
if (error instanceof InternalCompilerError) throw error;
lowerer.diags.length = diags;
return null;
} finally {
lowerer.errorPathCopies--;
lowerer.anySymbols.delete(result.symbol);
lowerer.scopes.pop();
lowerer.suppressStats = stats;
}
};
// Mutable: the body may assign to its result (`if (!out) out = ...`).
const slot = declareContextLocal(lowerer.ctx, "%result", DYN, true, undefined, undefined);
const undefinedResult: IrStmt = {
kind: "varDecl",
localId: slot.id,
init: dynUndefinedExpr(loc),
loc,
};
if (lowerer.errorPathCopies > 0) {
// One copy for both paths, with the result boxed when present.
const dynamic = lowerDynamic(slot, null);
if (dynamic !== null)
return [
{
kind: "varDecl",
localId: slot.id,
init: {
kind: "ternary",
cond: absent(),
then: dynUndefinedExpr(loc),
else_: lowerer.coerceInto(result.name, varRef(param.id, param.type, loc), DYN),
type: DYN,
loc,
},
loc,
},
...dynamic,
];
}
const withResult = withKnownError(result.onlyAdapterCalls ? false : null, lowerBody);
const withoutResult = lowerDynamic(slot, true);
return [
{
kind: "if",
cond: absent(),
then: withoutResult === null ? [fence] : [undefinedResult, ...withoutResult],
else_: withResult,
loc,
},
];
}
/** Whether the error-path copy reads the checked-dynamic result slot only as
* a checked-dynamic value, never converted into a static slot (where Node
* would hold undefined) other than a Buffer one. */
function readsSlotDynamically(body: IrStmt[], slotId: string): boolean {
const isSlot = (e: IrExpr): boolean => e.kind === "varRef" && e.localId === slotId;
return everyStmtList(body, {
expr: (e) => {
if (isSlot(e)) return e.type.kind === "dyn";
if (e.kind === "dynCheck" && isSlot(e.value)) return isBufferCheckOf(e, slotId);
return true;
},
stmt: () => true,
});
}
/** Whether `e` converts the result slot into a Buffer slot. */
function isBufferCheckOf(e: IrExpr, slotId: string): boolean {
return (
e.kind === "dynCheck" &&
e.value.kind === "varRef" &&
e.value.localId === slotId &&
e.type.kind === "bytes" &&
e.type.elem === "u8"
);
}
@@ -1054,7 +1054,8 @@ export function moduleArtifacts(
}
const reachable = lowerer.reachableForArtifacts ?? lowerer.reachable;
return {
classes: [...lowerer.classes.values()]
classes: lowerer
.classesInTableOrder()
.map((c) => c.def)
.filter((def) => classNames.has(def.name))
.map((def): IrClassDef => {
@@ -4749,7 +4749,8 @@ function lowerTlsServerOptions(
lowerer: Lowerer,
node: ts.Expression,
what: string,
): { cert: IrExpr; key: IrExpr } {
allowSni = false,
): { cert: IrExpr; key: IrExpr; sni?: IrExpr } {
if (!ts.isObjectLiteralExpression(node)) {
lowerer.noLowering(
`${what} with a non-literal options argument`,
@@ -4760,6 +4761,8 @@ function lowerTlsServerOptions(
const loc = locOf(node);
let cert: IrExpr | null = null;
let key: IrExpr | null = null;
let sni: IrExpr | undefined;
let pendingSni: IrStmt | null = null;
for (const prop of node.properties.nodes) {
let initializer: ts.Expression | null;
if (
@@ -4777,6 +4780,25 @@ function lowerTlsServerOptions(
);
}
const k = (prop.name as ts.Identifier | ts.StringLiteral).text;
if (k === "SNICallback" && allowSni) {
const value =
initializer !== null
? lowerer.lowerExpr(initializer)
: lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment);
const checked = checkSniCallbackValue(lowerer, value, prop, what);
if (cert !== null && key !== null) {
sni = checked;
} else {
// The runtime call reads cert and key first. A cert or key entry
// after the callback can change the binding the callback reads,
// so the callback is saved ahead of the next of them, in source
// order.
const saved = lowerer.declareHiddenLocal("%sniCallback", checked.type);
pendingSni = { kind: "varDecl", localId: saved.id, init: checked, loc: locOf(prop) };
sni = varRef(saved.id, checked.type, locOf(prop));
}
continue;
}
if (k !== "cert" && k !== "key") {
const init = ts.isPropertyAssignment(prop) ? prop.initializer : null;
if (
@@ -4801,7 +4823,7 @@ function lowerTlsServerOptions(
"cert and key (PEM strings or Buffers) are the supported options",
{
SNICallback:
"SNICallback lowers only on http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here",
"SNICallback lowers on tls.createServer and http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here",
requestCert:
"client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert: false is the lowered value",
},
@@ -4838,6 +4860,10 @@ function lowerTlsServerOptions(
"cert/key are PEM strings or Buffers here",
);
}
if (pendingSni !== null) {
v = { kind: "seqExpr", stmts: [pendingSni], result: v, type: v.type, loc };
pendingSni = null;
}
if (k === "cert") cert = v;
else key = v;
}
@@ -4848,7 +4874,7 @@ function lowerTlsServerOptions(
"the supported options object is { cert, key } (PEM strings or Buffers)",
);
}
return { cert, key };
return sni === undefined ? { cert, key } : { cert, key, sni };
}
/** The options argument of tls/https createServer, split three ways: an
@@ -4877,12 +4903,14 @@ const TLS_VALIDATED_OPTIONS: ReadonlySet<string> = new Set([
]);
/** True when a literal options bag must ride the runtime walker: JS uses
* it for the typed TLS validation ladders, and HTTPS uses it for
* it for the typed TLS validation ladders, HTTPS uses it for
* keepAliveTimeoutBuffer so the HTTP option is consumed without losing
* the literal's evaluation order. */
* the literal's evaluation order, and `runtimeKeys` names options only the
* runtime walk implements (tls.createServer's ALPNProtocols). */
function tlsLiteralNeedsRuntimeWalk(
node: ts.ObjectLiteralExpression,
includeHttpTimeoutOptions = false,
runtimeKeys: ReadonlySet<string> = new Set(),
): boolean {
const jsSource = isJsSourceFile(ts.getSourceFileOfNode(node));
return node.properties.nodes.some(
@@ -4890,21 +4918,43 @@ function tlsLiteralNeedsRuntimeWalk(
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
((includeHttpTimeoutOptions && p.name.text === "keepAliveTimeoutBuffer") ||
runtimeKeys.has(p.name.text) ||
(jsSource && TLS_VALIDATED_OPTIONS.has(p.name.text))),
);
}
/** tls.createServer options the runtime walk implements and the literal
* cert/key lowering does not. */
const TLS_SERVER_RUNTIME_OPTIONS: ReadonlySet<string> = new Set([
"ALPNProtocols",
"minVersion",
"maxVersion",
]);
function lowerTlsServerOptionsOrDyn(
lowerer: Lowerer,
node: ts.Expression,
what: string,
includeHttpTimeoutOptions = false,
): { cert: IrExpr; key: IrExpr; dyn?: undefined } | { dyn: IrExpr } {
if (
ts.isObjectLiteralExpression(node) &&
!tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions)
) {
return lowerTlsServerOptions(lowerer, node, what);
runtimeKeys: ReadonlySet<string> = new Set(),
allowSni = false,
): { cert: IrExpr; key: IrExpr; sni?: IrExpr; dyn?: undefined } | { dyn: IrExpr } {
if (ts.isObjectLiteralExpression(node)) {
if (!tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions, runtimeKeys))
return lowerTlsServerOptions(lowerer, node, what, allowSni);
const sniOption = node.properties.nodes.find(
(p) =>
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
p.name.text === "SNICallback",
);
if (allowSni && sniOption !== undefined) {
lowerer.noLowering(
`${what} with SNICallback and options the runtime walk reads`,
sniOption,
"SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet",
);
}
}
const v = lowerer.lowerExpr(node);
if (v.type.kind === "dyn") return { dyn: v };
@@ -4934,14 +4984,14 @@ function lowerTlsServerOptionsOrDyn(
* connect(options[, cb]) and connect(port[, host][, options][, cb]) with
* a RUNTIME options record (the checked-dynamic JS lane's shape — a dyn
* value, or a record that converts): the runtime walk implements
* port/host/rejectUnauthorized/ca/servername and throws the catchable
* port/host/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout and throws the catchable
* fence for other documented members (the divergence-66 stance). The
* callback (and 'secureConnect'/'connect' listeners) fires
* post-handshake — Node's secureConnect timing. */
function lowerTlsConnectCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr {
const args = expr.arguments.nodes;
const FORMS_HINT =
"the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/rejectUnauthorized/ca/servername";
"the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout";
if (args.length < 1 || args.length > 4 || args.some(ts.isSpreadElement)) {
lowerer.noLowering(`tls.connect with ${args.length} arguments`, expr, FORMS_HINT);
}
@@ -5060,10 +5110,31 @@ function lowerTlsModuleCall(
"the supported form is createServer({ cert, key }, socket => ...)",
);
}
const opts = lowerTlsServerOptionsOrDyn(lowerer, args[0]!, "tls.createServer");
const opts = lowerTlsServerOptionsOrDyn(
lowerer,
args[0]!,
"tls.createServer",
false,
TLS_SERVER_RUNTIME_OPTIONS,
true,
);
if (args.length === 1) {
return opts.dyn !== undefined
? { kind: "libCall", fn: "tls.createServerDyn", args: [opts.dyn], type: NETSERVER_T, loc }
if (opts.dyn !== undefined)
return {
kind: "libCall",
fn: "tls.createServerDyn",
args: [opts.dyn],
type: NETSERVER_T,
loc,
};
return opts.sni !== undefined
? {
kind: "libCall",
fn: "tls.createServerSni",
args: [opts.cert, opts.key, opts.sni],
type: NETSERVER_T,
loc,
}
: {
kind: "libCall",
fn: "tls.createServer",
@@ -5081,11 +5152,19 @@ function lowerTlsModuleCall(
"use (socket) or ()",
[NETSOCKET_T],
);
return opts.dyn !== undefined
if (opts.dyn !== undefined)
return {
kind: "libCall",
fn: "tls.createServerDynCb",
args: [opts.dyn, cb],
type: NETSERVER_T,
loc,
};
return opts.sni !== undefined
? {
kind: "libCall",
fn: "tls.createServerDynCb",
args: [opts.dyn, cb],
fn: "tls.createServerSniCb",
args: [opts.cert, opts.key, opts.sni, cb],
type: NETSERVER_T,
loc,
}
@@ -5463,6 +5542,44 @@ const SNI_HINT =
"...(x ? { SNICallback: x } : {}) — the callback is (servername, cb) => void " +
"with cb: (err: Error | null, ctx?: tls.SecureContext) => void";
/** An SNICallback option value: (servername: string, cb) => void where cb
* is (err: Error | null, ctx?: SecureContext) => void, or that function
* unioned with undefined (no callback). cb may declare fewer params (the
* emitted answer thunk matches its ABI), but what it does declare must
* carry the arms the thunk decodes. */
function lowerSniCallbackValue(
lowerer: Lowerer,
node: ts.Expression,
blame: ts.Node,
what: string,
): IrExpr {
return checkSniCallbackValue(lowerer, lowerer.lowerExpr(node), blame, what);
}
function checkSniCallbackValue(lowerer: Lowerer, v: IrExpr, blame: ts.Node, what: string): IrExpr {
const shapeOk = (t: IrType): boolean => {
if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false;
if (t.params[0]!.kind !== "string") return false;
const cbT = t.params[1]!;
if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false;
const armKinds = (u: IrType): string[] =>
u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : [];
const p0 = cbT.params[0];
if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false;
const p1 = cbT.params[1];
if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false;
return true;
};
const arms = v.type.kind === "union" ? (lowerer.unions.get(v.type.unionId)?.arms ?? []) : [];
const funcArm = v.type.kind === "func" ? v.type : arms.find((a) => a.kind === "func");
const unionOk =
v.type.kind !== "union" || (arms.length === 2 && arms.some((a) => a.kind === "undefinedT"));
if (funcArm === undefined || !unionOk || !shapeOk(funcArm)) {
lowerer.noLowering(`a ${what} SNICallback of '${lowerer.fmt(v.type)}' values`, blame, SNI_HINT);
}
return v;
}
/** Strips parentheses and type assertions: the portless spelling wraps
* an options spread as `...({ ... } as Record<string, unknown>)`. */
function stripParensAndCasts(node: ts.Expression): ts.Expression {
@@ -5715,43 +5832,8 @@ function lowerHttp2ModuleCall(
let sni: IrExpr | null = null;
let allowHttp1 = false;
let enableConnectProtocol = false;
// The SNICallback value shape: (servername: string, cb) => void where
// cb is (err: Error | null, ctx?: SecureContext) => void — cb may
// declare fewer params (the emitted answer thunk matches its ABI),
// but what it does declare must carry the arms the thunk decodes.
const sniShapeOk = (t: IrType): boolean => {
if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false;
if (t.params[0]!.kind !== "string") return false;
const cbT = t.params[1]!;
if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false;
const armKinds = (u: IrType): string[] =>
u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : [];
const p0 = cbT.params[0];
if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false;
const p1 = cbT.params[1];
if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false;
return true;
};
const lowerSniValue = (node: ts.Expression, blame: ts.Node): void => {
const v = lowerer.lowerExpr(node);
const funcArm =
v.type.kind === "func"
? v.type
: v.type.kind === "union"
? (lowerer.unions.get(v.type.unionId)?.arms ?? []).find((a) => a.kind === "func")
: undefined;
const unionOk =
v.type.kind !== "union" ||
((lowerer.unions.get(v.type.unionId)?.arms ?? []).length === 2 &&
(lowerer.unions.get(v.type.unionId)?.arms ?? []).some((a) => a.kind === "undefinedT"));
if (funcArm === undefined || !unionOk || !sniShapeOk(funcArm)) {
lowerer.noLowering(
`a createSecureServer SNICallback of '${lowerer.fmt(v.type)}' values`,
blame,
SNI_HINT,
);
}
sni = v;
sni = lowerSniCallbackValue(lowerer, node, blame, "createSecureServer");
};
const lowerOption = (prop: ts.ObjectLiteralElementLike): void => {
if (ts.isSpreadAssignment(prop)) {
@@ -2,7 +2,7 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js";
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
import { lowerToNumberArgument } from "./lower-exprs.js";
import { lowerToNumberArguments } from "./lower-exprs.js";
import type { Lowerer } from "./lowerer.js";
import { isDeclaredInAmbientNamespace } from "../type-mapper.js";
import {
@@ -2415,8 +2415,14 @@ export function lowerStdlibMethodCall(
const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined;
// A missing element (a hole bound by a loop, a local or field holding a
// missing read, or the read itself) converts to NaN, ToNumber's answer
// for undefined, instead of trusting the checker's number type.
const mathArg = (a: ts.Expression): IrExpr => lowerToNumberArgument(lowerer, a);
// for undefined, instead of trusting the checker's number type. The
// arguments are all evaluated before any converts (lowerToNumberArguments).
const mathArgs = (
nodes: readonly ts.Expression[],
lowerOther?: (node: ts.Expression) => IrExpr | null,
) => lowerToNumberArguments(lowerer, nodes, lowerOther);
const afterArguments = (stmts: IrStmt[], result: IrExpr): IrExpr =>
stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: result.type, loc };
// Math.hypot, and Math.max/Math.min with a spread among other arguments
// (`Math.max(...xs, 5)`): the arguments in order, spreads flattened, as
// one number[] the runtime folds.
@@ -2427,8 +2433,8 @@ export function lowerStdlibMethodCall(
call.arguments.nodes.length > 1 &&
call.arguments.nodes.some(ts.isSpreadElement)))
) {
const elems = call.arguments.nodes.map((a) =>
ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a),
const { stmts, values: elems } = mathArgs(call.arguments.nodes, (a) =>
ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : null,
);
const spreads = call.arguments.nodes.flatMap((a, i) => (ts.isSpreadElement(a) ? [i] : []));
const packed: IrExpr = {
@@ -2439,7 +2445,7 @@ export function lowerStdlibMethodCall(
loc,
};
const fn = name === "max" ? "math.maxArr" : name === "min" ? "math.minArr" : staticMath.fn;
return { kind: "libCall", fn, args: [packed], type: F64, loc };
return afterArguments(stmts, { kind: "libCall", fn, args: [packed], type: F64, loc });
}
if (staticMath && call.arguments.nodes.every((a) => !ts.isSpreadElement(a))) {
// Math.max/Math.min at ANY plain arity — Node's are variadic. The
@@ -2455,16 +2461,16 @@ export function lowerStdlibMethodCall(
if (call.arguments.nodes.length === 0) {
return { kind: "numLit", value: name === "max" ? -Infinity : Infinity, type: F64, loc };
}
const args = call.arguments.nodes.map(mathArg);
const { stmts, values: args } = mathArgs(call.arguments.nodes);
let acc = args[0]!;
for (let i = 1; i < args.length; i++) {
acc = { kind: "libCall", fn: staticMath.fn, args: [acc, args[i]!], type: F64, loc };
}
return acc;
return afterArguments(stmts, acc);
}
if (call.arguments.nodes.length === staticMath.arity) {
const args = call.arguments.nodes.map(mathArg);
return { kind: "libCall", fn: staticMath.fn, args, type: F64, loc };
const { stmts, values: args } = mathArgs(call.arguments.nodes);
return afterArguments(stmts, { kind: "libCall", fn: staticMath.fn, args, type: F64, loc });
}
}
// `Math.max(...xs)` / `Math.min(...xs)` over a number[]: a STATIC
@@ -54,7 +54,7 @@ function mutate(value) { captured = value; compound += value; let shadow = 0; sh
ctx.bind(shadow, []);
ctx.bind(untouched, []);
ctx.bind(innerShadow, [at(ctx.assignments, 2)]);
const walk = vi.spyOn(ctx.file, "appendChildIndices");
const walk = vi.spyOn(ctx.file.file.wire, "preorder");
for (let i = 0; i < 20; i++) {
expect(jsBindingHasOpenWrites(ctx.lowerer, captured)).toBe(true);
expect(jsBindingHasOpenWrites(ctx.lowerer, compound)).toBe(true);
@@ -159,7 +159,7 @@ import { classStaticDataFor } from "./class-static-data.js";
import { objectFactorySignature } from "./object-factory-new.js";
import { isClassCallback, lowerClassCallbackAssign } from "./class-callbacks.js";
import { classForInHasKey, classPropertiesHelper } from "./class-dynamic-dispatch.js";
import { genericIfaceBindingKeepsClass } from "./lower-classes.js";
import { genericIfaceBindingKeepsClass, superConditionLead } from "./lower-classes.js";
import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js";
import { staticAssertInstanceDecl } from "./assert-instances.js";
import {
@@ -1537,43 +1537,48 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
return lowerer.lowerDefaultExport(stmt);
}
if (ts.isIfStatement(stmt)) {
const cond = lowerer.lowerCondition(stmt.expression);
// `if (super(), test)`: the super call runs as a statement, then the
// test decides.
const lead = superConditionLead(stmt.expression);
const leading = lead ? lowerer.lowerExprStatement(lead.left) : null;
const test = lead ? lead.right : stmt.expression;
const cond = lowerer.lowerCondition(test);
// A global-detection condition (static-typeof.ts) folds; its untaken
// arm is code Node never runs and does not lower, unless it declares a
// hoisted binding.
const decided = staticCondition(lowerer, stmt.expression);
const decided = staticCondition(lowerer, test);
if (decided !== null) {
const taken = decided ? stmt.thenStatement : stmt.elseStatement;
const untaken = decided ? stmt.elseStatement : stmt.thenStatement;
if (untaken === undefined || !declaresHoistedBinding(untaken)) {
const arm = taken === undefined ? [] : lowerer.lowerScopedBlock(taken);
return decided
const folded: IrStmt = decided
? { kind: "if", cond, then: arm, else_: null, loc: locOf(stmt) }
: { kind: "if", cond, then: [], else_: arm, loc: locOf(stmt) };
return leading ? { kind: "block", body: [leading, folded], loc: locOf(stmt) } : folded;
}
}
// Aliased-typeof narrows (ms's `var type = typeof val` guard): the
// then-branch lowers under what a TRUE condition proves, the
// else-branch under a FALSE one — the var/let alias narrowing the
// checker only performs for consts (aliasTypeofNarrows).
const then = lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, stmt.expression, true), () =>
withRuntimeOptionalNarrowed(lowerer, runtimeOptionalTrueIds(lowerer, stmt.expression), () =>
const then = lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, test, true), () =>
withRuntimeOptionalNarrowed(lowerer, runtimeOptionalTrueIds(lowerer, test), () =>
lowerer.lowerScopedBlock(stmt.thenStatement),
),
);
const else_ = stmt.elseStatement
? lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, stmt.expression, false), () =>
withRuntimeOptionalNarrowed(
lowerer,
runtimeOptionalGuardIds(lowerer, stmt.expression, false),
() => lowerer.lowerScopedBlock(stmt.elseStatement!),
? lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, test, false), () =>
withRuntimeOptionalNarrowed(lowerer, runtimeOptionalGuardIds(lowerer, test, false), () =>
lowerer.lowerScopedBlock(stmt.elseStatement!),
),
)
: null;
for (const local of falsyExitRuntimeOptionalLocals(lowerer, stmt)) {
lowerer.runtimeOptionalLocals.delete(lowerer.runtimeOptionalRootOf(local));
}
return { kind: "if", cond, then, else_, loc: locOf(stmt) };
const lowered: IrStmt = { kind: "if", cond, then, else_, loc: locOf(stmt) };
return leading ? { kind: "block", body: [leading, lowered], loc: locOf(stmt) } : lowered;
}
if (ts.isWhileStatement(stmt)) {
const labels = lowerer.takeLabels();
@@ -11594,10 +11599,23 @@ function lowerForInArray(
try {
const arr = lowerer.declareHiddenLocal("%inarr", arrExpr.type);
const len = lowerer.declareHiddenLocal("%inlen", F64);
const keysT: IrType = { kind: "array", elem: STRING };
const keys = lowerer.declareHiddenLocal("%inkeys", keysT);
const total = lowerer.declareHiddenLocal("%intotal", F64);
const i = lowerer.declareHiddenLocal("%ini", F64);
i.mutable = true;
const arrRef = (): IrExpr => ({ kind: "varRef", localId: arr.id, type: arrExpr.type, loc });
const keysRef = (): IrExpr => ({ kind: "varRef", localId: keys.id, type: keysT, loc });
const lenRef = (): IrExpr => ({ kind: "varRef", localId: len.id, type: F64, loc });
const iRef = (): IrExpr => ({ kind: "varRef", localId: i.id, type: F64, loc });
const isIndex = (): IrExpr => ({
kind: "bin",
op: "<",
left: iRef(),
right: lenRef(),
type: BOOL,
loc,
});
const liveLen = (): IrExpr => ({
kind: "arrIntrinsic",
method: "length",
@@ -11606,7 +11624,52 @@ function lowerForInArray(
type: F64,
loc,
});
const keyInit: IrExpr = { kind: "toString", operand: iRef(), type: STRING, loc };
// Indices first, then the named keys the array had when the loop began.
const namedKey = (): IrExpr => ({
kind: "arrayGet",
arr: keysRef(),
index: { kind: "bin", op: "-", left: iRef(), right: lenRef(), type: F64, loc },
type: STRING,
loc,
});
const keyInit: IrExpr = {
kind: "ternary",
cond: isIndex(),
then: { kind: "toString", operand: iRef(), type: STRING, loc },
else_: namedKey(),
type: STRING,
loc,
};
// An index is visited while it is below the live length and not a hole,
// a named key while the array still has it.
const visits: IrExpr = {
kind: "ternary",
cond: isIndex(),
then: {
kind: "logical",
op: "&&",
left: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc },
right: {
kind: "bin",
op: "!==",
left: { kind: "arrayState", arr: arrRef(), index: iRef(), type: F64, loc },
right: { kind: "numLit", value: 0, type: F64, loc },
type: BOOL,
loc,
},
type: BOOL,
loc,
},
else_: {
kind: "libCall",
fn: "array.hasNamedKey",
args: [arrRef(), namedKey()],
type: BOOL,
loc,
},
type: BOOL,
loc,
};
// The three binding forms, sharing the per-visit key declaration.
let keyDecl: IrStmt;
@@ -11670,6 +11733,32 @@ function lowerForInArray(
body: [
{ kind: "varDecl", localId: arr.id, init: arrExpr, loc },
{ kind: "varDecl", localId: len.id, init: liveLen(), loc },
{
kind: "varDecl",
localId: keys.id,
init: { kind: "libCall", fn: "array.namedKeys", args: [arrRef()], type: keysT, loc },
loc,
},
{
kind: "varDecl",
localId: total.id,
init: {
kind: "bin",
op: "+",
left: lenRef(),
right: {
kind: "arrIntrinsic",
method: "length",
receiver: keysRef(),
args: [],
type: F64,
loc,
},
type: F64,
loc,
},
loc,
},
{
kind: "for",
init: {
@@ -11682,7 +11771,7 @@ function lowerForInArray(
kind: "bin",
op: "<",
left: iRef(),
right: { kind: "varRef", localId: len.id, type: F64, loc },
right: { kind: "varRef", localId: total.id, type: F64, loc },
type: BOOL,
loc,
},
@@ -11702,7 +11791,7 @@ function lowerForInArray(
body: [
{
kind: "if",
cond: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc },
cond: visits,
then: [keyDecl, ...writes, ...body],
else_: null,
loc,
@@ -1,4 +1,5 @@
import { onlyForwarded } from "./typed-arguments.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js";
import {
needsGeneralNodeStreams,
noteUntypedStreamFlow,
@@ -121,6 +122,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js";
import { indexReadInBounds } from "./runtime-optional-bounds.js";
import { ArrayOwnership } from "./runtime-optional-ownership.js";
import { RecordFlowClasses } from "./runtime-optional-records.js";
import { SiteDependencies } from "./site-dependencies.js";
import { StringIndexBounds } from "./string-index-bounds.js";
import { NanCodedReads } from "./nan-coded-reads.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
@@ -146,6 +148,7 @@ import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
import { recordTextCodecClass } from "../../ir/ir.js";
import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js";
import { dynamicTierWarning } from "./dynamic-tier.js";
import { promoteJsvals } from "../../ir/jsval.js";
import {
analyzeTiers,
@@ -174,7 +177,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js";
* - Lexical scoping is resolved here: locals get function-unique ids
* ("x.0", "x.1" for shadowing); the IR is scope-flat.
*/
import { resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { bindingSource } from "../binding-source.js";
import { tsgoPath } from "../dts-paths.js";
import * as ts from "../ts7/adapter.js";
@@ -784,6 +787,9 @@ export interface LowerResult {
/** Compile-time-detectable behavior differences from Node (SC6xxx
* divergence warnings) in the program's own modules. Never failures. */
divergences?: ScrDiagnostic[];
/** Performance warnings (SC61xx): the likely-hot functions the source
* leaves untyped (lowering/dynamic-tier.ts). Never failures. */
performance?: ScrDiagnostic[];
/** Functions lowering placed in the dynamic tier (ir/tiers.ts), present
* when there are any. */
dynamicFunctions?: DynamicFunction[];
@@ -953,6 +959,33 @@ function directExternalTypeSpecifiersByFile(
return out;
}
/** Debugging aid: SCRIPTC_LOWER_SHUFFLE_SEED=<n> lowers each wave of
* reachable bodies in an order drawn from that seed instead of discovery
* order. Output assembly is ordered independently of it, so any IR
* difference between seeds is state one body's lowering leaves for another
* (scripts/lowering-order.mjs). */
function lowerShuffle(): ((wave: string[]) => void) | null {
const raw = process.env["SCRIPTC_LOWER_SHUFFLE_SEED"];
if (raw === undefined || raw === "") return null;
// mulberry32
let state = Number.parseInt(raw, 10) >>> 0;
const next = (): number => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return (wave) => {
for (let index = wave.length - 1; index > 0; index--) {
const other = Math.floor(next() * (index + 1));
const swap = wave[index]!;
wave[index] = wave[other]!;
wave[other] = swap;
}
};
}
/** Build lowering runs as a reachability worklist over the ts.Program:
*
* 1. REACHABLE EMIT — a worklist computes the set of reachable bodies.
@@ -1160,6 +1193,13 @@ export function lowerToIr(
const divergences = scanDivergences(resultLowerer, moduleOrder);
timing("divergence-scan", { found: divergences.length });
if (divergences.length > 0) result = { ...result, divergences };
// The performance warning (SC6101): likely-hot functions of the program
// that compile to the dynamic tier because the source leaves them untyped.
if (result.module !== null && resultLowerer.dynamicFunctions.length > 0) {
const hotDynamic = dynamicTierWarning(resultLowerer, result.module, dirname(entry.fileName));
timing("dynamic-tier-scan", { found: hotDynamic === null ? 0 : 1 });
if (hotDynamic !== null) result = { ...result, performance: [hotDynamic] };
}
// Contract types (@scriptc/types): written annotations, checked against
// the IR by the compile pipeline (contracts/check.ts).
// Acyclic object types name the IR unit the program lowered them to; a
@@ -1828,6 +1868,13 @@ function monomorphizedImplicit(
return true;
}
/** `fns` sorted by name; functions of one name keep their order. */
function byName(fns: readonly IrFunction[]): IrFunction[] {
return [...fns].sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
);
}
export class Lowerer {
readonly frontendServices: FrontendServices | undefined;
readonly checker: ts.TypeChecker;
@@ -2014,6 +2061,40 @@ export class Lowerer {
this.genericClassInstances.splice(from, tail.length, ...tail);
}
/** The classes in table order. Generic instances are added as bodies
* demand them, so they keep the slots they hold and fill them in the
* order of their settled demand ranks. */
classesInTableOrder(): ClassInfo[] {
const all = [...this.classes.values()];
const slots = all.flatMap((info, index) =>
this.genericClassDemandPriority.has(info) ? [index] : [],
);
const ranked = slots
.map((index) => all[index]!)
.sort((left, right) => {
const a = this.genericClassDemandPriority.get(left)!.rank;
const b = this.genericClassDemandPriority.get(right)!.rank;
return a[0]! - b[0]! || a[1]! - b[1]!;
});
slots.forEach((slot, index) => (all[slot] = ranked[index]!));
return all;
}
/** The globals in table order. A class's prototype data globals are
* added when a body first reads that prototype, so they keep the slots
* they hold and fill them in id order. */
private globalsInTableOrder(): IrGlobal[] {
const all = [...this.globalsList];
const prototypeData = (global: IrGlobal): boolean =>
global.id.startsWith("%g.%prototype.data.");
const slots = all.flatMap((global, index) => (prototypeData(global) ? [index] : []));
const sorted = slots
.map((index) => all[index]!)
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
slots.forEach((slot, index) => (all[slot] = sorted[index]!));
return all;
}
private settleGenericDemandPriorities(): void {
const functionQueue: GenericInstance[] = [];
const classQueue: ClassInfo[] = [];
@@ -2656,6 +2737,19 @@ export class Lowerer {
/** True while re-lowering a base function's 2nd+ instance: the same source
* statements were already counted for the first instance. */
suppressStats = false;
/** Callback literals passed straight to an error-first zlib or crypto
* function: only that function's callback adapter calls them
* (lowerUndefinedResultBody). */
readonly errorFirstLiterals = new Set<ts.Node>();
/** Parameters read as `any` in the error-path copy of an error-first
* callback. */
readonly anySymbols = new Set<ts.Symbol>();
/** Error parameters of an error-first callback with a known value in
* the copy being lowered: true while it holds an Error, false while it
* holds null (staticCondition folds conditions on them). */
readonly knownErrors = new Map<ts.Symbol, boolean>();
/** The number of error-path callback copies being lowered. */
errorPathCopies = 0;
/** Comparisons interned by operand representations and equality semantics. */
readonly equalityHelpers = new Map<string, string>();
/** Synthetic array-HOF loop functions (map/filter/forEach desugar),
@@ -2931,6 +3025,12 @@ export class Lowerer {
* for the duration of that call, the receiver lowers to the Transform of
* the value held in the named local. */
readonly hashStreamReceivers = new Map<ts.Expression, { localId: string; type: IrType }>();
/** fs call arguments evaluated ahead of a URL path's conversion
* (builtins/fs-paths.ts): each lowers to the hidden local that holds its
* value, which is null until the path's lowering has saved it. */
readonly stagedArguments = new Map<ts.Expression, { localId: string; type: IrType } | null>();
/** The staged arguments that follow each such URL path, in order. */
readonly stagedAfterPath = new Map<ts.Expression, ts.Expression[]>();
readonly moduleNsParamOverrides = new Map<
ts.ParameterDeclaration,
IrType & { kind: "moduleNs" }
@@ -3158,6 +3258,60 @@ export class Lowerer {
constructedClassCount(): number {
return this.constructedClasses?.size ?? -1;
}
/** Helpers that every site asking for them shares (shareHelper). */
private readonly sharedHelpers = new Set<IrFunction>();
private readonly sharedHelperNames = new Set<string>();
/** Marks a helper, or the lifted helpers of a name, as shared by every
* site that asks for it. A shared helper is built for whichever site asks
* first, so once lowering finishes its locations are replaced by one that
* names no site. */
shareHelper(helper: IrFunction | string): void {
if (typeof helper === "string") this.sharedHelperNames.add(helper);
else this.sharedHelpers.add(helper);
}
/** Gives each shared helper the location of the entry file's start: the
* helpers marked by shareHelper, and those interned by key in the helper
* tables. */
private settleSharedHelperLocations(): void {
const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 };
const interned = new Set<string>([...this.sharedHelperNames, ...this.classMethodValueHelpers]);
for (const table of [
this.equalityHelpers,
this.arrHofHelpers,
this.unionCallHelpers,
this.valueHelpers,
this.assertHelpers,
this.inspectHelpers,
this.iterDrainHelpers,
this.mapHofHelpers,
this.setHofHelpers,
this.spHofHelpers,
this.primitiveCtorFns,
this.builtinCallableValueFns,
this.regionRunHelpers,
this.internTableHelpers,
this.coercions.retags,
this.coercions.narrows,
])
for (const name of table.values()) interned.add(name);
for (const fn of [...this.liftedFns, ...this.implicitFns])
if (interned.has(fn.name)) this.sharedHelpers.add(fn);
const seen = new Set<object>();
const visit = (node: unknown): void => {
if (typeof node !== "object" || node === null || seen.has(node)) return;
seen.add(node);
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
const record = node as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (key === "loc") record[key] = loc;
else if (key !== "type") visit(record[key]);
}
};
for (const fn of this.sharedHelpers) visit(fn);
}
/** The first lifted function named `name`, the one
* `liftedFns.find((fn) => fn.name === name)` returns. */
liftedFnNamed(name: string | undefined): IrFunction | undefined {
@@ -4691,10 +4845,15 @@ export class Lowerer {
// resolveValueSymbol also flushes deferred diagnostics and applies
// merged-namespace fences, whose authority remains ordinary lowering.
this.checker.prefetchSymbolRoots(sourceFiles, true);
// The facts each fixed-point site reads, by the symbols, nodes and
// element keys they belong to (created with the site list below).
let siteDependencies: SiteDependencies | null = null;
const symbolOf = (node: ts.Node): ts.Symbol | null => {
const s = this.checker.getSymbolAtLocation(node);
if (!s) return null;
return s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s;
const symbol = s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s;
siteDependencies?.read(symbol);
return symbol;
};
const signatureOf = (node: ts.Identifier): FnSig | null => {
const symbol = symbolOf(node);
@@ -4879,7 +5038,10 @@ export class Lowerer {
const callableSymbols = new Map<ts.Expression, ts.Symbol | null>();
const callableSymbolOf = (node: ts.Expression): ts.Symbol | null => {
const cached = callableSymbols.get(node);
if (cached !== undefined) return cached;
if (cached !== undefined) {
if (cached !== null) siteDependencies?.read(cached);
return cached;
}
let symbol = symbolOf(ts.isPropertyAccessExpression(node) ? node.name : node);
if (symbol === null || signatureBySymbol.has(symbol)) {
callableSymbols.set(node, symbol);
@@ -5168,7 +5330,10 @@ export class Lowerer {
if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this))
return true;
if (ts.isCallExpression(e) && this.runtimeOptionalReduceTypes.has(e)) return true;
if (ts.isCallExpression(e)) {
siteDependencies?.read(e);
if (this.runtimeOptionalReduceTypes.has(e)) return true;
}
if (
ts.isCallExpression(e) &&
e.arguments.nodes.length === 0 &&
@@ -5666,6 +5831,7 @@ export class Lowerer {
if (fnType?.kind === "func" && bodyReturnsOptional(fn)) {
const widenedReturn = this.runtimeOptionalType(fnType.ret);
provenance?.note("callback-return", fn, provenanceSite);
siteDependencies?.read(fn);
const previousReturn = this.runtimeOptionalFunctionReturnType(fn, fnType.ret);
this.runtimeOptionalFunctionReturns.set(fn, widenedReturn);
if (!typeEquals(previousReturn, widenedReturn)) changed = true;
@@ -5901,8 +6067,10 @@ export class Lowerer {
arm.elem.kind === "union"
? this.armTag(arm.elem.unionId, UNDEFINED_T) >= 0
: isUnitType(arm.elem) || arm.elem.kind === "void"
)
) {
if (!optionalElementKeys.has(key)) siteDependencies?.changed(key);
optionalElementKeys.add(key);
}
keys.push(key);
}
return keys;
@@ -6014,7 +6182,10 @@ export class Lowerer {
return keys;
};
const elementStates = new ArrayElementStates({
elementAdmitsUndefined: (key) => optionalElementKeys.has(key),
elementAdmitsUndefined: (key) => {
siteDependencies?.read(key);
return optionalElementKeys.has(key);
},
ownerOf: (node) => ownership.ownerOf(node),
allocationOwner: (node) => ownership.allocationOwner(node),
arrayKeys: arrayKeysOf,
@@ -6087,6 +6258,8 @@ export class Lowerer {
note: provenance
? (fact, key, site) => provenance.note(`element-${fact}`, site, site, key)
: null,
read: (fact) => siteDependencies?.read(fact),
changed: (fact) => siteDependencies?.changed(fact),
});
// Each pass revisits every array callback site; the last visit holds
// the decision at the fixed point.
@@ -6388,6 +6561,7 @@ export class Lowerer {
) {
if (promoteHofCallback(callback, [0, 1])) changed = true;
const result = this.runtimeOptionalType(this.irTypeOf(node));
siteDependencies?.read(node);
const previous = this.runtimeOptionalReduceTypes.get(node);
if (!previous || !typeEquals(previous, result)) {
this.runtimeOptionalReduceTypes.set(node, result);
@@ -6455,6 +6629,7 @@ export class Lowerer {
)
continue;
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const shape = signatureBySymbol.get(target)?.params[i];
if (!shape || shape.mode !== "required") continue;
const widened = addUndefined(shape.type);
@@ -6499,6 +6674,7 @@ export class Lowerer {
if (!ts.isSpreadElement(arg) && shape && i !== restAt && fieldsOfValue(arg)) {
const fields = fieldsOfValue(arg)!;
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const parameterShape = signatureBySymbol.get(target)?.params[i];
const parameter = functionDeclBySymbol.get(target)?.parameters?.nodes[i];
if (!parameterShape || parameterShape.type.kind !== "record") continue;
@@ -6544,6 +6720,7 @@ export class Lowerer {
// argument can make its elements undefined, not the pack.
// Keep every virtual implementation on the same array ABI.
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const rest = signatureBySymbol.get(target)?.params[i];
if (rest?.mode !== "rest" || rest.type.kind !== "array") continue;
const widened = arrayOf(addUndefined(rest.type.elem));
@@ -6565,6 +6742,7 @@ export class Lowerer {
// downstream calls and returns see the same optional value.
const family = familyBySymbol.get(symbol);
for (const target of family ?? [symbol]) {
siteDependencies?.read(target);
const parameterShape = signatureBySymbol.get(target)?.params[i];
if (!parameterShape) continue;
parameterShape.type = addUndefined(parameterShape.type);
@@ -6595,8 +6773,21 @@ export class Lowerer {
});
}
};
for (const node of sitesOf(sf)) scan(node);
return changed;
// A site whose facts are unchanged since its last visit would compute
// that visit's result again, except in a full pass.
const base = siteBase.get(sf)!;
const dependencies = siteDependencies!;
const sites = sitesOf(sf);
let fileChanged = false;
for (let i = 0; i < sites.length; i++) {
if (!fullPass && !dependencies.isStale(base + i)) continue;
changed = false;
dependencies.begin(base + i);
scan(sites[i]!);
dependencies.end(changed);
if (changed) fileChanged = true;
}
return fileChanged;
};
const scanReturns = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): boolean => {
let changed = false;
@@ -6646,23 +6837,41 @@ export class Lowerer {
optionalSymbols.add(symbol);
this.earlyReadVars.add(symbol);
}
let changed = true;
while (changed) {
// Number the sites of every file. The first and the last pass visit
// every site; the passes between visit only the sites whose facts
// changed (SiteDependencies). When such a pass changes nothing, a full
// pass confirms the fixed point, and the iteration resumes if it
// changes a fact after all.
const siteBase = new Map<ts.SourceFile, number>();
let siteCount = 0;
for (const sf of sourceFiles) {
siteBase.set(sf, siteCount);
siteCount += sitesOf(sf).length;
}
siteDependencies = new SiteDependencies(siteCount);
let fullPass = true;
for (let changed = true, first = true; changed || !fullPass; first = false) {
fullPass = first || !changed;
changed = false;
for (const [symbol, field] of staticFieldsBySymbol) {
if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) {
optionalSymbols.add(symbol);
siteDependencies.changed(symbol);
provenance?.note("static-field", field.initializer.parent, field.initializer);
changed = true;
}
}
for (const sf of sourceFiles) if (scanFile(sf)) changed = true;
for (const [symbol, decl] of functionDeclBySymbol)
if (scanReturns(symbol, decl)) changed = true;
for (const [symbol, decl] of functionDeclBySymbol) {
if (!scanReturns(symbol, decl)) continue;
siteDependencies.changed(symbol);
changed = true;
}
const noteGenericReturn = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): void => {
if (optionalReturns.has(symbol) || isJsSourceFile(ts.getSourceFileOfNode(decl))) return;
if (!returnsOf(decl).some(genericMayBeOptional)) return;
optionalReturns.add(symbol);
siteDependencies!.changed(symbol);
this.runtimeOptionalGenericReturns.add(decl);
provenance?.note("return", decl.name ?? decl, decl);
changed = true;
@@ -6699,6 +6908,7 @@ export class Lowerer {
: previous;
if (!typeEquals(previous, next)) {
this.runtimeOptionalFunctionReturns.set(fn, next);
siteDependencies.changed(fn);
provenance?.note("lambda-return", fn, fn.body);
changed = true;
}
@@ -6716,10 +6926,12 @@ export class Lowerer {
if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) {
if (!optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
siteDependencies.changed(symbol);
changed = true;
}
} else if (symbol && !arithmeticReturns.has(symbol)) {
arithmeticReturns.set(symbol, next);
siteDependencies.changed(symbol);
changed = true;
}
}
@@ -6736,6 +6948,7 @@ export class Lowerer {
: this.runtimeOptionalType(previous);
if (!typeEquals(previous, next)) {
this.runtimeOptionalFunctionReturns.set(fn, next);
siteDependencies.changed(fn);
changed = true;
}
}
@@ -6749,6 +6962,7 @@ export class Lowerer {
for (const symbol of optional ? family : []) {
if (!optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
siteDependencies.changed(symbol);
provenance?.note(
"return",
functionDeclBySymbol.get(symbol),
@@ -6771,10 +6985,14 @@ export class Lowerer {
const previous = arithmeticReturns.get(symbol);
if (previous && typeEquals(previous, joined)) continue;
arithmeticReturns.set(symbol, joined);
siteDependencies.changed(symbol);
changed = true;
}
}
}
// The site facts serve only the fixed point; release them for the rest
// of lowering.
siteDependencies = null;
// NaN-coded slots convert optional values on entry instead of holding
// the union, so promotion never applies to them.
for (const symbol of [...optionalSymbols])
@@ -7186,6 +7404,7 @@ export class Lowerer {
// Lambdas lifted while lowering any of the above (plus synthetic
// array-HOF loop functions, which ride the same list), and the
// implicit-any instances lowered eagerly at their first call sites.
this.settleSharedHelperLocations();
functions.push(...this.liftedFns);
functions.push(...this.implicitFns);
functions.push(...runtimeClassConstructs(this, functions));
@@ -7319,7 +7538,7 @@ export class Lowerer {
classes: artifacts.classes,
records: artifacts.records,
unions: artifacts.unions,
globals: this.globalsList,
globals: this.globalsInTableOrder(),
entry: ENTRY_NAME,
...(this.usesWorkers ? { workers: true } : {}),
...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}),
@@ -7728,6 +7947,7 @@ export class Lowerer {
this.shapes.withDeclaredOrderPriority(ref, () => this.lowerClassMembers(info)),
),
);
relieveHeapPressure();
}
}
while (instLowered < this.instantiationQueue.length) {
@@ -7755,6 +7975,7 @@ export class Lowerer {
),
),
);
relieveHeapPressure();
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
}
@@ -7765,6 +7986,15 @@ export class Lowerer {
// exactly like generic instances.
while (specLowered < this.emitSpecQueue.length) {
const waveEnd = this.emitSpecQueue.length;
// A wave's specializations lower by class and event, not in the
// order bodies asked for them.
const wave = this.emitSpecQueue.slice(specLowered, waveEnd);
const specKey = ({ info, event }: EmitSpecRequest): string =>
`${info.def.name}\0${event}`;
wave.sort((left, right) =>
specKey(left) < specKey(right) ? -1 : specKey(left) > specKey(right) ? 1 : 0,
);
this.emitSpecQueue.splice(specLowered, wave.length, ...wave);
this.checker.prefetchRoots(
this.emitSpecQueue
.slice(specLowered, waveEnd)
@@ -7814,9 +8044,11 @@ export class Lowerer {
// seed the worklist beside the init bodies. Unknown names are inert
// (the export-map resolution reports them as SC4002 later).
for (const root of extraRoots ?? []) this.onEdge?.(root);
const shuffle = lowerShuffle();
const drainUnits = (): void => {
while (queue.length > 0) {
const wave = queue.splice(0);
shuffle?.(wave);
this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots));
for (const name of wave) {
// A body-level poison outside the per-statement catches (a fenced
@@ -7831,6 +8063,7 @@ export class Lowerer {
this.shapes.withDeclaredOrderPriority(priority, unit.lower),
);
if (fn) loweredUnits.set(name, fn);
relieveHeapPressure();
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
}
@@ -7845,21 +8078,28 @@ export class Lowerer {
// queue above is the only portion whose discovery order differed from
// historical emit order.
const classDispatch = new ClassDynamicDispatch(extraRoots !== undefined);
// Units in declaration order, so the passes below meet functions, and the
// classes they collect, in an order that does not depend on lowering order.
const unitsInOrder = (): IrFunction[] =>
[...loweredUnits]
.sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order)
.map(([, fn]) => fn);
for (;;) {
drainInstances();
const declared = unitsInOrder();
const initializerChanged = this.deferredModuleInitializers.process(this, [
...loweredUnits.values(),
...declared,
...initFunctions,
...instanceFunctions,
...this.liftedFns,
...this.implicitFns,
...byName(this.liftedFns),
...byName(this.implicitFns),
]);
const dispatchChanged = classDispatch.process(this, [
...loweredUnits.values(),
...declared,
...initFunctions,
...instanceFunctions,
...this.liftedFns,
...this.implicitFns,
...byName(this.liftedFns),
...byName(this.implicitFns),
]);
if (queue.length === 0 && !dispatchChanged && !initializerChanged) break;
drainUnits();
@@ -7867,9 +8107,7 @@ export class Lowerer {
this.restoreGenericClassInstanceOrder(clsInstLowered);
}
classDispatch.finalize(this);
const orderedUnits = [...loweredUnits]
.sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order)
.map(([, fn]) => fn);
const orderedUnits = unitsInOrder();
this.settleGenericDemandPriorities();
this.shapes.settleDeclaredOrderPriorities();
for (const finalize of this.shapeOrderMetadataFinalizers) finalize();
@@ -7882,13 +8120,16 @@ export class Lowerer {
]);
observeRecordLiteralRebuilds(null);
for (const finalize of this.shapeOrderHelperFinalizers) finalize();
this.settleSharedHelperLocations();
// Lifted and implicit functions are created in lowering order; they
// are assembled by name.
const functions = [
...orderedUnits,
...initFunctions,
this.buildMain(),
...instanceFunctions,
...this.liftedFns,
...this.implicitFns,
...byName(this.liftedFns),
...byName(this.implicitFns),
];
functions.push(...runtimeClassConstructs(this, functions));
if (this.regexGroupResults) for (const fn of functions) foldRegexSpanReads(this, fn);
@@ -8392,7 +8633,36 @@ export class Lowerer {
return fenceStaticReadableStreamMember(this, access, use);
}
/** Whether `node` reads an anySymbols parameter, or a member, element or
* call result of one: the error-path copy of a callback reads those as
* `any`. */
private readsAnySymbol(node: ts.Node): boolean {
let base: ts.Node = node;
for (;;) {
if (
ts.isPropertyAccessExpression(base) ||
ts.isElementAccessExpression(base) ||
ts.isNonNullExpression(base) ||
ts.isParenthesizedExpression(base)
) {
base = base.expression;
} else if (ts.isCallExpression(base) && ts.isPropertyAccessExpression(base.expression)) {
base = base.expression.expression;
} else {
break;
}
}
if (!ts.isIdentifier(base)) return false;
const parent = base.parent;
const symbol =
parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === base
? this.checker.getShorthandAssignmentValueSymbol(parent)
: this.checker.getSymbolAtLocation(base);
return symbol !== undefined && this.anySymbols.has(symbol);
}
typeOf(node: ts.Node): ts.Type {
if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType();
if (ts.isPropertyAccessExpression(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) {
const receiver =
node.expression.kind === ts.SyntaxKind.ThisKeyword && this.fnStack.length > 0
@@ -8688,8 +8958,9 @@ export class Lowerer {
/** The ts-level twin of typeParamResolver: the bound CHECKER type of a
* type parameter in the current instantiation (typeParamTsBindings), for
* the resolutions where mapType's widening already dropped what the body
* needs (indexed accesses over literal-bound keys). Inert outside
* call-keyed generic instantiation. */
* needs (indexed accesses over literal-bound keys, conditional types
* over literal arguments). Inert outside generic function and class
* instantiation. */
readonly typeParamTsResolver = (t: ts.Type): ts.Type | null => {
if (!this.typeParamTsBindings || !(t.flags & ts.TypeFlags.TypeParameter)) return null;
const sym: ts.Symbol | undefined = t.getSymbol();
@@ -9720,6 +9991,20 @@ export class Lowerer {
* directly when the expression was already lowered. */
coerceInto(node: ts.Node, expr: IrExpr, expected: IrType): IrExpr {
refuseInferredCallbackDrops(this, node, expr.type, expected);
// An assertion's scalar check whose value goes straight back into a
// checked-dynamic slot (`process.loadEnvFile({} as unknown as string)`)
// keeps the original value: JavaScript erases assertions, so the check
// could only add a TypeError Node does not throw, and the slot holds
// the value either way.
if (expected.kind === "dyn" && expr.kind === "dynCheck" && expr.value.type.kind === "dyn") {
let source = node;
while (ts.isParenthesizedExpression(source)) source = source.expression;
if (
(ts.isAsExpression(source) || ts.isTypeAssertion(source)) &&
(expr.type.kind === "string" || expr.type.kind === "f64" || expr.type.kind === "bool")
)
return expr.value;
}
// A union destination can accept the binding's tagged storage directly.
// The checker's single-arm flow type can be stale after a callback writes
// the binding. Extracting that arm and wrapping it again would discard the
@@ -10047,6 +10332,8 @@ export class Lowerer {
// retain their own checked conversion and must not be erased here.
while (ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node))
node = node.expression;
const staged = this.stagedArgument(node);
if (staged) return expected ? this.coerceInto(node, staged, expected) : staged;
if (expected) {
if (ts.isConditionalExpression(node)) return lowerTernary(this, node, expected);
const escaped = this.neverAssertionInto(node, expected);
@@ -10882,6 +11169,7 @@ export class Lowerer {
family: ClassInfo;
name: string;
bindings: Map<ts.Symbol, IrType>;
tsBindings: Map<ts.Symbol, ts.Type>;
typeArgsText: string;
ordinal: number;
},
@@ -12079,7 +12367,19 @@ export class Lowerer {
lowerExpr(expr: ts.Expression): IrExpr {
const hash = this.hashStreamReceivers.get(expr);
if (hash) return hashStreamOf(varRef(hash.localId, hash.type, locOf(expr)));
return lowerExpr(this, expr);
return this.stagedArgument(expr) ?? lowerExpr(this, expr);
}
/** The saved value of a staged fs argument (stagedArguments, keyed
* without type-only wrappers), or null for every other expression. */
private stagedArgument(node: ts.Expression): IrExpr | null {
if (this.stagedArguments.size === 0) return null;
node = stripTypeWrappers(node);
if (!this.stagedArguments.has(node)) return null;
const slot = this.stagedArguments.get(node);
if (!slot)
throw new InternalCompilerError("an fs argument lowered before the URL path it follows");
return varRef(slot.localId, slot.type, locOf(node));
}
lowerIntrinsicProperty(expr: ts.PropertyAccessExpression): IrExpr | null {
@@ -39,8 +39,16 @@ export interface ElementStateHost {
allocationOwner(node: ts.Expression): ts.Symbol | null;
/** Debug provenance for a new fact. */
note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null;
/** The scan read, or added to, the facts of an element key, of an owning
* slot, or of every array (ANY_ARRAY), for dependency tracking
* (SiteDependencies). */
read?(fact: string | ts.Symbol): void;
changed?(fact: string | ts.Symbol): void;
}
/** The fact name of the facts shared by every array. */
const ANY_ARRAY = "*";
/** What an array value can contain besides present values. */
interface Contents {
undefined: boolean;
@@ -179,8 +187,10 @@ export class ArrayElementStates {
if (built !== undefined) return built;
}
const owner = this.host.ownerOf(e);
if (owner !== null)
if (owner !== null) {
this.host.read?.(owner);
return { undefined: this.undefinedOwners.has(owner), holes: this.holeyOwners.has(owner) };
}
return this.stored(this.host.arrayKeys(e));
}
@@ -317,7 +327,9 @@ export class ArrayElementStates {
/** The facts recorded for arrays with these element ABIs. */
private stored(keys: readonly string[] | null): Contents | null {
if (this.ownFactsOnly) return NOTHING;
this.host.read?.(ANY_ARRAY);
if (keys === null) return null;
for (const key of keys) this.host.read?.(key);
return {
undefined:
this.anyUndefined ||
@@ -333,6 +345,7 @@ export class ArrayElementStates {
const keys = this.host.arrayKeys(node);
if (keys !== null && keys.length === 0) return false;
const contents = this.contents(node);
this.host.read?.(ANY_ARRAY);
return contents === null
? this.anyUndefined || this.anyHoley
: contents.undefined || contents.holes;
@@ -368,6 +381,7 @@ export class ArrayElementStates {
this.holeyOwners.add(owner);
changed = true;
}
if (changed) this.host.changed?.(owner);
return changed;
}
let changed = false;
@@ -406,6 +420,7 @@ export class ArrayElementStates {
if (owner === null) return this.addUndefined(this.host.arrayKeys(receiver));
if (this.undefinedOwners.has(owner)) return false;
this.undefinedOwners.add(owner);
this.host.changed?.(owner);
return true;
}
@@ -415,6 +430,7 @@ export class ArrayElementStates {
if (owner === null) return this.addHoles(this.host.arrayKeys(receiver));
if (this.holeyOwners.has(owner)) return false;
this.holeyOwners.add(owner);
this.host.changed?.(owner);
return true;
}
@@ -422,6 +438,7 @@ export class ArrayElementStates {
if (keys === null) {
if (this.anyUndefined) return false;
this.anyUndefined = true;
this.host.changed?.(ANY_ARRAY);
this.host.note?.("undefined", "*", this.site);
return true;
}
@@ -429,6 +446,7 @@ export class ArrayElementStates {
for (const key of keys) {
if (this.undefinedKeys.has(key)) continue;
this.undefinedKeys.add(key);
this.host.changed?.(key);
this.host.note?.("undefined", key, this.site);
changed = true;
}
@@ -439,6 +457,7 @@ export class ArrayElementStates {
if (keys === null) {
if (this.anyHoley) return false;
this.anyHoley = true;
this.host.changed?.(ANY_ARRAY);
this.host.note?.("holes", "*", this.site);
return true;
}
@@ -446,6 +465,7 @@ export class ArrayElementStates {
for (const key of keys) {
if (this.holeyKeys.has(key)) continue;
this.holeyKeys.add(key);
this.host.changed?.(key);
this.host.note?.("holes", key, this.site);
changed = true;
}
@@ -0,0 +1,56 @@
import { expect, test } from "vitest";
import { SiteDependencies } from "./site-dependencies.js";
test("a changed fact makes the sites that read it stale", () => {
const dependencies = new SiteDependencies(3);
const a = {};
const b = "key";
dependencies.begin(0);
dependencies.read(a);
dependencies.end(false);
dependencies.begin(1);
dependencies.read(b);
dependencies.end(false);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, false, false]);
dependencies.changed(b);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, true, false]);
dependencies.begin(1);
expect(dependencies.isStale(1)).toBe(false);
});
test("a visit that changes a fact makes every reader of its facts stale", () => {
const dependencies = new SiteDependencies(3);
const shared = {};
const own = {};
dependencies.begin(0);
dependencies.read(shared);
dependencies.end(false);
dependencies.begin(2);
dependencies.read(own);
dependencies.end(false);
dependencies.begin(1);
dependencies.read(shared);
dependencies.read(shared);
dependencies.end(true);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, false]);
});
test("reads outside a visit are not recorded", () => {
const dependencies = new SiteDependencies(1);
const fact = {};
dependencies.read(fact);
dependencies.changed(fact);
expect(dependencies.isStale(0)).toBe(false);
});
test("a site visited again is listed once per fact", () => {
const dependencies = new SiteDependencies(3);
const fact = {};
for (const site of [0, 2, 1, 2, 0]) {
dependencies.begin(site);
dependencies.read(fact);
dependencies.end(false);
}
dependencies.changed(fact);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, true]);
});
@@ -0,0 +1,79 @@
/** The facts each site of a fixed-point scan read on its last visit, so that
* a pass visits again only the sites whose facts changed since.
*
* Sites are numbered from 0. A fact is any value usable as a Map key: a
* symbol, a syntax node, or a string that names a derived fact. A visit
* reports what it reads (`read`). When a visit changes a fact, every site
* that read one of the facts this visit read is visited again: the facts a
* visit changes belong to what it read. Facts changed outside a visit, or
* not read before they change, are reported with `changed`.
*
* A site that is not visited again would compute what its last visit
* computed, as long as every fact it reads is reported. The scan confirms
* that with a pass over every site once a pass changes nothing, and resumes
* when that pass changes a fact: a fact this tracking misses costs passes,
* not a different result. */
export class SiteDependencies {
/** Each fact's entry: the visit that last read it, so that a visit lists a
* fact once, followed by the sites that read it, ascending. */
private readonly readers = new Map<unknown, number[]>();
private readonly stale: Uint8Array;
private site = -1;
private visit = 0;
private facts: unknown[] = [];
constructor(siteCount: number) {
this.stale = new Uint8Array(siteCount);
}
/** A fact that site `index` read changed since its last visit. */
isStale(index: number): boolean {
return this.stale[index] === 1;
}
/** Starts a visit of site `index`. */
begin(index: number): void {
this.site = index;
this.visit++;
this.stale[index] = 0;
this.facts = [];
}
/** Ends the visit; `changed` when it changed a fact. */
end(changed: boolean): void {
this.site = -1;
if (changed) for (const fact of this.facts) this.changed(fact);
}
/** The site being visited read `fact`. */
read(fact: unknown): void {
if (this.site < 0) return;
const entry = this.readers.get(fact);
if (entry === undefined) {
this.readers.set(fact, [this.visit, this.site]);
this.facts.push(fact);
return;
}
if (entry[0] === this.visit) return;
entry[0] = this.visit;
this.facts.push(fact);
// A pass visits sites in ascending order, so a site is new at the end;
// a site visited again is usually listed already.
const site = this.site;
let low = 1;
let high = entry.length;
if (entry[high - 1]! < site) low = high;
while (low < high) {
const middle = (low + high) >>> 1;
if (entry[middle]! < site) low = middle + 1;
else high = middle;
}
if (entry[low] !== site) entry.splice(low, 0, site);
}
/** `fact` changed: every site that read it is visited again. */
changed(fact: unknown): void {
const entry = this.readers.get(fact);
if (entry !== undefined) for (let i = 1; i < entry.length; i++) this.stale[entry[i]!] = 1;
}
}
@@ -63,9 +63,44 @@ export function staticTypeofComparison(lowerer: Lowerer, e: ts.BinaryExpression)
}
}
/** Whether a callback's error parameter holds an Error (true) or null
* (false) in the copy of the callback body being lowered
* (lowerUndefinedResultBody), or null for other expressions. */
function knownError(lowerer: Lowerer, e: ts.Expression): boolean | null {
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (lowerer.knownErrors.size === 0 || !ts.isIdentifier(e)) return null;
const symbol = lowerer.checker.getSymbolAtLocation(e);
return symbol === undefined ? null : (lowerer.knownErrors.get(symbol) ?? null);
}
/** A comparison of a known error parameter against `null`
* (`err !== null`, `err == null`), folded. Null for other shapes. */
function knownErrorComparison(lowerer: Lowerer, e: ts.BinaryExpression): boolean | null {
const isNull = (x: ts.Expression): boolean => {
while (ts.isParenthesizedExpression(x)) x = x.expression;
return x.kind === ts.SyntaxKind.NullKeyword;
};
const error = isNull(e.right)
? knownError(lowerer, e.left)
: isNull(e.left)
? knownError(lowerer, e.right)
: null;
if (error === null) return null;
switch (e.operatorToken.kind) {
case ts.SyntaxKind.EqualsEqualsEqualsToken:
case ts.SyntaxKind.EqualsEqualsToken:
return !error;
case ts.SyntaxKind.ExclamationEqualsEqualsToken:
case ts.SyntaxKind.ExclamationEqualsToken:
return error;
default:
return null;
}
}
/** The value of a condition built from statically answered typeof
* comparisons, `!`, `&&` and `||` (the global-detection chains), or null
* when any operand needs evaluation. */
* comparisons, known callback error parameters, `!`, `&&` and `||` (the
* global-detection chains), or null when any operand needs evaluation. */
export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean | null {
let e = expr;
while (ts.isParenthesizedExpression(e)) e = e.expression;
@@ -73,6 +108,7 @@ export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean
const inner = staticCondition(lowerer, e.operand);
return inner === null ? null : !inner;
}
if (ts.isIdentifier(e)) return knownError(lowerer, e);
if (!ts.isBinaryExpression(e)) return null;
const op = e.operatorToken.kind;
if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) {
@@ -82,7 +118,7 @@ export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean
if (left !== isAnd) return left;
return staticCondition(lowerer, e.right);
}
return staticTypeofComparison(lowerer, e);
return staticTypeofComparison(lowerer, e) ?? knownErrorComparison(lowerer, e);
}
/** The expression a value reduces to once statically decided conditions
@@ -41,7 +41,10 @@ export function earlyReadVarSymbols(
if (sf.isDeclarationFile) continue;
const bindings = new Map<ts.Symbol, Binding>();
const names = new Set<string>();
const functionNames = new Set<string>();
ts.walkPreorder(sf, (node) => {
if (ts.isFunctionDeclaration(node) && node.name !== undefined)
functionNames.add(node.name.text);
if (!ts.isVariableDeclarationList(node)) return undefined;
if ((node.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const | ts.NodeFlags.Using)) !== 0)
return undefined;
@@ -66,7 +69,7 @@ export function earlyReadVarSymbols(
});
if (bindings.size === 0) continue;
const refs = referencesByName(lowerer, sf, names);
const analysis = new ScopeAnalysis(lowerer, sf);
const analysis = new ScopeAnalysis(lowerer, sf, functionNames);
for (const [symbol, binding] of bindings) {
const settle = analysis.settleIndex(binding.scope, symbol);
const early = (refs.get(symbol) ?? []).some(
@@ -151,6 +154,31 @@ function referencesByName(
if (ts.isIdentifier(node) && names.has(node.text)) idents.push(node);
return undefined;
});
return referencesBySymbol(lowerer, idents);
}
/** The identifiers of `sf` named in `names`, by name, each list in source
* order. */
function identifiersByName(
sf: ts.SourceFile,
names: ReadonlySet<string>,
): Map<string, ts.Identifier[]> {
const out = new Map<string, ts.Identifier[]>();
ts.walkPreorder(sf, (node) => {
if (!ts.isIdentifier(node) || !names.has(node.text)) return undefined;
const list = out.get(node.text);
if (list) list.push(node);
else out.set(node.text, [node]);
return undefined;
});
return out;
}
/** `idents` by resolved symbol, each list in the order of `idents`. */
function referencesBySymbol(
lowerer: Lowerer,
idents: readonly ts.Identifier[],
): Map<ts.Symbol, ts.Identifier[]> {
lowerer.checker.prefetchSymbolNodesExact(idents);
const out = new Map<ts.Symbol, ts.Identifier[]>();
for (const id of idents) {
@@ -173,10 +201,14 @@ class ScopeAnalysis {
* its own body names is never called through that name). */
private readonly functionMemo = new Map<string, boolean>();
private readonly functionRefs = new Map<ts.Node, ts.Identifier[]>();
/** The file's identifiers named like one of its function declarations,
* by name, collected in one walk on first use. */
private functionNameIdents: Map<string, ts.Identifier[]> | null = null;
constructor(
private readonly lowerer: Lowerer,
private readonly sf: ts.SourceFile,
private readonly functionNames: ReadonlySet<string>,
) {
this.js = isJsSourceFile(sf);
}
@@ -263,12 +295,19 @@ class ScopeAnalysis {
const refs =
symbol === undefined
? []
: (referencesByName(this.lowerer, this.sf, new Set([name.text])).get(symbol) ?? []).filter(
(ref) => ref !== name,
);
: (
referencesBySymbol(this.lowerer, this.identifiersNamed(name.text)).get(symbol) ?? []
).filter((ref) => ref !== name);
this.functionRefs.set(fn, refs);
return refs;
}
/** The identifiers named `text`, the name of a function declaration of
* the file. */
private identifiersNamed(text: string): readonly ts.Identifier[] {
this.functionNameIdents ??= identifiersByName(this.sf, this.functionNames);
return this.functionNameIdents.get(text) ?? [];
}
}
/** A loop statement that settles the binding settles the references in
@@ -0,0 +1,121 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { nodeFrontendServices } from "./services-node.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function unwrap(source: string): string | null {
return nodeFrontendServices().umdFactory(source, "/pkg/index.js");
}
/** TypeScript's UMD wrapper around `body`. */
function umd(body: string, params = "require, exports"): string {
return `(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports"], factory);
}
})(function (${params}) {
${body}
});
`;
}
const probe = `
console.log(JSON.stringify(module.exports), Object.keys(module.exports).join(","));
`;
function run(file: string): string {
const result = spawnSync(process.execPath, [file], { encoding: "utf8" });
expect(result.status).toBe(0);
return result.stdout;
}
/** The unwrapped module exports what the original exports. */
function expectSameModule(source: string): string {
const result = unwrap(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-umd-"));
dirs.push(dir);
const original = join(dir, "original.cjs");
const unwrapped = join(dir, "unwrapped.cjs");
writeFileSync(original, source + probe);
writeFileSync(unwrapped, result! + probe);
const answer = run(original);
expect(run(unwrapped)).toBe(answer);
return result!;
}
test("the factory body runs at the top level with its positions kept", () => {
const source = umd(` "use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twice = twice;
const path = require("path");
exports.sep = path.sep;
function twice(n) {
return n * 2;
}`);
const result = expectSameModule(source);
expect(result.length).toBe(source.length);
expect(result.split("\n").length).toBe(source.split("\n").length);
expect(result.indexOf("exports.twice = twice;")).toBe(source.indexOf("exports.twice = twice;"));
expect(result).not.toContain("define.amd");
expect(result).not.toContain("factory");
});
test("TypeScript's helper probes of this keep their fallback", () => {
expectSameModule(
umd(` "use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const os_1 = __importDefault(require("os"));
exports.eol = os_1.default.EOL;`),
);
});
test("nested functions keep their own return, arguments and this", () => {
expectSameModule(
umd(` "use strict";
exports.first = function () { return arguments[0]; };
exports.self = { get() { return this; } };
exports.arrow = () => { return 1; };`),
);
});
test("other shapes keep the original text", () => {
// `export =` returns the module value from the factory.
expect(unwrap(umd(` "use strict";\n return { a: 1 };`))).toBeNull();
// The body's own `this` and `arguments` differ from the module's.
expect(unwrap(umd(` exports.self = this;`))).toBeNull();
expect(unwrap(umd(` exports.count = arguments.length;`))).toBeNull();
// Other factory parameters.
expect(unwrap(umd(` exports.a = 1;`, "require, exports, module"))).toBeNull();
expect(unwrap(umd(` exports.a = 1;`, "req, exp"))).toBeNull();
// Code after the wrapper.
expect(unwrap(umd(` exports.a = 1;`) + "exports.b = 2;\n")).toBeNull();
// Other statements in the wrapper, which unwrapping would drop.
const wrapped = umd(` exports.a = 1;`);
const call = " var v = factory(require, exports);\n";
expect(unwrap(wrapped.replace(call, call + " exports.b = 2;\n"))).toBeNull();
expect(unwrap(wrapped.replace(call, " exports.b = 2;\n" + call))).toBeNull();
expect(unwrap(wrapped.replace(" }\n})(", " }\n exports.b = 2;\n})("))).toBeNull();
expect(
unwrap(wrapped.replace("define.amd) {", "define.amd) {\n exports.b = 2;")),
).toBeNull();
expect(
unwrap(wrapped.replace("define(", "exports.b = 2;\n }\n else {\n define(")),
).toBeNull();
// Not a UMD wrapper.
expect(unwrap(`exports.a = 1;\n`)).toBeNull();
});
@@ -0,0 +1,192 @@
/* TypeScript's UMD module emit in a compiled package: the module body is a
* factory function that the wrapper calls with the CommonJS module's own
* `require` and `exports`.
*
* (function (factory) {
* if (typeof module === "object" && typeof module.exports === "object") {
* var v = factory(require, exports);
* if (v !== undefined) module.exports = v;
* }
* else if (typeof define === "function" && define.amd) {
* define(["require", "exports", "./dep"], factory);
* }
* })(function (require, exports) {
* "use strict";
* const dep = require("./dep");
* exports.f = f;
* });
*
* Under Node the CommonJS branch runs: the factory body runs once with the
* module's `require` and `exports`, and a body without a `return` leaves
* `module.exports` as it was. That is the module's own body, so the wrapper
* is blanked out (line breaks kept, so positions stay) and the body runs at
* the module's top level, where its requires and export assignments are the
* module's own.
*
* Accepted: the wrapper is the file's only statement, the wrapper's body is
* exactly the emit above (so dropping it drops nothing else), the factory's
* parameters are exactly `require` and `exports`, and the body uses no
* `return` or `arguments` of its own. `this` in the body is the factory's
* receiver, not the module's: it may appear only in TypeScript's helper
* probes (`this && this.__importDefault`), which find no helper on either
* receiver. Any other shape keeps the original text. */
import * as ts from "./ts7/syntax.js";
import { tokenStartOfNode } from "./ts7/node-functions.js";
import { applyEdits } from "./npm-static-bundled-chunks.js";
/** True when the text may hold a UMD wrapper (a cheap probe). */
export function mayHoldUmdWrapper(source: string): boolean {
return source.includes("define.amd") && source.includes("(require, exports)");
}
/** The module text with a UMD wrapper's factory body at the top level, or
* null when the file is not one. */
export function unwrapUmdFactory(sf: ts.SourceFile): string | null {
const stmts = sf.statements.nodes;
if (stmts.length !== 1) return null;
const stmt = stmts[0]!;
if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) return null;
const call = stmt.expression;
if (call.arguments.nodes.length !== 1) return null;
const wrapper = unparenthesized(call.expression);
const factory = call.arguments.nodes[0]!;
if (!ts.isFunctionExpression(wrapper) || !ts.isFunctionExpression(factory)) return null;
const wrapperParams = wrapper.parameters.nodes;
if (wrapperParams.length !== 1 || !isPlainParameter(wrapperParams[0]!)) return null;
const factoryName = (wrapperParams[0]!.name as ts.Identifier).text;
if (!isTypeScriptUmdWrapper(wrapper, factoryName)) return null;
const params = factory.parameters.nodes;
if (
params.length !== 2 ||
!params.every(isPlainParameter) ||
(params[0]!.name as ts.Identifier).text !== "require" ||
(params[1]!.name as ts.Identifier).text !== "exports"
)
return null;
const body = factory.body;
if (body === undefined || !ownBodyKeepsModuleSemantics(body)) return null;
const open = tokenStartOfNode(body) + 1;
const close = body.end - 1;
return applyEdits(sf.text, 0, sf.text.length, [
{ start: tokenStartOfNode(stmt), end: open, text: "" },
{ start: close, end: stmt.end, text: "" },
]);
}
function unparenthesized(e: ts.Expression): ts.Expression {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
}
/** A parameter that is a bare identifier: no default, rest or pattern. */
function isPlainParameter(p: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(p.name) && p.initializer === undefined && p.dotDotDotToken === undefined;
}
/** True when the wrapper body is exactly TypeScript's emit and nothing else:
* one `if` statement whose CommonJS branch holds only the factory call and
* the `module.exports` assignment, and whose `else`, when present, is only
* the AMD branch:
*
* if (typeof module === "object" && typeof module.exports === "object") {
* var v = factory(require, exports);
* if (v !== undefined) module.exports = v;
* }
* else if (typeof define === "function" && define.amd) {
* define([...], factory);
* }
*
* Unwrapping drops the wrapper, so any other statement in it would be lost. */
function isTypeScriptUmdWrapper(wrapper: ts.FunctionExpression, factoryName: string): boolean {
const stmts = wrapper.body?.statements.nodes ?? [];
if (stmts.length !== 1 || !ts.isIfStatement(stmts[0]!)) return false;
const test = stmts[0];
const sf = ts.getSourceFileOfNode(wrapper);
const text = (n: ts.Node): string =>
sf.text.slice(tokenStartOfNode(n), n.end).replace(/\s+/g, " ");
if (text(test.expression) !== 'typeof module === "object" && typeof module.exports === "object"')
return false;
const branch = test.thenStatement;
if (!ts.isBlock(branch) || branch.statements.nodes.length !== 2) return false;
const [call, assign] = branch.statements.nodes as [ts.Statement, ts.Statement];
if (!ts.isVariableStatement(call)) return false;
const declarations = call.declarationList.declarations.nodes;
const name = declarations.length === 1 ? declarations[0]!.name : undefined;
if (name === undefined || !ts.isIdentifier(name)) return false;
const result = name.text;
if (text(call) !== `var ${result} = ${factoryName}(require, exports);`) return false;
if (text(assign) !== `if (${result} !== undefined) module.exports = ${result};`) return false;
const amd = test.elseStatement;
if (amd === undefined) return true;
if (!ts.isIfStatement(amd) || amd.elseStatement !== undefined) return false;
if (text(amd.expression) !== 'typeof define === "function" && define.amd') return false;
const define = ts.isBlock(amd.thenStatement) ? amd.thenStatement.statements.nodes : [];
if (define.length !== 1 || !ts.isExpressionStatement(define[0]!)) return false;
const defineCall = define[0].expression;
if (!ts.isCallExpression(defineCall) || text(defineCall.expression) !== "define") return false;
const last = defineCall.arguments.nodes.at(-1);
return last !== undefined && ts.isIdentifier(last) && last.text === factoryName;
}
/** True when the factory body behaves the same as the module's top level:
* no `return` or `arguments` of its own, and `this` only in TypeScript's
* helper probes. */
function ownBodyKeepsModuleSemantics(body: ts.Block): boolean {
let keeps = true;
ts.walkPreorder(body, (n) => {
if (!keeps) return "stop";
// Nested functions have their own `return`, `arguments` and `this`;
// arrow functions only their own `return`.
if (
ts.isFunctionDeclaration(n) ||
ts.isFunctionExpression(n) ||
ts.isMethodDeclaration(n) ||
ts.isGetAccessorDeclaration(n) ||
ts.isSetAccessorDeclaration(n) ||
ts.isConstructorDeclaration(n)
)
return "skip";
if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return "skip";
if (ts.isReturnStatement(n) && !insideArrow(n, body)) keeps = false;
else if (ts.isIdentifier(n) && n.text === "arguments") keeps = false;
else if (n.kind === ts.SyntaxKind.ThisKeyword && !isHelperProbe(n)) keeps = false;
return undefined;
});
return keeps;
}
/** True when `node` sits inside an arrow function nested in `body`. */
function insideArrow(node: ts.Node, body: ts.Block): boolean {
for (let p = node.parent; p !== undefined && p !== body; p = p.parent)
if (ts.isArrowFunction(p)) return true;
return false;
}
/** `this` in TypeScript's helper probe `this && this.__name`: the left
* operand, or the receiver of the right operand's `__` member. */
function isHelperProbe(node: ts.Node): boolean {
const parent = node.parent;
if (parent === undefined) return false;
const probe =
ts.isBinaryExpression(parent) && parent.left === node
? parent
: ts.isPropertyAccessExpression(parent) &&
parent.expression === node &&
ts.isIdentifier(parent.name) &&
parent.name.text.startsWith("__") &&
parent.parent !== undefined &&
ts.isBinaryExpression(parent.parent) &&
parent.parent.right === parent
? parent.parent
: undefined;
if (probe === undefined || probe.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken)
return false;
const right = probe.right;
return (
probe.left.kind === ts.SyntaxKind.ThisKeyword &&
ts.isPropertyAccessExpression(right) &&
right.expression.kind === ts.SyntaxKind.ThisKeyword &&
ts.isIdentifier(right.name) &&
right.name.text.startsWith("__")
);
}
@@ -452,6 +452,10 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
} else if (esm !== null) {
original = esm;
}
// TypeScript's UMD wrapper calls its factory with the module's own
// require and exports: the factory body is the module body.
const umd = original === null ? null : services.umdFactory(original, path);
if (umd !== null) original = umd;
// Polyfill packages that ARE a Node builtin compile as the builtin.
const aliased = original === null ? null : services.builtinAliasRequires(original, path);
if (aliased !== null) original = aliased;
@@ -472,6 +476,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (exportsVar !== null) original = exportsVar;
const respelled =
split !== null ||
umd !== null ||
tscHelpers !== null ||
esm !== null ||
aliased !== null ||
@@ -351,7 +351,7 @@ test("repeated cycle edges reuse a file's binding index", () => {
[peer, peerImports.map((stmt) => ({ dep: main, stmt }))],
]);
const admit = makeCycleAdmission(load.program, (file) => edges.get(file) ?? []);
const walk = vi.spyOn(peer, "appendChildIndices");
const walk = vi.spyOn(peer.file.wire, "preorder");
try {
expect(admit(peer, edges.get(peer)![0]!)).toBeNull();
expect(walk).toHaveBeenCalled();
@@ -12,6 +12,7 @@ import {
mayRequireBuiltinAlias,
rewriteBuiltinAliasRequires,
} from "./npm-static-builtin-aliases.js";
import { mayHoldUmdWrapper, unwrapUmdFactory } from "./npm-static-umd.js";
import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js";
import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js";
import { builtinPackageAliasFor } from "./package-shims.js";
@@ -114,6 +115,13 @@ export class FrontendServices {
if (!mayHoldTscBuiltinImport(source)) return null;
return unwrapTscBuiltinImports(this.parse(path, source, "js"));
}
/** A package module written as TypeScript's UMD wrapper with its
* factory body at the top level (null: not one). */
umdFactory(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldUmdWrapper(source)) return null;
return unwrapUmdFactory(this.parse(path, source, "js"));
}
/** A package module with its literal-valued TypeScript enum IIFEs
* respelled as object literals (null: none). */
enumIifes(source: string, path: string): string | null {
@@ -511,6 +511,40 @@ test("preorder traversal preserves depth, skipped subtrees and early termination
expect(prefix).toEqual(expected.slice(0, 7));
});
test("preorder walks from every node match a recursive visit of every file", () => {
const kinds = new Set<AstNode["kind"]>([AstKind.Identifier, AstKind.JSDocTypeLiteral]);
for (const [name, { file, oracle }] of decoded) {
expect(file.wire.preorder(), name).not.toBeNull();
for (let index = 1; index < file.wire.nodeCount; index++) {
if (file.wire.kind(index) === KIND_NODE_LIST) continue;
const expected: [number, number][] = [];
const selected: [number, number][] = [];
const visit = (node: OracleNode, depth: number): void => {
expected.push([node.index, depth]);
if (kinds.has(node.kind)) selected.push([node.index, depth]);
node.forEachChild((child) => {
visit(child as OracleNode, depth + 1);
});
};
visit(oracle.getOrCreateNodeAtIndex(index), 0);
const actual: [number, number][] = [];
walkPreorder(file.node(index), (node, depth) => {
actual.push([node.index, depth]);
});
expect(actual, `${name}:${index}`).toEqual(expected);
const actualSelected: [number, number][] = [];
walkPreorder(
file.node(index),
(node, depth) => {
actualSelected.push([node.index, depth]);
},
kinds,
);
expect(actualSelected, `${name}:${index}`).toEqual(selected);
}
}
});
test("child traversal still rejects a sibling belonging to another parent", () => {
const { file, bytes } = decoded.get("main.ts")!;
let parent = 0;
+43 -10
View File
@@ -9,6 +9,7 @@ import {
import { astLineOfPosition, astLineStarts, astSkipTrivia } from "./ast-text.js";
import {
AstWireFile,
PREORDER_LIST,
parseAstNodeHandle,
type AstFileReference,
type AstNodeHandle,
@@ -359,6 +360,32 @@ export class AstNode {
forEachChild<T>(
visitNode: (node: AstNode) => T,
visitList?: (nodes: AstNodeList) => T,
): T | undefined {
const wire = this.file.wire;
const tree = wire.preorder();
if (tree === null) return this.forEachLinkedChild(visitNode, visitList);
// In the checked preorder tree, the first child follows its parent and
// each later child starts where the previous child's subtree ends.
const kinds = tree.kinds;
const ends = tree.ends;
const stop = ends[this.index]!;
for (let index = this.index + 1; index < stop; index = ends[index]!) {
const kind = kinds[index]!;
if (kind === PREORDER_LIST) {
const result = this.visitChildList(index, visitNode, visitList);
if (result) return result;
} else if (kind !== AstKind.JSDoc) {
const result = visitNode(this.file.node(index));
if (result) return result;
}
}
return undefined;
}
/** forEachChild through the sibling and parent links. */
private forEachLinkedChild<T>(
visitNode: (node: AstNode) => T,
visitList?: (nodes: AstNodeList) => T,
): T | undefined {
const wire = this.file.wire;
// Walk the immutable sibling links directly. Repeated semantic scans
@@ -368,16 +395,8 @@ export class AstNode {
throw new AstDecodeError("sibling belongs to another parent");
const kind = wire.kind(index);
if (kind === KIND_NODE_LIST) {
const list = this.file.list(index);
if (visitList !== undefined) {
const result = visitList(list);
if (result) return result;
} else {
for (const node of list.nodes) {
const result = visitNode(node);
if (result) return result;
}
}
const result = this.visitChildList(index, visitNode, visitList);
if (result) return result;
} else if (kind !== AstKind.JSDoc) {
const result = visitNode(this.file.node(index));
if (result) return result;
@@ -386,6 +405,20 @@ export class AstNode {
return undefined;
}
private visitChildList<T>(
index: number,
visitNode: (node: AstNode) => T,
visitList?: (nodes: AstNodeList) => T,
): T | undefined {
const list = this.file.list(index);
if (visitList !== undefined) return visitList(list);
for (const node of list.nodes) {
const result = visitNode(node);
if (result) return result;
}
return undefined;
}
get jsDoc(): AstNode[] | undefined {
const docs: AstNode[] = [];
for (const index of this.file.wire.children(this.index)) {
@@ -32,6 +32,16 @@ import {
astChildOrder,
} from "./ast-schema.generated.js";
/** A node table read as a preorder tree (AstWireFile.preorder): each
* node's kind, with PREORDER_LIST for lists, and the end of its subtree. */
export interface AstPreorder {
readonly kinds: Uint16Array;
readonly ends: Uint32Array;
}
/** The kind AstPreorder records for a list (KIND_NODE_LIST on the wire). */
export const PREORDER_LIST = 0xffff;
export interface AstFileReference {
pos: number;
end: number;
@@ -125,6 +135,7 @@ export class AstWireFile {
private readonly words: Uint32Array;
/** Decoded strings by string word index / 2. */
private readonly stringCache: (string | undefined)[];
private preorderTree: AstPreorder | null | undefined;
constructor(private readonly bytes: Uint8Array) {
astBounds(bytes, 0, HEADER_SIZE);
@@ -317,6 +328,66 @@ export class AstWireFile {
return children;
}
/** The node table as a preorder tree, built on first use: every node's
* subtree is the index range up to `ends[index]`, and `kinds[index]` is its
* kind. The pass checks the links the linked walk checks one child at a
* time: each node's parent is open, the first child follows its parent,
* each sibling link names the end of the previous sibling's subtree, a
* last child has no sibling link, and each list holds `data` nodes and no
* lists. null when any check fails; walks then follow the links and fail
* where they read the inconsistent node. */
preorder(): AstPreorder | null {
if (this.preorderTree === undefined) this.preorderTree = this.buildPreorder();
return this.preorderTree;
}
private buildPreorder(): AstPreorder | null {
const count = this.nodeCount;
const words = this.words;
const kinds = new Uint16Array(count);
const ends = new Uint32Array(count);
// The nodes whose subtrees contain the current index, innermost last,
// with the number of children each has so far.
const open: number[] = [1];
const children: number[] = [0];
const rootKind = words[NODE_WORDS + WORD_KIND]!;
if (rootKind >= PREORDER_LIST) return null;
kinds[1] = rootKind;
for (let index = 2; index <= count; index++) {
const word = index * NODE_WORDS;
const parent = index < count ? words[word + WORD_PARENT]! : 0;
let previous = 0;
while (open.length > 0 && open[open.length - 1] !== parent) {
const closed = open.pop()!;
const closedChildren = children.pop()!;
ends[closed] = index;
const closedWord = closed * NODE_WORDS;
if (kinds[closed] === PREORDER_LIST && closedChildren !== words[closedWord + WORD_DATA]!)
return null;
if (open.length > 0 && open[open.length - 1] === parent) previous = closed;
else if (closed !== 1 && words[closedWord + WORD_NEXT]! !== 0) return null;
}
if (index === count) break;
if (open.length === 0 || parent >= index) return null;
if (
previous !== 0 ? words[previous * NODE_WORDS + WORD_NEXT]! !== index : index !== parent + 1
)
return null;
const kind = words[word + WORD_KIND]!;
if (kind === KIND_NODE_LIST) {
if (kinds[parent] === PREORDER_LIST) return null;
kinds[index] = PREORDER_LIST;
} else {
if (kind >= PREORDER_LIST) return null;
kinds[index] = kind;
}
children[children.length - 1] = children[children.length - 1]! + 1;
open.push(index);
children.push(0);
}
return { kinds, ends };
}
/** The semantic walk flattens lists and omits attached JSDoc trees.
* Validate the same list links as list(), without materializing nodes
* that a selective syntax scan will never inspect. */
+57 -2
View File
@@ -47,6 +47,8 @@ import {
} from "./ast-guards.generated.js";
import type { Ts7DiagnosticData as Diagnostic } from "./session-schema.generated.js";
import { ModifierFlags, SyntaxKind } from "./enums.js";
import { AstKind } from "./ast-schema.generated.js";
import { PREORDER_LIST } from "./ast-wire.js";
/* ---- everything 7 kept under the same name ---- */
@@ -101,7 +103,7 @@ export function forEachChild<T>(
return node.forEachChild(cbNode, cbNodes);
}
/** Preorder walk over a whole subtree with an EXPLICIT stack. The idiomatic
/** Preorder walk over a whole subtree without recursion. The idiomatic
* recursive forEachChild visit costs two JS frames per AST level, so a
* pathologically deep tree — the ~6500-term left-nested binary chain of the
* binderBinaryExpressionStress corpus pair — overflows the stack as an ICE
@@ -110,11 +112,64 @@ export function forEachChild<T>(
* detection) walk this way instead, so absurd nesting always reaches the
* named fence. The callback may answer "skip" to leave a node's children
* unvisited (scope and depth cut-offs — `depth` counts levels below the
* root) or "stop" to end the walk early. */
* root) or "stop" to end the walk early.
*
* The wire stores nodes in preorder, so the walk visits the subtree's index
* range in order: it enters lists without a level of their own, and passes
* over attached JSDoc trees and skipped subtrees by their end index. A table
* that is not a checked preorder tree (AstWireFile.preorder) is walked
* through its links instead. */
export function walkPreorder(
root: Node,
cb: (node: Node, depth: number) => void | "skip" | "stop",
kinds?: ReadonlySet<SyntaxKind>,
): void {
const file = root.file;
const tree = file.wire.preorder();
if (tree === null) {
walkLinkedPreorder(root, cb, kinds);
return;
}
const nodeKinds = tree.kinds;
const ends = tree.ends;
const start = root.index;
const stop = ends[start]!;
// The subtree ends of the visited nodes that contain the current index.
const open: number[] = [];
let index = start;
while (index < stop) {
const kind = nodeKinds[index]!;
if (kind === PREORDER_LIST) {
index++;
continue;
}
if (
kind === AstKind.JSDoc &&
index !== start &&
nodeKinds[file.wire.parent(index)] !== PREORDER_LIST
) {
index = ends[index]!;
continue;
}
while (open.length > 0 && open[open.length - 1]! <= index) open.pop();
const node = kinds === undefined || kinds.has(kind) ? file.node(index) : undefined;
const verdict = node === undefined ? undefined : cb(node, open.length);
if (verdict === "stop") return;
if (verdict === "skip") {
index = ends[index]!;
continue;
}
open.push(ends[index]!);
index++;
}
}
/** walkPreorder through the sibling and parent links, with an explicit
* stack. */
function walkLinkedPreorder(
root: Node,
cb: (node: Node, depth: number) => void | "skip" | "stop",
kinds?: ReadonlySet<SyntaxKind>,
): void {
const file = root.file;
const stack: number[] = [root.index];
+115 -17
View File
@@ -46,6 +46,7 @@ import { literalValues } from "./literal-values.js";
import { remapUnionDiscriminant } from "./union-discriminants.js";
import { isJsSourceFile, isNodeTypesPath } from "./program.js";
import { isProvenanceSourceFile } from "./provenance-registry.js";
import { functionCanReturnUndefined } from "./function-completion.js";
import { accessorSlotProp, recordTextCodecClass } from "../ir/ir.js";
// typeKey moved to ir/ir.ts (the backend needs it too, for per-type
@@ -1108,6 +1109,11 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null;
* once per Lowerer and threaded through the recursion — adding a hook means
* one new field here, not a new parameter at every call site. */
export interface TypeMapperCtx {
/** Set only while a union maps an arm that is the type inferred from an
* empty object literal, beside another non-nullish arm: that arm holds
* only the literal's objects, so it maps to the empty record (see the
* empty object type rule in the object mapping). */
emptyLiteralArm?: boolean;
checker: ts.TypeChecker;
/** JavaScript declarations whose instance or object-literal types only
* the checked-dynamic representation holds (js-dynamic-objects.ts).
@@ -1650,7 +1656,10 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
const topLevel = mapTypeDepth === 0;
const typeId = (type as { id?: number }).id;
const memoizable = typeId !== undefined && (ctx.canMemoizeType?.() ?? true);
const memoKey = memoizable ? `${0}:${typeId}` : null;
// An empty literal's type maps to the empty record as an unreduced union's
// arm (emptyLiteralArm) and to the checked-dynamic value elsewhere: the two
// answers keep separate memo entries.
const memoKey = memoizable ? `${ctx.emptyLiteralArm ? 1 : 0}:${typeId}` : null;
if (memoKey !== null) {
const memo = ctx.typeMemo?.get(memoKey);
if (memo !== undefined) return memo;
@@ -2459,6 +2468,27 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return { kind: "classval", className: inst.className };
}
}
// A constructable declared in the program whose instances are not one
// program class's (`new () => unknown`, `abstract new () => S` over a
// record, a type that also names static members such as `{ readonly
// schema: unknown; new (): unknown }`, construct overloads): the class
// value rides the checked-dynamic tree, as an `unknown` slot already
// holds one. `new` through it and static reads dispatch at run time,
// and the constructed value checks into the signature's instance type.
// Lib and package constructables keep their fences, and so do those of
// fetched provenance sources (third-party code under its own policies:
// an unused `@__PURE__` const typed `{ new (): any }` elides there).
if (
ctorSigs.length > 0 &&
checker.getCallSignatures(widened).length === 0 &&
(widened.getSymbol()?.declarations.length ?? 0) > 0 &&
widened.getSymbol()!.declarations.every((d) => {
const sf = ts.getSourceFileOfNode(d);
return ctx.isProgramFile(sf) && !isProvenanceSourceFile(sf.fileName);
})
) {
return DYN;
}
}
// Map<K, V>: a reference to the standard library's Map interface —
// provenance, not the name (a user's own `interface Map` maps as a record
@@ -4648,6 +4678,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
);
};
const hasArrayPart = parts.some((part) => checker.isArrayType(part) || matchArray(part));
// Subtype reduction removes every arm assignable to `{}`, so a union
// that keeps another non-nullish arm beside an empty literal's `{}`
// was not reduced: that `{}` arm holds only the literal's objects.
const nullish = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void;
const literalEmptyArms =
parts.some((part) => isEmptyObjectLiteralType(part, ctx)) &&
parts.some(
(part) =>
(part.flags & nullish) === 0 &&
!checker.isNeverType(part) &&
!isEmptyObjectLiteralType(part, ctx),
);
for (const part of parts) {
if (hasArrayPart && emptyTuple(part)) continue;
// TypeScript's client can retain impossible intersections in a
@@ -4666,7 +4708,17 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
byKey.set(typeKey(NULL_T), NULL_T);
continue;
}
const mapped = mapType(part, ctx);
let mapped: IrType | null;
if (literalEmptyArms && isEmptyObjectLiteralType(part, ctx)) {
ctx.emptyLiteralArm = true;
try {
mapped = mapType(part, ctx);
} finally {
ctx.emptyLiteralArm = false;
}
} else {
mapped = mapType(part, ctx);
}
if (!mapped) {
return null;
@@ -5836,7 +5888,9 @@ function evalMappedTemplate(
if (!checkType || !extendsType) return null;
const checked = evalMappedTemplate(checkType, keys, scope, ctx);
if (!checked) return null;
const inferred = matchInferReference(checked, extendsType, ctx);
const inferred =
primitiveExtendsCheck(checked, extendsType, ctx) ??
matchInferReference(checked, extendsType, ctx);
if (inferred === undefined) return null;
const branch = inferred ? type.getTrueType() : type.getFalseType();
const branchScope = inferred ? new Map([...scope, ...inferred]) : scope;
@@ -5845,6 +5899,30 @@ function evalMappedTemplate(
return mentionsMappedParams(type, keys, aliases) ? null : type;
}
/** `checked extends X` where X is a primitive or literal type, or a union of
* them (`K extends "required" ? ...`), and `checked` is one such type:
* X has no infer positions, so assignability decides the branch (an empty
* binding map for the true branch). A union `checked` distributes and stays
* undecided here (undefined), as do other shapes. */
function primitiveExtendsCheck(
checked: ts.Type,
extendsType: ts.Type,
ctx: TypeMapperCtx,
): Map<ts.Symbol, ts.Type> | false | undefined {
const primitive =
ts.TypeFlags.StringLike |
ts.TypeFlags.NumberLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.Null |
ts.TypeFlags.Undefined;
const isPrimitive = (t: ts.Type): boolean =>
!t.isUnionType() && (t.flags & primitive) !== 0 && (t.flags & ts.TypeFlags.TypeParameter) === 0;
const extendsParts = extendsType.isUnionType() ? ts.constituentTypes(extendsType) : [extendsType];
if (!isPrimitive(checked) || !extendsParts.every(isPrimitive)) return undefined;
return ctx.checker.isTypeAssignableTo(checked, extendsType) ? new Map() : false;
}
/** `checked extends G<..., infer T, ...>`: the infer bindings when
* `checked` is a reference to the same generic `G` (its arguments bind the
* infer positions; other positions must be the same types), false when it
@@ -7470,25 +7548,29 @@ function mapRecordTypeInner(
// An INDEX-SIGNATURE shape is exempt: `Record<string, T>` legitimately
// has zero declared members — the signature is the shape.
if (computed && props.length === 0 && !indexValue) return null;
// A DECLARED empty object type — `{}` (spelled or the checker's shared
// intrinsic), `interface Empty {}` — is tsc's TOP type over non-nullish
// values: every number, string, record, array, function, or class
// instance is assignable into the slot, so the exact-record stance
// cannot hold it. It lowers like `unknown` (the dyn), the same rule
// as `object` and the lib's `Object`: assignments convert at the site
// (sources outside the checked-dynamic tree fence there, exactly as unknown does), reads
// narrow back out through the same checked casts. The type INFERRED
// from an empty object literal (`const o = {}`) stays the empty record
// — it describes a value the program built, not an annotation that
// admits everything — and computed empties (`Partial<T>` in a generic
// body) returned null above. Empty shapes WITH an index signature
// (`Record<string, T>`) are not empty: the signature is the shape.
// An empty object type — `{}` (spelled, the checker's shared
// intrinsic, or inferred from an empty object literal), `interface
// Empty {}` — is tsc's TOP type over non-nullish values: every number,
// string, record, array, function, or class instance is assignable
// into the slot, so the exact-record stance cannot hold it. It lowers
// like `unknown` (the dyn), the same rule as `object` and the lib's
// `Object`: assignments convert at the site (sources outside the
// checked-dynamic tree fence there, exactly as unknown does), reads
// narrow back out through the same checked casts. A literal's inferred
// `{}` is no exception: union subtype reduction folds primitives into
// it (`["12", {}]` is `{}[]`, `flag ? "x" : {}` is `{}`), and `let o =
// {}` admits `o = "x"`. As an arm beside another non-nullish arm (the
// union was not reduced, `string | {} | DataView`) it holds only the
// literal's objects and stays the empty record (emptyLiteralArm).
// Computed empties (`Partial<T>` in a generic body) returned null
// above. Empty shapes WITH an index signature (`Record<string, T>`) are
// not empty: the signature is the shape.
if (
props.length === 0 &&
indexValue === undefined &&
checker.getCallSignatures(widened).length === 0 &&
checker.getConstructSignatures(widened).length === 0 &&
!(anonSym !== undefined && anonSym.declarations.some((d) => ts.isObjectLiteralExpression(d)))
!(ctx.emptyLiteralArm && isEmptyObjectLiteralType(widened, ctx))
) {
return DYN;
}
@@ -8128,3 +8210,19 @@ function innerBlockedMember(
}
return here;
}
/** The type tsc infers from an empty object literal (`{}` written as a
* value): no members, index signatures or signatures, declared by an object
* literal expression. */
function isEmptyObjectLiteralType(type: ts.Type, ctx: TypeMapperCtx): boolean {
const { checker } = ctx;
const symbol = type.getSymbol();
return (
symbol !== undefined &&
symbol.declarations.some((d) => ts.isObjectLiteralExpression(d)) &&
checker.getPropertiesOfType(type).length === 0 &&
checker.getIndexInfosOfType(type).length === 0 &&
checker.getCallSignatures(type).length === 0 &&
checker.getConstructSignatures(type).length === 0
);
}
@@ -0,0 +1,64 @@
import { expect, test } from "vitest";
import { heapPressureCheck } from "./heap-collection.js";
const MB = 1024 * 1024;
/** A heap whose used size the test sets; a collection leaves `live` bytes. */
function fakeHeap(initial: number) {
const heap = { used: initial, live: initial, collections: 0, reads: 0 };
const check = heapPressureCheck(
() => {
heap.reads++;
return heap.used;
},
() => {
heap.collections++;
heap.used = heap.live;
},
);
return { heap, check };
}
test("the check reads the heap every 64th call and at phase ends", () => {
const { heap, check } = fakeHeap(100 * MB);
heap.reads = 0;
for (let i = 0; i < 63; i++) check(false);
expect(heap.reads).toBe(0);
check(false);
expect(heap.reads).toBe(1);
check(true);
expect(heap.reads).toBe(2);
});
test("the check collects once the heap doubles and grows by 512 MB", () => {
const { heap, check } = fakeHeap(100 * MB);
// Five times the size, but only 400 MB more: no collection.
heap.used = 500 * MB;
check(true);
expect(heap.collections).toBe(0);
heap.used = 612 * MB;
check(true);
expect(heap.collections).toBe(1);
// The size after the collection is the new baseline: 400 MB live needs 912 MB.
heap.live = 400 * MB;
heap.used = 1000 * MB;
check(true);
expect(heap.collections).toBe(2);
heap.used = 900 * MB;
check(true);
expect(heap.collections).toBe(2);
heap.used = 912 * MB;
check(true);
expect(heap.collections).toBe(3);
// From 1 GB live, doubling is the larger bound.
heap.live = 1024 * MB;
heap.used = 2000 * MB;
check(true);
expect(heap.collections).toBe(4);
heap.used = 2047 * MB;
check(true);
expect(heap.collections).toBe(4);
heap.used = 2048 * MB;
check(true);
expect(heap.collections).toBe(5);
});
+49
View File
@@ -0,0 +1,49 @@
import { getHeapStatistics, setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setHeapPressureCheck } from "./heap-pressure.js";
/** Calls of the check between two reads of the heap size, outside phase ends. */
const SAMPLE_INTERVAL = 64;
/** The heap grows by at least this much before a collection. */
const MIN_GROWTH_BYTES = 512 * 1024 * 1024;
/**
* The check `collectGarbageUnderHeapPressure` installs: every 64th call, and at every phase end,
* reads the used heap size and runs `collect` once it is at least twice the size after the
* previous collection and 512 MB above it.
*/
export function heapPressureCheck(
usedHeap: () => number,
collect: () => void,
): (phase: boolean) => void {
let live = usedHeap();
let calls = 0;
return (phase) => {
if (!phase && ++calls % SAMPLE_INTERVAL !== 0) return;
const used = usedHeap();
if (used < Math.max(2 * live, live + MIN_GROWTH_BYTES)) return;
collect();
live = usedHeap();
};
}
/**
* Runs a full garbage collection from the compiler's allocation-heavy loops
* (`relieveHeapPressure`) as `heapPressureCheck` decides. Without it, V8 lets the heap of a long
* lowering or LLVM emission grow to several times its live size between full collections, and
* Node keeps those pages for the life of the process.
*
* This sets --expose-gc for the process, so only the compiler's own processes call it (the CLI and
* the frontend process, frontend-process.ts), not `compile()` callers.
*/
export function collectGarbageUnderHeapPressure(): void {
setFlagsFromString("--expose-gc");
const gc: unknown = runInNewContext("gc");
if (typeof gc !== "function") return;
setHeapPressureCheck(
heapPressureCheck(
() => getHeapStatistics().used_heap_size,
() => (gc as () => void)(),
),
);
}
+13
View File
@@ -0,0 +1,13 @@
/** Allocation-heavy loops (lowering reached bodies and generic instances, emitting LLVM
* functions) call `relieveHeapPressure` between units of work. It does nothing unless the process
* installed a check with `setHeapPressureCheck` (collectGarbageUnderHeapPressure in
* heap-collection.ts). `phase` is true where a phase that allocates without these calls ends. */
let check: (phase: boolean) => void = () => {};
export function relieveHeapPressure(phase = false): void {
check(phase);
}
export function setHeapPressureCheck(next: (phase: boolean) => void): void {
check = next;
}
+22 -32
View File
@@ -82,7 +82,7 @@ import { privateSiblingPath } from "./backend/build-cache.js";
import { nativeCodegenTarget, nativeCodegenTargetRefusal } from "./backend/targets.js";
import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js";
import { createNativeLinkInfo } from "./backend/native-link-info.js";
import { RuntimePackError, loadRuntimeBitcode } from "./backend/runtime-pack.js";
import { RuntimePackError } from "./backend/runtime-pack.js";
import { createNativeLinkPlan } from "./backend/link-plan.js";
import {
nativeProfileKey,
@@ -159,6 +159,7 @@ import {
type EarlyExecutableNativeFeatures,
} from "./executable/executable-cache.js";
import { compilerImplementationIdentity } from "./library/compiler-self-identity.js";
import { isPerformanceCode } from "./diagnostics/dynamic-tier.js";
export const VERSION = "0.0.1";
@@ -182,6 +183,7 @@ export {
} from "./backend/external-c.js";
export { warmNativeCaches } from "./backend/warm-cache.js";
export { mergeNativeProfiles as mergeProfiles } from "./backend/native-codegen.js";
export { collectGarbageUnderHeapPressure } from "./heap-collection.js";
export {
ANDROID_MIN_API,
IPHONEOS_MIN_VERSION,
@@ -455,6 +457,15 @@ interface CompileWarningSink {
sourceTexts: Map<string, string> | null;
}
/** The build's performance warnings (SC61xx), stored with its cache entry
* so a cache hit repeats them. */
function performanceWarnings(sink: CompileWarningSink | undefined): {
performance?: ScrDiagnostic[];
} {
const performance = (sink?.warnings ?? []).filter((d) => isPerformanceCode(d.code));
return performance.length === 0 ? {} : { performance };
}
function withCompileWarnings<T extends CompileRequestResult>(
result: T,
sink: CompileWarningSink,
@@ -637,7 +648,6 @@ async function emitNativeProgramObject(
entryPath: string,
opts: CompileRequestOptions,
llvm: LlvmInput,
features: EarlyExecutableNativeFeatures,
profile: NativeProfileOptions | undefined,
): Promise<NativeProgramObject> {
const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
@@ -651,24 +661,6 @@ async function emitNativeProgramObject(
const llvmBytes = llvmInputBytes(llvm);
const partitions =
opts.nativeProgramObject === true || target === null ? 1 : nativeProgramPartitions(llvmBytes);
// Optimized programs import small runtime functions from the bitcode of
// the exact runtime units they link, so LLVM can inline across the
// boundary. Release units ship only the bodies that need none of their
// unit-local state, which keeps the import cheap; speed units promote
// that state and offer more. Every partition reads the bitcode again, so
// release imports only into programs small enough for one partition: in
// larger ones the build-time and size cost outgrows the gain.
const runtimeBitcode =
opts.sanitize !== true &&
target !== null &&
(opts.optimization === "speed" ||
(opts.optimization !== "dev" && nativeProgramPartitions(llvmBytes) === 1))
? await loadRuntimeBitcode({
target,
features,
optimization: opts.optimization === "speed" ? "speed" : "release",
})
: null;
try {
const artifact = await emitNativeArtifact({
outputPath: linkPath,
@@ -678,16 +670,13 @@ async function emitNativeProgramObject(
optimization: opts.optimization === "dev" ? "0" : "2",
...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }),
partitions,
...(runtimeBitcode === null
? {}
: { importBitcode: { paths: runtimeBitcode.paths, digests: runtimeBitcode.digests } }),
...(profile === undefined ? {} : { profile }),
});
return {
linkPath,
partitionPaths: artifact.outputPaths.slice(1),
artifactPath,
dependencies: [...artifact.dependencies, ...(runtimeBitcode?.dependencies ?? [])],
dependencies: artifact.dependencies,
};
} catch (error) {
await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions));
@@ -1457,6 +1446,12 @@ async function compileTracked(
sourceTexts: new Map(),
};
}
// A cached build repeats its performance warning (SC6101): the code it
// names is just as untyped as when it was built.
if (warningSink !== undefined && earlyHit.performance !== undefined) {
warningSink.warnings = earlyHit.performance;
warningSink.sourceTexts = new Map();
}
const executableCacheOptions = earlyCacheOptions;
// Route/proof metadata is independently evictable. A full-compiler
// fallback that still finds the validated payload repairs that lightweight
@@ -1497,7 +1492,6 @@ async function compileTracked(
// A large program's LLVM exceeds the engine's string limit as one
// string; the emitter takes it in parts, as on a cold build.
await readTextParts(earlyHit.llvmPath),
earlyHit.native,
profile,
);
nativeInputPath = nativeProgramObject.linkPath;
@@ -1613,13 +1607,7 @@ async function compileTracked(
);
}
try {
nativeProgramObject = await emitNativeProgramObject(
entryPath,
opts,
llvmSource,
nativeFeatures,
profile,
);
nativeProgramObject = await emitNativeProgramObject(entryPath, opts, llvmSource, profile);
timing("native-object");
await recordEarlyExecutableNativeBuild(cacheRoot, executableCacheOptions).catch(
() => undefined,
@@ -1653,6 +1641,7 @@ async function compileTracked(
nativeDependencies: dependencies,
frontend: frontendInputs.snapshot(),
...(irPath === undefined ? {} : { irPath }),
...performanceWarnings(warningSink),
});
publishedExecutable = true;
},
@@ -1694,6 +1683,7 @@ async function compileTracked(
executableRestored: false,
frontend: frontendInputs.snapshot(),
...(irPath === undefined ? {} : { irPath }),
...performanceWarnings(warningSink),
}).catch(() => undefined);
}
await pruneBuildCache(cacheRoot);
@@ -2119,6 +2119,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* surface — the handles are the same kinds. */
"tls.createServer": { argTypes: [null, null], result: NETSERVER_T },
"tls.createServerCb": { argTypes: [null, null, null], result: NETSERVER_T },
/** The SNICallback forms: (cert, key, SNICallback[, handler]). The
* callback is http2.createSecureServerSni's shape, a function or a
* function | undefined union. */
"tls.createServerSni": { argTypes: [null, null, null], result: NETSERVER_T },
"tls.createServerSniCb": { argTypes: [null, null, null, null], result: NETSERVER_T },
// The runtime options records (divergence 66's stance): a dyn options
// value whose members read at runtime; pemDyn extracts a runtime-valued
// cert/key member (arg 1 is the precomposed fence label).
@@ -3675,6 +3680,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"dyn.bagGet": { argTypes: [DYN, STRING, DYN], result: DYN },
"dyn.bagSet": { argTypes: [DYN, STRING, DYN, DYN], result: VOID },
"dyn.objCreate": { argTypes: [DYN], result: DYN },
// Names a compiled class's prototype object (args[0]) after the class
// (args[1]) for util.inspect.
"dyn.nameClassPrototype": { argTypes: [DYN, STRING], result: VOID },
"dyn.objCreateWithProperties": { argTypes: [DYN, DYN], result: DYN },
"dyn.getPrototype": { argTypes: [DYN], result: DYN },
"dyn.setPrototype": { argTypes: [DYN, DYN], result: DYN },
@@ -3733,6 +3741,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"regex.groupParseInt": { argTypes: [arrayOf(STRING), F64, F64], result: F64 },
// `.index` / `.input` (args[1]) of a match result. Borrowed; never throws.
"regex.resultProp": { argTypes: [arrayOf(STRING), STRING], result: DYN },
// The string keys `for...in` visits on an array (args[0], any element
// type) after its indices. Borrowed; never throws.
"array.namedKeys": { argTypes: [null], result: arrayOf(STRING) },
// Whether the array (args[0]) still has the key (args[1]) from its
// namedKeys list. Borrowed; never throws.
"array.hasNamedKey": { argTypes: [null, STRING], result: BOOL },
// node:events EventEmitter: receivers are emitter-hierarchy objects and
// the chaining forms (on/off/removeAll/setMax) return the receiver's
// own class — program-dependent object types, checked in the libCall
@@ -4625,6 +4639,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* lone surrogate as U+FFFD). Borrowed arg; owned (+1) string; never throws. */
"text.decode": { argTypes: [BYTES_U8], result: STRING },
"bytes.bufferSource": { argTypes: [DYN], result: BYTES_U8 },
/** Whether an error-first callback's Buffer result is absent: the zlib
* and crypto callback adapters pass NULL after an error. Borrowed arg;
* never throws. */
"bytes.isAbsent": { argTypes: [BYTES_U8], result: BOOL },
/** The absent Buffer result itself (NULL), where an error-path callback
* body passes its undefined result on into a Buffer slot. Owned (+1);
* never throws. */
"bytes.absent": { argTypes: [], result: BYTES_U8 },
/** The Buffer brand of a Uint8Array source onto a fresh Uint8Array
* result (map and filter of a Buffer return a Buffer). */
"bytes.copyBrand": { argTypes: [BYTES_U8, BYTES_U8], result: VOID },

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