diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f131747fa..b3bf0e770 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -524,7 +524,7 @@ jobs:
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.linux == 'true' }}
name: test (tsgo and tsc-ts give the same IR)
runs-on: ubuntu-24.04
- timeout-minutes: 30
+ timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec0b27dbf..96f13fa26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,28 @@ All notable changes to scriptc will be documented in this file.
+## 0.4.3
+
+### Highlights
+
+- **Faster checking and smaller builds with tsc-ts 0.0.9:** scriptc checks with tsc-ts 0.0.9, and builds reuse the bundled declaration files' diagnostics instead of checking them again. Small IR builds are 10.6 to 16.0% faster and small executable builds 5.5 to 7.6% faster, with identical output. A native build of tsc-ts with this release checks 0.4 to 6.6% faster than with 0.4.2 and peaks 4 to 16% lower.
+- **Integer arrays and loops:**
+ - Number arrays that only hold int32 values use 4-byte slots in locals, across direct calls and in module globals, and loop-carried locals written from their elements stay int32. A workload that walks arrays of cursors runs 54.5% faster.
+ - Loops with two counters closing in on each other (`for (let i = lo, j = hi; i < j; i++, j--)`) and element copies through `a[i]!` run without per-element checks, and two-counter `while` loops run on integer counters.
+ - Loops over typed arrays and charAt scans are versioned on their bounds, and ASCII strings are scanned as bytes.
+- **Less work for untyped and dynamic code:**
+ - String-keyed record reads under `??` and `!` no longer allocate: a dictionary-walking workload makes no union boxes instead of 363,359 and runs 12 to 15% faster.
+ - Checked-dynamic method calls dispatch on method ids, observed class methods call natively while their slot is unchanged, and calls on bag-tracked class instances skip building their views.
+- **Node.js parity:**
+ - `crypto.getCipherInfo`, AES-CCM, AES key wrap, the legacy `createCipheriv` modes, `checkPrime`/`generatePrime`, and Node's argument checks for `generateKeyPair`.
+ - Streams: `construct`, `writev`, `decodeStrings` and `defaultEncoding`, including `writev` on `Transform` and `PassThrough`.
+ - `FinalizationRegistry`, `new.target`, `String.prototype.at` on untyped values, `Error.stackTraceLimit`, `spawnSync`'s `env` option, and class constructors called through `call`/`apply` throw Node's TypeError.
+ - Records enumerate in the key order their object literal wrote, and objects converting to a union with an empty-record arm keep every field.
+- **npm packages:** zod 3 compiles natively and zod 4's core runs, minified packages such as marked compile, chalk 5 and hot-shots 8 build from built-in shims, and `@hono/node-server` 2.x is built in. User code types npm package declarations at a checked, identity-preserving boundary.
+- **Builds:** the cached build is reused after whitespace and comment edits, function bodies are emitted on forked workers, and the LLVM emitter reads its analyses from one recorded walk of each module.
+
+
+
## 0.4.2
### Highlights
@@ -159,8 +181,6 @@ All notable changes to scriptc will be documented in this file.
- **The same program compiles to the same executable wherever it sits on disk** and whatever order its functions lower in, and edits outside a declaration do not rename the helpers and closures inside it.
- **Libraries that use untyped byte arrays** link.
-
-
## 0.4.1
### Highlights
diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx
index f1b64948d..e017267d7 100644
--- a/docs/content/docs/how-it-works.mdx
+++ b/docs/content/docs/how-it-works.mdx
@@ -42,17 +42,16 @@ Parsing, type checking, lowering, and LLVM IR generation run one after another o
### 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.
+The LLVM helper divides an executable's LLVM IR into partitions by its size. Up to 1 MiB of LLVM text is one partition, and up to 2 MiB is four. Larger programs get one partition per 4 MiB of LLVM text, rounded up to a power of two, and at least 8 and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets.
LLVM text Partitions
- Up to 4 MiB 1
- Over 4 MiB, up to 8 MiB 2
- Over 8 MiB, up to 16 MiB 4
- Over 16 MiB, up to 32 MiB 8
+ Up to 1 MiB 1
+ Over 1 MiB, up to 2 MiB 4
+ Over 2 MiB, up to 32 MiB 8
Over 32 MiB, up to 64 MiB 16
Over 64 MiB, up to 128 MiB 32
Over 128 MiB 64
@@ -63,7 +62,7 @@ The count depends only on the size of the LLVM text, so a program has the same p
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.
+The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend. A program of one partition is optimized and compiled on one thread.
How partitions are compiled depends on `--optimization`:
diff --git a/docs/content/limitations/builtin-apis/asynclocalstorage.mdx b/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
index 341994faa..ce77d57f5 100644
--- a/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
+++ b/docs/content/limitations/builtin-apis/asynclocalstorage.mdx
@@ -1,2 +1,12 @@
- **AsyncLocalStorage:** timer, interval, `setImmediate`, `process.nextTick` and `queueMicrotask` callbacks run with the stores that were active when they were scheduled, as in Node.
- Completion callbacks of callback-style I/O APIs, such as `fs.readFile` and `crypto.randomBytes`, run with no store, where Node runs them with the stores that were active when the operation started.
+ The completion callbacks of `fs`, `crypto` and `zlib` callback APIs, `dns.lookup`, `child_process.execFile`, `server.listen`, `net.connect`, `http.request`, `http.get` and `dgram` run with the stores that were active at the call, as in Node.
+ Events of `net`, `tls`, `http` and `https` sockets, servers, requests and responses run with the stores Node runs them with.
+ A socket's or client request's events see the context of the call that created it, and a server's events and its requests' and responses' events see the context of its `listen()` call.
+ Socket `write` and `end` callbacks, `'finish'` and `server.close(callback)` see the context of their call.
+ Stream events and callbacks run with the stores of the code that triggers them, as in Node.
+ Stream steps that Node defers with `process.nextTick` see the context that scheduled them, and `stream.finished` and `stream.pipeline` callbacks see the context of their call.
+ `fs` read and write streams and `zlib` streams run their events and callbacks in the context that created them.
+ Events of `child_process` children, their standard streams and their IPC channels see the context of the `spawn()` or `fork()` call; stdin's `'finish'` and `send()` callbacks see the context of their call.
+ A client `http2` session's events see the context of its `http2.connect()` call, and a client stream's events the context of its `request()` call.
+ An `http2` server's `'session'` and `'stream'` events and the compatibility API's request handler see the context of its `listen()` call; a server stream's own events, and its compatibility request's and response's, run with no store, as in Node.
+ `AsyncResource` has no lowering yet.
diff --git a/docs/content/limitations/builtin-apis/crypto.mdx b/docs/content/limitations/builtin-apis/crypto.mdx
index e4b555806..329aa6809 100644
--- a/docs/content/limitations/builtin-apis/crypto.mdx
+++ b/docs/content/limitations/builtin-apis/crypto.mdx
@@ -27,20 +27,22 @@
A call on a DiffieHellman value whose arguments no @types/node overload accepts, or whose output encoding is typed `any`, is refused at compile time, as are its `Object.prototype` methods; other property reads, and property writes, throw a coded SC2020 error.
`Object.keys()` and `in` do not see a DiffieHellman's own `verifyError` property, and `JSON.stringify()` and `instanceof` are refused.
After `createDiffieHellman(primeLength)` throws `Invalid DH parameters`, the next OpenSSL error Node throws can list the failed generation's errors in `opensslErrorStack`; errors in a compiled binary list only their own.
- `crypto.diffieHellman`, which takes DH, EC, or X25519 KeyObjects, and the `DiffieHellman` and `DiffieHellmanGroup` constructors are not available.
+ `crypto.diffieHellman` computes ECDH and X25519 secrets from EC and X25519 keys, given as KeyObjects or in any form `createPrivateKey` and `createPublicKey` read, with Node's errors; DH keys have no KeyObjects here, and the `DiffieHellman` and `DiffieHellmanGroup` constructors are not available.
`checkPrime`, `checkPrimeSync`, `generatePrime`, and `generatePrimeSync` support bigint, ArrayBuffer, typed-array, Buffer, and DataView values and the `checks`, `safe`, `add`, `rem`, and `bigint` options on native targets, with Node's errors; the callback forms compute before they return and call back in a later macrotask.
Candidates and sizes longer than 262144 bits throw a coded SC2020 error, and wasm32-wasi builds are refused.
Node calls their callbacks with `undefined` as the error, which a callback whose error parameter is typed `Error | null`, as @types/node declares it, receives as `null`.
- RSA, EC, and Ed25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
+ RSA, EC, Ed25519, and X25519 private and public KeyObjects come from `generateKeyPairSync`, `generateKeyPair`, `createPrivateKey`, and `createPublicKey`, on native targets.
`generateKeyPairSync` and `generateKeyPair` check their arguments in Node's order, with Node's errors, including the OpenSSL errors for RSA modulus lengths below 512 or above 2147483647 bits and for public exponents of 0, 1, or an even number, which `generateKeyPair` passes to its callback.
- RSA keys generate with even modulus lengths from 1024 to 8192 bits and public exponents up to 2147483647; other lengths and exponents that Node accepts, the `raw-public`, `raw-private`, and `raw-seed` encodings, and `paramEncoding: 'explicit'` throw a coded SC2020 error.
+ RSA keys generate with even modulus lengths from 1024 to 8192 bits and public exponents up to 2147483647; other lengths and exponents that Node accepts, and `paramEncoding: 'explicit'`, throw a coded SC2020 error.
The EC curves are prime256v1 (P-256), secp384r1 (P-384), secp521r1 (P-521), and secp256k1.
- Keys read and write PEM and DER as PKCS#1, SEC1, PKCS#8, and SubjectPublicKeyInfo, and JWK (Ed25519 keys use PKCS#8, SubjectPublicKeyInfo, and JWK); `createPublicKey` also takes a private key or an X.509 certificate.
+ Keys read and write PEM and DER as PKCS#1, SEC1, PKCS#8, and SubjectPublicKeyInfo, and JWK (Ed25519 and X25519 keys use PKCS#8, SubjectPublicKeyInfo, and JWK); `createPublicKey` also takes a private key or an X.509 certificate.
+ EC, Ed25519, and X25519 keys also read and write the `raw-public` and `raw-private` formats, including compressed EC points.
+ Raw X448, Ed448, and post-quantum keys, EC scalars of 0 or at least the curve order, and the EC point at infinity, which Node accepts, throw a coded SC2020 error.
They support `type`, `asymmetricKeyType`, `asymmetricKeyDetails`, `export()`, `equals()`, and `instanceof KeyObject`.
`crypto.sign`, `crypto.verify`, `createSign`, and `createVerify` support RSA PKCS#1 v1.5 and PSS padding and ECDSA with DER and IEEE P1363 encodings over SHA-1 and SHA-2 digests, and Ed25519 through `crypto.sign` and `crypto.verify` with a `null` or `undefined` algorithm.
Ed25519 signing, verification, and private keys link when a string literal in the program names `ed25519` (in any case); without one they throw a coded SC2020 error, while Ed25519 public keys still import and export.
Sign and Verify values accumulate `update()` data and are not writable streams.
- Encrypted private keys (`passphrase`, `cipher`), the other key types (`rsa-pss`, `dsa`, `ed448`, `x25519`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused.
+ Encrypted private keys (`passphrase`, `cipher`), the other key types (`rsa-pss`, `dsa`, `ed448`, `x448`, and the rest), and wasm32-wasi builds throw a coded SC2020 error or are refused.
Comparing keys of different types with `equals()` answers false; Node also leaves an OpenSSL error queued, which a later key operation can report.
`publicEncrypt`/`privateDecrypt` remain unsupported.
The WebCrypto `crypto` global, which node:crypto also exports as `webcrypto`, supports `randomUUID()` and `getRandomValues()`, also read as values from untyped JavaScript.
diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx
index ac8bf9115..89266b745 100644
--- a/docs/content/limitations/builtin-apis/date.mdx
+++ b/docs/content/limitations/builtin-apis/date.mdx
@@ -1,5 +1,6 @@
-- **Date:** Date unions, spread constructor arguments, setters, statically typed TypeScript identity comparisons, thrown Date values, and locale formatters are unsupported.
- Zero-argument, single number/string, and local-time year/month field constructors, storage and passing, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
- JavaScript Dates retain identity through checked storage and support Date-copy construction and JSON serialization.
- A TypeScript Date converted to `unknown` becomes a Date with the same time value; each conversion creates a new Date, so two `unknown` values converted from one TypeScript Date are not `===`.
+- **Date:** Spread constructor arguments, setters, thrown Date values, and locale formatters are unsupported.
+ Dates are objects: `===` compares identity, `new Date(date)` makes a new Date, and Dates compile in arrays (`includes`, `indexOf`, `lastIndexOf`, `join`, and default `sort` by `toString()` text), in `Date | undefined` and `Date | null` slots, as Map and Set keys and values, and in records, tuples, class fields, closures, promises and generators.
+ Unions of a Date with other non-nullish types are unsupported.
+ Zero-argument, single number/string, and local-time year/month field constructors, `getTime`, `valueOf`, `toISOString`, `toUTCString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), `toString`, `toDateString`, `toTimeString`, `String(date)` and template literal conversion, relational comparison, calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile.
+ A Date converted to `unknown` is the same Date object.
See [Dates and timezones](#dates-and-timezones) for timezone data.
diff --git a/docs/content/limitations/builtin-apis/streams-and-readline.mdx b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
index 45a784742..f9d69e3db 100644
--- a/docs/content/limitations/builtin-apis/streams-and-readline.mdx
+++ b/docs/content/limitations/builtin-apis/streams-and-readline.mdx
@@ -27,7 +27,8 @@
A `Duplex.from` body function may take the `{ signal }` argument, which is aborted with the error the Duplex is destroyed with.
`stream.compose` accepts streams and async generator functions in any position, an async function as the last stage, and an array, a string, or a generator as the first stage; one stage is `Duplex.from` of it.
A web `ReadableStream` is read as an async iterable by `Readable.from` and as the first stage of `compose` or `pipeline`, and through its reader by `Readable.fromWeb` (with `objectMode`, `highWaterMark`, `encoding`, and `signal` in an options object literal) and `Duplex.from`.
- A web stream in a later compose stage, `Readable.toWeb`, and web `WritableStream` and `TransformStream` interop are unsupported.
+ `new TransformStream()` without arguments builds the identity transform: its readable side is a web `ReadableStream`, its writable side supports `getWriter`, `abort`, `close`, and `locked`, and the writer supports `write`, `close`, `abort`, `releaseLock`, `ready`, `closed`, and `desiredSize`, with Node's backpressure (a write completes when the readable side reads its chunk).
+ A web stream in a later compose stage, `Readable.toWeb`, a `TransformStream` with a transformer or queuing strategies, the `WritableStream` constructor, `pipeTo`, and `pipeThrough` are unsupported.
The `ERR_INVALID_ARG_VALUE` message for a stream that is no longer readable or writable names the stream's class instead of printing the stream.
`readable.compose(stage, { signal })` is `stream.compose(readable, stage)` with the signal added; the options are an object literal whose `signal` is a variable or a property read.
`pipeline` from `node:stream/promises` accepts a trailing `{ signal, end }` object, with or without function stages, and `finished` accepts `readable`, `writable`, `error`, `cleanup`, and `signal` options, with boolean literals for the flags.
diff --git a/docs/content/limitations/builtin-apis/text-encoding.mdx b/docs/content/limitations/builtin-apis/text-encoding.mdx
index bffd24424..e20cfd943 100644
--- a/docs/content/limitations/builtin-apis/text-encoding.mdx
+++ b/docs/content/limitations/builtin-apis/text-encoding.mdx
@@ -1,6 +1,7 @@
- **Text encoding:** `TextEncoder` and `TextDecoder` can be stored in fields, arguments, return values, arrays, and closures.
Encoding supports UTF-8, including `encodeInto` into a Uint8Array or Buffer view.
Codec properties expose the canonical encoding and decoder flags.
+ Codecs held in `any` or `unknown` storage keep their identity and streaming state, and dynamic code can call `encode`, `encodeInto` and `decode`, with options objects, and read their properties.
Decoding accepts recognized WHATWG labels, including runtime labels and omitted or undefined encodings.
Literal constructor options support `fatal` and `ignoreBOM`; literal decode options support streaming UTF-8, UTF-16, and single-byte encodings.
Other legacy streaming encodings, runtime options objects, inspection, JSON conversion, and module-scope declarations using `var` or lacking an initializer are unsupported.
diff --git a/docs/package.json b/docs/package.json
index 3c2a950fd..51d74d0a1 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -10,9 +10,10 @@
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && next typegen && tsc --noEmit",
+ "test:unit": "node --test test/compatibility-matrix.test.mjs",
"test:routes": "node scripts/test-routes.mjs",
"compat:check": "pnpm --dir .. node-compat:check",
- "check": "pnpm compat:check && pnpm typecheck && pnpm build && pnpm test:routes"
+ "check": "pnpm compat:check && pnpm typecheck && pnpm test:unit && pnpm build && pnpm test:routes"
},
"dependencies": {
"@vercel/geistdocs": "2.7.8",
diff --git a/docs/src/components/node-compatibility-matrix.tsx b/docs/src/components/node-compatibility-matrix.tsx
index 095bcdaf2..af2435668 100644
--- a/docs/src/components/node-compatibility-matrix.tsx
+++ b/docs/src/components/node-compatibility-matrix.tsx
@@ -4,52 +4,28 @@
// empty after an edit.
// @refresh reset
-import { useEffect, useMemo, useState } from "react";
+import { memo, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from "react";
+import {
+ buildTree,
+ expandableIds,
+ filterTree,
+ flattenTree,
+ nodeCount,
+ rowMatcher,
+ searchTexts,
+ statusCounts,
+ statuses,
+ type NodeStability,
+ type Row,
+ type Snapshot,
+ type Status,
+ type Verification,
+} from "@/lib/compatibility-tree";
// Set by next.config.mjs from the committed artifact.
const artifactVersion = process.env.COMPATIBILITY_ARTIFACT_VERSION;
const rowCount = Number(process.env.COMPATIBILITY_ROW_COUNT);
-type Status =
- | "supported"
- | "partial"
- | "refused"
- | "not-implemented"
- | "by-design"
- | "unreviewed"
- | "not-applicable";
-type VerificationBasis =
- | "test-backed"
- | "explicit-refusal"
- | "verified-absence"
- | "declared-gap"
- | "registry-gap"
- | "architectural-policy"
- | "unreviewed"
- | "not-applicable"
- | "derived";
-type Verification = { label: string; detail: string };
-type Tier = { status: Status; detail: string; verification: VerificationBasis };
-type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
-type Row = {
- id: string;
- chapter: string;
- kind: string;
- name: string;
- signature: string;
- label: string;
- apiSymbol: string;
- depth: number;
- scope: "api" | "documentation" | "metadata" | "configuration";
- anchor: string;
- nodeStability: NodeStability;
- static: Tier;
-};
-type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
-type Snapshot = { nodeVersion: string; verificationBases: Record; chapters: Chapter[]; rows: Row[] };
-type TreeNode = { row: Row; children: TreeNode[] };
-type VisibleNode = { node: TreeNode; level: number };
-
const labels: Record = {
supported: "Supported",
partial: "Partial",
@@ -70,7 +46,6 @@ const styles: Record = {
"not-applicable": "border-gray-alpha-400 bg-background-200 text-gray-700",
};
-const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
const stabilityStyles: Record = {
0: "border-red-500/40 bg-red-100 text-red-900 dark:bg-red-200",
1: "border-amber-500/50 bg-amber-100 text-amber-900 dark:bg-amber-200",
@@ -78,114 +53,83 @@ const stabilityStyles: Record = {
3: "border-gray-alpha-500 bg-gray-alpha-100 text-gray-900",
};
-function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
- const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
+/** A badge's tooltip, mounted only while the badge is hovered or has
+ * keyboard focus (the :focus-visible rule the CSS-only tooltip used), and
+ * named by aria-describedby while open. Hover and focus are tracked apart,
+ * so moving the pointer away keeps a keyboard-focused badge's tooltip.
+ * Escape dismisses it until the next hover or focus. */
+function BadgeTooltip({ tooltipClass, children, badge }: { tooltipClass: string; children: ReactNode; badge: ReactNode }) {
+ const [hovered, setHovered] = useState(false);
+ const [focused, setFocused] = useState(false);
+ const open = hovered || focused;
+ const id = useId();
+ const enter = useCallback(() => setHovered(true), []);
+ const leave = useCallback(() => setHovered(false), []);
+ const focus = useCallback((event: FocusEvent) => {
+ if (event.currentTarget.matches(":focus-visible")) setFocused(true);
+ }, []);
+ const blur = useCallback(() => setFocused(false), []);
+ const key = useCallback((event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ setHovered(false);
+ setFocused(false);
+ }, []);
return (
-
-
- {labels[status]}
-
- {tooltip ? (
-
- {detail ? {detail} : null}
- {verification ? Verification: {verification.label}. {verification.detail} : null}
+
+ {badge}
+ {open ? (
+
+ {children}
) : null}
);
}
-function StabilityBadge({ stability }: { stability: NodeStability }) {
+export const StatusBadge = memo(function StatusBadge({ status, detail, verification }: { status: Status; detail?: string; verification?: Verification }) {
+ const tooltip = verification ? `${detail ?? ""}${detail ? "\n\n" : ""}Verification: ${verification.label}. ${verification.detail}` : detail;
+ const badge = (
+
+ {labels[status]}
+
+ );
+ if (!tooltip) return {badge} ;
+ return (
+
+ {detail ? {detail} : null}
+ {verification ? Verification: {verification.label}. {verification.detail} : null}
+
+ );
+});
+
+export const StabilityBadge = memo(function StabilityBadge({ stability }: { stability: NodeStability }) {
if (stability === null) return — ;
const detail = `Node.js Stability ${stability.index}: ${stability.text}${stability.inherited ? " (inherited from the nearest parent section)" : ""}`;
return (
-
-
- {stability.index}
-
-
- {detail}
-
-
- );
-}
-
-function statusCounts(rows: Row[], tier: "static") {
- return rows.reduce>(
- (counts, row) => {
- if (row.scope !== "api" || row.depth === 0) return counts;
- counts[row[tier].status] += 1;
- return counts;
- },
- {
- supported: 0,
- partial: 0,
- refused: 0,
- "not-implemented": 0,
- "by-design": 0,
- unreviewed: 0,
- "not-applicable": 0,
- },
- );
-}
-
-/** Node's all.json is preorder with an explicit heading depth. */
-function buildTree(rows: Row[]): TreeNode[] {
- const roots: TreeNode[] = [];
- const stack: TreeNode[] = [];
- for (const row of rows) {
- const node: TreeNode = { row, children: [] };
- while (stack.length > row.depth) stack.pop();
- const parent = stack[row.depth - 1];
- if (row.depth === 0 || parent === undefined) roots.push(node);
- else parent.children.push(node);
- stack[row.depth] = node;
- stack.length = row.depth + 1;
- }
- return roots;
-}
-
-function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): TreeNode[] {
- const filtered: TreeNode[] = [];
- for (const node of nodes) {
- const children = filterTree(node.children, matches);
- if (matches(node.row) || children.length > 0) filtered.push({ row: node.row, children });
- }
- return filtered;
-}
-
-function flattenTree(nodes: TreeNode[], expanded: Set, forceExpanded: boolean): VisibleNode[] {
- const visible: VisibleNode[] = [];
- const visit = (items: TreeNode[], level: number) => {
- for (const node of items) {
- visible.push({ node, level });
- if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
- visit(node.children, level + 1);
+
+ {stability.index}
+
}
- }
- };
- visit(nodes, 1);
- return visible;
-}
-
-function nodeCount(nodes: TreeNode[]): number {
- return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
-}
-
-function expandableIds(nodes: TreeNode[]): Set {
- const ids = new Set();
- const visit = (items: TreeNode[]) => {
- for (const node of items) {
- if (node.children.length > 0) ids.add(node.row.id);
- visit(node.children);
- }
- };
- visit(nodes);
- return ids;
-}
+ >
+ {detail}
+
+ );
+});
function Chevron({ open }: { open: boolean }) {
return (
@@ -195,6 +139,47 @@ function Chevron({ open }: { open: boolean }) {
);
}
+type MatrixRowProps = {
+ row: Row;
+ level: number;
+ hasChildren: boolean;
+ open: boolean;
+ filtering: boolean;
+ displayName: string;
+ href: string;
+ verification: Verification | undefined;
+ onToggle: (id: string) => void;
+};
+
+/** One table row. Memoized on its own values, so a filter or expansion
+ * change re-renders only the rows whose values changed. */
+const MatrixRow = memo(function MatrixRow({ row, level, hasChildren, open, filtering, displayName, href, verification, onToggle }: MatrixRowProps) {
+ const isRoot = level === 1;
+ return (
+
+
+
+ {hasChildren ? (
+
onToggle(row.id)} disabled={filtering} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
+
+
+ ) :
}
+
+
+ {displayName}
+
+
+ {row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
+
+
+
+
+
+
+
+ );
+});
+
export function NodeCompatibilityMatrix() {
const [snapshot, setSnapshot] = useState(null);
const [loadError, setLoadError] = useState(false);
@@ -202,15 +187,16 @@ export function NodeCompatibilityMatrix() {
const [staticFilter, setStaticFilter] = useState<"all" | Status>("all");
const [expanded, setExpanded] = useState>(() => new Set());
- const rows = snapshot?.rows ?? [];
- const chapters = snapshot?.chapters ?? [];
+ const rows = useMemo(() => snapshot?.rows ?? [], [snapshot]);
+ const chapters = useMemo(() => snapshot?.chapters ?? [], [snapshot]);
const verificationBases = snapshot?.verificationBases;
const apiRowCount = useMemo(() => rows.filter((row) => row.scope === "api").length, [rows]);
const chapterTitles = useMemo(() => new Map(chapters.map((item) => [item.slug, item.title])), [chapters]);
const base = `https://nodejs.org/docs/v${snapshot?.nodeVersion ?? "24.22.0"}/api/`;
- const staticCounts = useMemo(() => statusCounts(rows, "static"), [rows]);
+ const staticCounts = useMemo(() => statusCounts(rows), [rows]);
const visibleStatuses = useMemo(() => statuses.filter((status) => staticCounts[status] > 0), [staticCounts]);
const tree = useMemo(() => buildTree(rows), [rows]);
+ const texts = useMemo(() => searchTexts(rows, chapterTitles), [rows, chapterTitles]);
useEffect(() => {
const controller = new AbortController();
@@ -231,36 +217,23 @@ export function NodeCompatibilityMatrix() {
return () => controller.abort();
}, []);
- const needle = query.trim().toLocaleLowerCase();
- const hasResultFilter = needle !== "" || staticFilter !== "all";
- const rowMatches = (row: Row) => {
- if (staticFilter !== "all" && row.static.status !== staticFilter) return false;
- if (!needle) return true;
- return [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
- .join(" ")
- .toLocaleLowerCase()
- .includes(needle);
- };
+ // The search field and the status select update at once; the table
+ // follows the deferred values, so input stays responsive while the rows
+ // re-render.
+ const deferredQuery = useDeferredValue(query);
+ const deferredStatic = useDeferredValue(staticFilter);
+ const needle = deferredQuery.trim().toLocaleLowerCase();
+ const hasResultFilter = needle !== "" || deferredStatic !== "all";
- const resultTree = useMemo(
- () => hasResultFilter ? filterTree(tree, rowMatches) : tree,
- // rowMatches closes over these filter values.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [hasResultFilter, needle, staticFilter, tree],
+ const result = useMemo(
+ () => hasResultFilter ? filterTree(tree, rowMatcher(texts, needle, deferredStatic)) : { nodes: tree, matched: nodeCount(tree) },
+ [hasResultFilter, needle, deferredStatic, texts, tree],
);
const visible = useMemo(
- () => flattenTree(resultTree, expanded, hasResultFilter),
- [expanded, hasResultFilter, resultTree],
- );
- const matchedCount = useMemo(
- () => hasResultFilter ? tree.reduce((count, node) => {
- const visit = (item: TreeNode): number => (rowMatches(item.row) ? 1 : 0) + item.children.reduce((sum, child) => sum + visit(child), 0);
- return count + visit(node);
- }, 0) : nodeCount(tree),
- // rowMatches closes over these filter values.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [hasResultFilter, needle, staticFilter, tree],
+ () => flattenTree(result.nodes, expanded, hasResultFilter),
+ [expanded, hasResultFilter, result],
);
+ const matchedCount = result.matched;
const changeQuery = (value: string) => setQuery(value);
const resetFilters = () => {
@@ -268,12 +241,12 @@ export function NodeCompatibilityMatrix() {
setStaticFilter("all");
setExpanded(new Set());
};
- const toggle = (id: string) => setExpanded((current) => {
+ const toggle = useCallback((id: string) => setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
- });
+ }), []);
useEffect(() => {
if (!snapshot) return;
@@ -316,8 +289,8 @@ export function NodeCompatibilityMatrix() {
{visible.length.toLocaleString()} visible · {matchedCount.toLocaleString()} {hasResultFilter ? "matching" : "API"} rows
{!hasResultFilter ? (
- setExpanded(expandableIds(tree))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all
- setExpanded(new Set())} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all
+ startTransition(() => setExpanded(expandableIds(tree)))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Expand all
+ startTransition(() => setExpanded(new Set()))} className="rounded px-1.5 py-1 text-gray-900 hover:bg-gray-alpha-100 hover:text-gray-1000">Collapse all
) : null}
@@ -335,33 +308,19 @@ export function NodeCompatibilityMatrix() {
{visible.map(({ node, level }) => {
const row = node.row;
- const hasChildren = node.children.length > 0;
- const open = hasResultFilter || expanded.has(row.id);
- const href = `${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`;
- const isRoot = level === 1;
- const displayName = isRoot ? chapterTitles.get(row.chapter) ?? row.label : row.label;
return (
-
-
-
- {hasChildren ? (
-
toggle(row.id)} disabled={hasResultFilter} aria-label={`${open ? "Collapse" : "Expand"} ${displayName}`} className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-800 hover:bg-gray-alpha-200 hover:text-gray-1000 disabled:cursor-default disabled:hover:bg-transparent">
-
-
- ) :
}
-
-
- {displayName}
-
-
- {row.kind}{row.apiSymbol && row.apiSymbol !== row.signature && !isRoot ? ` · ${row.apiSymbol}` : ""}
-
-
-
-
-
-
-
+ 0}
+ open={hasResultFilter || expanded.has(row.id)}
+ filtering={hasResultFilter}
+ displayName={level === 1 ? chapterTitles.get(row.chapter) ?? row.label : row.label}
+ href={`${base}${row.chapter}.html${row.anchor ? `#${row.anchor}` : ""}`}
+ verification={verificationBases?.[row.static.verification]}
+ onToggle={toggle}
+ />
);
})}
diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json
index c3f1835ab..1039bc3d8 100644
--- a/docs/src/generated/node-v24-compatibility.json
+++ b/docs/src/generated/node-v24-compatibility.json
@@ -11476,9 +11476,9 @@
"inherited": true
},
"static": {
- "status": "refused",
- "detail": "Explicitly rejected by a named compiler diagnostic.",
- "verification": "explicit-refusal"
+ "status": "partial",
+ "detail": "Implemented for the call shapes accepted by the compiler lowering.",
+ "verification": "test-backed"
}
},
{
@@ -35971,9 +35971,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"
}
},
{
@@ -81657,9 +81657,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 static async-iteration subset.",
+ "verification": "test-backed"
}
},
{
diff --git a/docs/src/lib/compatibility-tree.ts b/docs/src/lib/compatibility-tree.ts
new file mode 100644
index 000000000..4044bbc8d
--- /dev/null
+++ b/docs/src/lib/compatibility-tree.ts
@@ -0,0 +1,157 @@
+// The compatibility matrix's data model: the ledger rows as a tree, and the
+// search and filter passes over it. Kept free of React so the passes can be
+// tested directly.
+
+export type Status =
+ | "supported"
+ | "partial"
+ | "refused"
+ | "not-implemented"
+ | "by-design"
+ | "unreviewed"
+ | "not-applicable";
+export type VerificationBasis =
+ | "test-backed"
+ | "explicit-refusal"
+ | "verified-absence"
+ | "declared-gap"
+ | "registry-gap"
+ | "architectural-policy"
+ | "unreviewed"
+ | "not-applicable"
+ | "derived";
+export type Verification = { label: string; detail: string };
+export type Tier = { status: Status; detail: string; verification: VerificationBasis };
+export type NodeStability = { index: string; level: number; text: string; inherited: boolean } | null;
+export type Row = {
+ id: string;
+ chapter: string;
+ kind: string;
+ name: string;
+ signature: string;
+ label: string;
+ apiSymbol: string;
+ depth: number;
+ scope: "api" | "documentation" | "metadata" | "configuration";
+ anchor: string;
+ nodeStability: NodeStability;
+ static: Tier;
+};
+export type Chapter = { slug: string; title: string; entries: number; apiEntries: number };
+export type Snapshot = {
+ nodeVersion: string;
+ verificationBases: Record;
+ chapters: Chapter[];
+ rows: Row[];
+};
+export type TreeNode = { row: Row; children: TreeNode[] };
+export type VisibleNode = { node: TreeNode; level: number };
+
+export const statuses = ["supported", "partial", "not-implemented", "refused", "by-design", "unreviewed", "not-applicable"] as const;
+
+export function statusCounts(rows: Row[]): Record {
+ return rows.reduce>(
+ (counts, row) => {
+ if (row.scope !== "api" || row.depth === 0) return counts;
+ counts[row.static.status] += 1;
+ return counts;
+ },
+ {
+ supported: 0,
+ partial: 0,
+ refused: 0,
+ "not-implemented": 0,
+ "by-design": 0,
+ unreviewed: 0,
+ "not-applicable": 0,
+ },
+ );
+}
+
+/** Node's all.json is preorder with an explicit heading depth. */
+export function buildTree(rows: Row[]): TreeNode[] {
+ const roots: TreeNode[] = [];
+ const stack: TreeNode[] = [];
+ for (const row of rows) {
+ const node: TreeNode = { row, children: [] };
+ while (stack.length > row.depth) stack.pop();
+ const parent = stack[row.depth - 1];
+ if (row.depth === 0 || parent === undefined) roots.push(node);
+ else parent.children.push(node);
+ stack[row.depth] = node;
+ stack.length = row.depth + 1;
+ }
+ return roots;
+}
+
+/** The lowercase text a search matches against, built once per row: the
+ * chapter title, kind, name, signature, API symbol and support detail. */
+export function searchTexts(rows: Row[], chapterTitles: ReadonlyMap): Map {
+ return new Map(
+ rows.map((row) => [
+ row.id,
+ [chapterTitles.get(row.chapter), row.kind, row.name, row.signature, row.apiSymbol, row.static.detail]
+ .join(" ")
+ .toLocaleLowerCase(),
+ ]),
+ );
+}
+
+/** The rows a status filter and a lowercase search needle keep. */
+export function rowMatcher(
+ texts: ReadonlyMap,
+ needle: string,
+ status: "all" | Status,
+): (row: Row) => boolean {
+ return (row) => {
+ if (status !== "all" && row.static.status !== status) return false;
+ return needle === "" || (texts.get(row.id) ?? "").includes(needle);
+ };
+}
+
+/** The matching rows with their ancestors, and the number of matching
+ * rows, in one pass. */
+export function filterTree(nodes: TreeNode[], matches: (row: Row) => boolean): { nodes: TreeNode[]; matched: number } {
+ let matched = 0;
+ const visit = (items: TreeNode[]): TreeNode[] => {
+ const filtered: TreeNode[] = [];
+ for (const node of items) {
+ const children = visit(node.children);
+ const own = matches(node.row);
+ if (own) matched += 1;
+ if (own || children.length > 0) filtered.push({ row: node.row, children });
+ }
+ return filtered;
+ };
+ return { nodes: visit(nodes), matched };
+}
+
+export function flattenTree(nodes: TreeNode[], expanded: ReadonlySet, forceExpanded: boolean): VisibleNode[] {
+ const visible: VisibleNode[] = [];
+ const visit = (items: TreeNode[], level: number) => {
+ for (const node of items) {
+ visible.push({ node, level });
+ if (node.children.length > 0 && (forceExpanded || expanded.has(node.row.id))) {
+ visit(node.children, level + 1);
+ }
+ }
+ };
+ visit(nodes, 1);
+ return visible;
+}
+
+export function nodeCount(nodes: TreeNode[]): number {
+ return nodes.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
+}
+
+export function expandableIds(nodes: TreeNode[]): Set {
+ const ids = new Set();
+ const visit = (items: TreeNode[]) => {
+ for (const node of items) {
+ if (node.children.length > 0) ids.add(node.row.id);
+ visit(node.children);
+ }
+ };
+ visit(nodes);
+ return ids;
+}
diff --git a/docs/test/compatibility-matrix.test.mjs b/docs/test/compatibility-matrix.test.mjs
new file mode 100644
index 000000000..abd3df945
--- /dev/null
+++ b/docs/test/compatibility-matrix.test.mjs
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { test } from "node:test";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import ts from "typescript";
+import {
+ buildTree,
+ expandableIds,
+ filterTree,
+ flattenTree,
+ nodeCount,
+ rowMatcher,
+ searchTexts,
+ statusCounts,
+} from "../src/lib/compatibility-tree.ts";
+
+const row = (id, depth, label, status, detail = "") => ({
+ id,
+ chapter: "zlib",
+ kind: depth === 0 ? "module" : "method",
+ name: label,
+ signature: label,
+ label,
+ apiSymbol: label,
+ depth,
+ scope: "api",
+ anchor: "",
+ nodeStability: null,
+ static: { status, detail, verification: "test-backed" },
+});
+const rows = [
+ row("root", 0, "zlib", "partial"),
+ row("a", 1, "zlib.gzipSync()", "supported", "Lowered with Node's options"),
+ row("b", 1, "zlib.Gzip", "partial"),
+ row("b1", 2, "gzip.flush()", "supported"),
+ row("c", 1, "zlib.zstdCompress()", "not-implemented"),
+];
+const titles = new Map([["zlib", "Zlib"]]);
+
+test("the tree follows the rows' heading depths", () => {
+ const tree = buildTree(rows);
+ assert.equal(tree.length, 1);
+ assert.deepEqual(tree[0].children.map((node) => node.row.id), ["a", "b", "c"]);
+ assert.deepEqual(tree[0].children[1].children.map((node) => node.row.id), ["b1"]);
+ assert.equal(nodeCount(tree), 5);
+ assert.deepEqual([...expandableIds(tree)].sort(), ["b", "root"]);
+});
+
+test("filtering keeps matching rows with their ancestors and counts the matches once", () => {
+ const tree = buildTree(rows);
+ const texts = searchTexts(rows, titles);
+ const byText = filterTree(tree, rowMatcher(texts, "flush", "all"));
+ assert.equal(byText.matched, 1);
+ assert.deepEqual(flattenTree(byText.nodes, new Set(), true).map(({ node, level }) => `${node.row.id}@${level}`), ["root@1", "b@2", "b1@3"]);
+ const byDetail = filterTree(tree, rowMatcher(texts, "node's options", "all"));
+ assert.deepEqual(flattenTree(byDetail.nodes, new Set(), true).map(({ node }) => node.row.id), ["root", "a"]);
+ const byStatus = filterTree(tree, rowMatcher(texts, "", "supported"));
+ assert.equal(byStatus.matched, 2);
+ const both = filterTree(tree, rowMatcher(texts, "gzip", "partial"));
+ assert.equal(both.matched, 1);
+ assert.equal(filterTree(tree, rowMatcher(texts, "brotli", "all")).nodes.length, 0);
+});
+
+test("collapsed rows hide their children until expanded", () => {
+ const tree = buildTree(rows);
+ assert.deepEqual(flattenTree(tree, new Set(), false).map(({ node }) => node.row.id), ["root"]);
+ assert.deepEqual(flattenTree(tree, new Set(["root"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "c"]);
+ assert.deepEqual(flattenTree(tree, new Set(["root", "b"]), false).map(({ node }) => node.row.id), ["root", "a", "b", "b1", "c"]);
+});
+
+test("status counts cover API rows below the chapters", () => {
+ const counts = statusCounts(rows);
+ assert.equal(counts.supported, 2);
+ assert.equal(counts.partial, 1);
+ assert.equal(counts["not-implemented"], 1);
+});
+
+/** The matrix component, compiled for Node from its TSX source. */
+async function loadMatrix() {
+ const docs = new URL("../", import.meta.url);
+ const source = await readFile(new URL("src/components/node-compatibility-matrix.tsx", docs), "utf8");
+ const { outputText } = ts.transpileModule(source, {
+ compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+ });
+ const lib = pathToFileURL(fileURLToPath(new URL("src/lib/compatibility-tree.ts", docs))).href;
+ const dir = new URL("node_modules/.cache/compatibility-matrix-test/", docs);
+ await mkdir(dir, { recursive: true });
+ const file = new URL("node-compatibility-matrix.mjs", dir);
+ await writeFile(file, outputText.replace('"@/lib/compatibility-tree"', JSON.stringify(lib)));
+ return import(file.href);
+}
+
+test("badges render without their tooltips until hovered or focused", async () => {
+ const { StatusBadge, StabilityBadge } = await loadMatrix();
+ const status = renderToStaticMarkup(
+ createElement(StatusBadge, {
+ status: "partial",
+ detail: "Lowered with Node's options",
+ verification: { label: "Test-backed", detail: "Corpus programs cover it." },
+ }),
+ );
+ assert.ok(status.includes('tabindex="0"'), status);
+ assert.ok(status.includes('title="Lowered with Node's options'), status);
+ assert.ok(!status.includes('role="tooltip"'), status);
+ assert.ok(!status.includes("aria-describedby"), status);
+ const stability = renderToStaticMarkup(
+ createElement(StabilityBadge, { stability: { index: "2", level: 2, text: "Stable", inherited: false } }),
+ );
+ assert.ok(stability.includes('tabindex="0"'), stability);
+ assert.ok(!stability.includes('role="tooltip"'), stability);
+ const legend = renderToStaticMarkup(createElement(StatusBadge, { status: "supported" }));
+ assert.ok(!legend.includes("tabindex"), legend);
+});
diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json
index ba8692b49..b3d91e453 100644
--- a/internal/compatibility/generated/node-v24-internal.json
+++ b/internal/compatibility/generated/node-v24-internal.json
@@ -1387,7 +1387,20 @@
"evidence": "derived:descendants",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "chapter"
@@ -1437,7 +1450,20 @@
"evidence": "compiler-dedicated:AsyncLocalStorage",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1510,7 +1536,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.disable",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1537,7 +1576,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.getStore",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1564,7 +1616,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.enterWith",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1591,7 +1656,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.run",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1618,7 +1696,20 @@
"evidence": "compiler-dedicated:asyncLocalStorage.exit",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -1922,6 +2013,19 @@
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
"tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs",
"tests/harness/node-test.test.ts"
]
},
@@ -2484,7 +2588,20 @@
"evidence": "compiler-dedicated:AsyncLocalStorage",
"tests": [
"tests/corpus/2213-async-local-storage.cjs",
- "tests/corpus/5815-als-callback-context.ts"
+ "tests/corpus/5815-als-callback-context.ts",
+ "tests/corpus/7110-als-completion-callbacks.ts",
+ "tests/corpus/7111-als-bind-snapshot-options.ts",
+ "tests/corpus/7112-als-bind-snapshot-dynamic.mjs",
+ "tests/corpus/7119-als-net-event-context.mjs",
+ "tests/corpus/7380-als-http-event-context.mjs",
+ "tests/corpus/7383-als-stream-events.ts",
+ "tests/corpus/7384-als-stream-events.mjs",
+ "tests/corpus/7385-als-child-process-events.ts",
+ "tests/corpus/7386-als-child-fork-events/main.mjs",
+ "tests/corpus/7387-als-http2-events.mjs",
+ "tests/corpus/7388-als-http2-compat-events.mjs",
+ "tests/corpus/7389-als-http2-secure-events.mjs",
+ "tests/corpus/7391-als-response-end-and-finished.mjs"
]
},
"anchorSource": "exact"
@@ -20228,8 +20345,19 @@
"inherited": true
},
"static": {
- "status": "refused",
- "evidence": "surface-manifest:unsupported:node-builtin.crypto.diffieHellman"
+ "status": "partial",
+ "evidence": "surface-manifest:node-builtin.crypto.diffieHellman",
+ "tests": [
+ "tests/corpus/1358-crypto-random.ts",
+ "tests/corpus/1534-crypto-hash-chain.ts",
+ "tests/corpus/2556-crypto-introspection.ts",
+ "tests/corpus/2890-crypto-static-utility.ts",
+ "tests/harness/npm-static.test.ts",
+ "tests/corpus/node24-crypto-sha2.ts",
+ "tests/corpus/node24-crypto-kdf.ts",
+ "tests/corpus/node24-crypto-kdf-errors.ts",
+ "tests/corpus/node24-crypto-kdf-options.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -52194,6 +52322,8 @@
"tests/corpus/6294-webcrypto-js-values.mjs",
"tests/corpus/6295-jose-hs256/main.mjs",
"tests/corpus/6331-jose-root-entry/main.ts",
+ "tests/corpus/7743-transform-stream-identity.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/text-codec-values/main.ts",
"tests/corpus/webassembly-api.mjs",
"tests/corpus/webassembly-dylink/main.mjs",
@@ -53346,7 +53476,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53397,7 +53528,8 @@
"tests": [
"tests/corpus/1423-text-codec.ts",
"tests/corpus/text-codec-values/main.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -53443,8 +53575,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:globals"
+ "status": "partial",
+ "evidence": "compiler-feature:globals.TransformStream",
+ "tests": [
+ "tests/corpus/7743-transform-stream-identity.ts"
+ ]
},
"anchorSource": "exact"
},
@@ -101277,6 +101412,7 @@
"tests/corpus/5835-util-format-integer-specifiers.ts",
"tests/corpus/5884-inspect-layout-options.ts",
"tests/corpus/6428-promisify-callback-functions.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js",
"tests/corpus/node24-deep-equality-properties.js",
"tests/corpus/node24-deep-equality.cjs",
"tests/corpus/node24-deep-equality.ts",
@@ -101805,7 +101941,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -101926,7 +102063,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102036,7 +102174,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -102065,7 +102204,8 @@
"tests/corpus/text-codec-values/main.ts",
"tests/fixtures/node-types/text-codecs.mts",
"tests/harness/project-config.test.ts",
- "tests/corpus/3133-text-decoder-streaming.ts"
+ "tests/corpus/3133-text-decoder-streaming.ts",
+ "tests/corpus/7747-text-codec-untyped-slot.js"
]
},
"anchorSource": "exact"
@@ -111483,6 +111623,7 @@
"tests/corpus/2630-stream-consumers-errors.ts",
"tests/corpus/2862-web-readable-async-iteration.ts",
"tests/corpus/4875-stream-consumers-array-buffer.ts",
+ "tests/corpus/7743-transform-stream-identity.ts",
"tests/corpus/stream-web-readable.ts",
"tests/corpus/stream-web-require.cjs",
"tests/harness/fetch-conformance.test.ts"
@@ -112957,8 +113098,11 @@
"inherited": true
},
"static": {
- "status": "not-implemented",
- "evidence": "compiler-unmatched:webstreams"
+ "status": "partial",
+ "evidence": "compiler-feature:webstreams.TransformStream",
+ "tests": [
+ "tests/corpus/7743-transform-stream-identity.ts"
+ ]
},
"anchorSource": "exact"
},
diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json
index 100882c1a..92124f4f5 100644
--- a/internal/compatibility/static-support.json
+++ b/internal/compatibility/static-support.json
@@ -332,11 +332,11 @@
{ "chapter": "buffer", "signatures": ["`buf.readBigInt64BE([offset])`", "`buf.readBigInt64LE([offset])`", "`buf.readBigUInt64BE([offset])`", "`buf.readBigUInt64LE([offset])`", "`buf.writeBigInt64BE(value[, offset])`", "`buf.writeBigInt64LE(value[, offset])`", "`buf.writeBigUInt64BE(value[, offset])`", "`buf.writeBigUInt64LE(value[, offset])`"], "status": "supported", "evidence": ["tests/corpus/2902-bigint-bytes.ts", "tests/corpus/2904-bigint-errors.ts"] },
{ "chapter": "environment_variables", "symbols": ["export"], "status": "not-applicable" },
{ "chapter": "globals", "symbols": ["fetch"], "status": "partial", "evidence": ["tests/harness/fetch-conformance.test.ts"] },
- { "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
+ { "chapter": "globals", "signatures": ["Class: `TextEncoder`", "Class: `TextDecoder`"], "status": "partial", "evidence": ["tests/corpus/1423-text-codec.ts", "tests/corpus/text-codec-values/main.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "globals", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5287-blob-inspect.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5290-blob-dynamic.js"] },
{ "chapter": "buffer", "signatures": ["Class: `Blob`", "Class: `File`"], "status": "partial", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5288-blob-validation.js", "tests/fixtures/node-types/blob-file.ts"] },
{ "chapter": "buffer", "signatures": ["`blob.arrayBuffer()`", "`blob.bytes()`", "`blob.text()`", "`blob.slice([start[, end[, type]]])`", "`blob.size`", "`type` Type: {string}", "`name` Type: {string}", "`lastModified` Type: {number}"], "status": "supported", "evidence": ["tests/corpus/5285-blob-construct.ts", "tests/corpus/5286-blob-readers.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5289-blob-read-order.ts", "tests/corpus/5290-blob-dynamic.js", "tests/fixtures/node-types/blob-file.ts"] },
- { "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts"] },
+ { "chapter": "util", "signatures": ["Class: `util.TextEncoder`", "Class: `util.TextDecoder`", "`textEncoder.encode([input])`", "`textDecoder.decode([input[, options]])`"], "status": "partial", "evidence": ["tests/corpus/text-codec-values/main.ts", "tests/fixtures/node-types/text-codecs.mts", "tests/harness/project-config.test.ts", "tests/corpus/3133-text-decoder-streaming.ts", "tests/corpus/7747-text-codec-untyped-slot.js"] },
{ "chapter": "modules", "symbols": ["__dirname", "__filename", "exports", "require", "module.require", "main", "filename"], "status": "partial", "evidence": ["tests/corpus/1612-cjs-module-globals.cjs", "tests/corpus/1596-cjs-modules/main.js", "tests/corpus/1629-require-main-filename.cjs", "tests/corpus/2830-cjs-require-self-name/main.cjs", "tests/corpus/2880-cjs-module-graph/main.cjs", "tests/corpus/2923-cjs-factory-export/main.cjs"] },
{ "chapter": "modules", "symbols": ["require.resolve", "require.resolve.paths"], "status": "partial", "evidence": ["tests/corpus/2858-import-meta-resolve/main.mjs", "tests/corpus/2860-require-resolve/main.cjs"] },
{ "chapter": "modules", "symbols": ["cache", "children", "id", "isPreloading", "loaded", "parent", "path", "paths", "Module"], "status": "partial", "evidence": ["tests/corpus/2880-cjs-module-graph/main.cjs"] },
@@ -407,7 +407,9 @@
{ "chapter": "zlib", "signatures": ["`zlib.constants`"], "status": "partial", "evidence": ["tests/corpus/5376-zlib-constants.cjs", "tests/corpus/5374-zlib-stream-flush-params.ts"] },
{ "chapter": "dns", "symbols": ["dns.lookup", "dns.lookupService", "dns.getServers", "dns.getDefaultResultOrder", "dns.setDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/5648-dns-lookup-forms.ts", "tests/corpus/5649-dns-service-servers.ts", "tests/corpus/5650-dns-lookup-errors-untyped.cjs"] },
{ "chapter": "dns", "symbols": ["dnsPromises.lookup", "dnsPromises.lookupService", "dnsPromises.getDefaultResultOrder"], "status": "partial", "evidence": ["tests/corpus/dns-promises-lookup.ts", "tests/corpus/dns-promises-untyped.cjs"] },
- { "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] }
+ { "chapter": "webstreams", "symbols": ["ReadableStream"], "status": "partial", "evidence": ["tests/corpus/stream-web-readable.ts", "tests/corpus/stream-web-require.cjs", "tests/corpus/2862-web-readable-async-iteration.ts"] },
+ { "chapter": "webstreams", "symbols": ["TransformStream"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] },
+ { "chapter": "globals", "signatures": ["Class: `TransformStream`"], "status": "partial", "evidence": ["tests/corpus/7743-transform-stream-identity.ts"] }
],
"dedicated": [
{ "symbols": ["assert.doesNotThrow", "assert.doesNotReject"], "status": "partial", "evidence": ["tests/corpus/node24-no-error-assertions.ts", "tests/corpus/node24-no-error-assertions.cjs", "tests/corpus/node24-no-error-assertions-coercion.js", "tests/fixtures/node-types/no-error-assertions.ts", "tests/harness/project-config.test.ts"] },
@@ -439,7 +441,7 @@
{ "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] },
{ "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] },
{ "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts", "tests/corpus/5846-events-esm-default-max.ts", "tests/corpus/5847-events-cjs-default-max.cjs"] },
- { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts"] },
+ { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable", "asyncLocalStorage.name", "AsyncLocalStorage.bind", "AsyncLocalStorage.snapshot"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs", "tests/corpus/5815-als-callback-context.ts", "tests/corpus/7110-als-completion-callbacks.ts", "tests/corpus/7111-als-bind-snapshot-options.ts", "tests/corpus/7112-als-bind-snapshot-dynamic.mjs", "tests/corpus/7119-als-net-event-context.mjs", "tests/corpus/7380-als-http-event-context.mjs", "tests/corpus/7383-als-stream-events.ts", "tests/corpus/7384-als-stream-events.mjs", "tests/corpus/7385-als-child-process-events.ts", "tests/corpus/7386-als-child-fork-events/main.mjs", "tests/corpus/7387-als-http2-events.mjs", "tests/corpus/7388-als-http2-compat-events.mjs", "tests/corpus/7389-als-http2-secure-events.mjs", "tests/corpus/7391-als-response-end-and-finished.mjs"] },
{ "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] },
{ "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] },
{ "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] },
diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json
index 23584b60a..eee0ec3c8 100644
--- a/packages/cli-darwin-arm64/package.json
+++ b/packages/cli-darwin-arm64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-arm64",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json
index d9e6dafc6..b527a3cd7 100644
--- a/packages/cli-darwin-x64/package.json
+++ b/packages/cli-darwin-x64/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-x64",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json
index 5b8cb4db2..512a7cd13 100644
--- a/packages/cli-linux-arm64-gnu/package.json
+++ b/packages/cli-linux-arm64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json
index 0901602ec..8d0fdfb0f 100644
--- a/packages/cli-linux-arm64-musl/package.json
+++ b/packages/cli-linux-arm64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json
index f01478aad..b71205614 100644
--- a/packages/cli-linux-x64-gnu/package.json
+++ b/packages/cli-linux-x64-gnu/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json
index a6986a66a..eaeb6f15a 100644
--- a/packages/cli-linux-x64-musl/package.json
+++ b/packages/cli-linux-x64-musl/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-musl",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json
index 6b03811b8..3e3b8259f 100644
--- a/packages/cli-win32-x64-msvc/package.json
+++ b/packages/cli-win32-x64-msvc/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 034747c29..264a7f5f1 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "scriptc",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts
index d7abe70df..cb08a4965 100644
--- a/packages/compiler/ambient/scriptc-node-fallback.d.ts
+++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts
@@ -1106,6 +1106,34 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream;
readonly prototype: ReadableStream;
};
+/* The writable side of a TransformStream and its writer. The
+ * WritableStream constructor has no native lowering; writable streams come
+ * from `new TransformStream()`. */
+interface WritableStreamDefaultWriter {
+ readonly closed: Promise;
+ readonly desiredSize: number | null;
+ readonly ready: Promise;
+ abort(reason?: unknown): Promise;
+ close(): Promise;
+ releaseLock(): void;
+ write(chunk?: W): Promise;
+}
+interface WritableStream {
+ readonly locked: boolean;
+ abort(reason?: unknown): Promise;
+ close(): Promise;
+ getWriter(): WritableStreamDefaultWriter;
+}
+/* The identity transform: chunks written to `writable` are read from
+ * `readable`, and a write completes when its chunk is read. */
+interface TransformStream {
+ readonly readable: ReadableStream;
+ readonly writable: WritableStream;
+}
+declare var TransformStream: {
+ new (): TransformStream;
+ readonly prototype: TransformStream;
+};
/* fetch, typed as the native JSON + readable-stream slice: one URL, an
* optional init with the members CLI requests carry, and a Response
@@ -2964,6 +2992,24 @@ declare module "crypto" {
options: ED25519KeyPairOptions<"der", "der">,
): KeyPairSyncResult;
export function generateKeyPairSync(type: "ed25519", options?: {}): KeyPairKeyObjectResult;
+ export interface X25519KeyPairOptions {
+ publicKeyEncoding: { type: "spki"; format: PubF };
+ privateKeyEncoding: { type: "pkcs8"; format: PrivF };
+ }
+ export function generateKeyPairSync(
+ type: "x25519",
+ options: X25519KeyPairOptions<"pem", "pem">,
+ ): KeyPairSyncResult;
+ export function generateKeyPairSync(
+ type: "x25519",
+ options: X25519KeyPairOptions<"der", "der">,
+ ): KeyPairSyncResult;
+ export function generateKeyPairSync(type: "x25519", options?: {}): KeyPairKeyObjectResult;
+ export function generateKeyPair(
+ type: "x25519",
+ options: {} | undefined,
+ callback: (err: Error | null, publicKey: KeyObject, privateKey: KeyObject) => void,
+ ): void;
export function generateKeyPair(
type: "ed25519",
options: {} | undefined,
@@ -2992,6 +3038,11 @@ declare module "crypto" {
export interface VerifyKeyObjectInput extends SigningOptions {
key: KeyObject;
}
+ export function diffieHellman(options: { privateKey: KeyObject; publicKey: KeyObject }): Buffer;
+ export function diffieHellman(
+ options: { privateKey: KeyObject; publicKey: KeyObject },
+ callback: (err: Error | null, secret: Buffer) => void,
+ ): void;
export function sign(
algorithm: string | null | undefined,
data: ArrayBufferView,
@@ -7207,9 +7258,18 @@ declare module "node:tty" {
declare module "async_hooks" {
/* AsyncLocalStorage: fiber-carried context snapshots — run/getStore/
* exit/enterWith/disable lower onto the runtime's active-slot machinery
- * (als.* libCalls); values cross as dyn values. */
+ * (als.* libCalls); values cross as dyn values. bind and snapshot bind
+ * functions to the active context; the options carry the store's
+ * default value and name. */
+ export interface AsyncLocalStorageOptions {
+ defaultValue?: any;
+ name?: string | undefined;
+ }
export class AsyncLocalStorage {
- constructor();
+ constructor(options?: AsyncLocalStorageOptions);
+ static bind any>(fn: Func): Func;
+ static snapshot(): (fn: (...args: TArgs) => R, ...args: TArgs) => R;
+ readonly name: string;
run(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
exit(callback: (...args: any[]) => R, ...args: any[]): R;
getStore(): T | undefined;
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index a358ae545..60f6bd0f0 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@scriptc/compiler",
- "version": "0.4.2",
+ "version": "0.4.3",
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
- "tsc-ts": "0.0.8",
+ "tsc-ts": "0.0.9",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts
index 32318d53e..a9076af22 100644
--- a/packages/compiler/src/backend/executable-features.ts
+++ b/packages/compiler/src/backend/executable-features.ts
@@ -50,6 +50,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
+ textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
diff --git a/packages/compiler/src/backend/library-pack.ts b/packages/compiler/src/backend/library-pack.ts
index e9f52f7a0..79be316db 100644
--- a/packages/compiler/src/backend/library-pack.ts
+++ b/packages/compiler/src/backend/library-pack.ts
@@ -54,6 +54,7 @@ export async function compilePackedLibrary(
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts
index aa6345dab..2efeb96ac 100644
--- a/packages/compiler/src/backend/llvm/alloc.ts
+++ b/packages/compiler/src/backend/llvm/alloc.ts
@@ -459,18 +459,72 @@ export type FreeObservers = "weak" | "weak-or-source";
* type's disposals), and the block — `scr_cyc_free` for collector-headered
* objects, `scr_rt_free` otherwise. Labels use the `sf.` prefix; the lines
* end in an open block.
+ *
+ * With `keep` (an alloca of ptr holding NULL; inline allocation paths only),
+ * a block that would go onto the calling thread's free list is stored there
+ * instead, for a teardown segment (emitSegmentFree); a block the system or
+ * another arena owns is still freed.
*/
export function emitObjectFree(
host: AllocHost,
cyc: boolean,
observers: FreeObservers = "weak",
+ keep: string | null = null,
): string[] {
- const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers));
+ const lines = threadLocalState(host, "sf", freeLines(host, cyc, observers, keep));
if (host.objectAudit === false) return lines.filter((line) => line !== FREE_NOTE);
host.declare(`declare void @scr_obj_free_note()`);
return lines;
}
+/** Lines that put the blocks `first` to `last` (linked through their first
+ * words; one block when they are equal) of the class at reservation offset
+ * `off` on the calling thread's free list. */
+function freeListPushLines(prefix: string, off: string, first: string, last: string): string[] {
+ return [
+ ` %${prefix}.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
+ ` %${prefix}.sh32 = load i32, ptr %${prefix}.shp`,
+ ` %${prefix}.sh = zext i32 %${prefix}.sh32 to i64`,
+ ` %${prefix}.c = lshr i64 ${off}, %${prefix}.sh`,
+ ` %${prefix}.c8 = shl i64 %${prefix}.c, 3`,
+ ` %${prefix}.fo = add i64 %${prefix}.c8, ${SA_FREE}`,
+ ` %${prefix}.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %${prefix}.fo`,
+ ` %${prefix}.head = load ptr, ptr %${prefix}.fp`,
+ ` store ptr %${prefix}.head, ptr ${last}`,
+ ` store ptr ${first}, ptr %${prefix}.fp`,
+ ];
+}
+
+/**
+ * Lines that free a teardown segment: the blocks `first` to `last` (ptr
+ * values, both NULL for an empty segment), linked through their first words,
+ * which emitObjectFree kept. Every block of a segment is in the calling
+ * thread's arena and of one size class, so the segment goes onto that
+ * class's free list in one step, and the next allocations of the class take
+ * its blocks in segment order. Labels use `prefix`; the lines end in the
+ * open block `${prefix}.done`.
+ */
+export function emitSegmentFree(
+ host: AllocHost,
+ prefix: string,
+ first: string,
+ last: string,
+): string[] {
+ declareState(host);
+ const lines = [
+ ` %${prefix}.none = icmp eq ptr ${first}, null`,
+ ` br i1 %${prefix}.none, label %${prefix}.done, label %${prefix}.push`,
+ `${prefix}.push:`,
+ ` %${prefix}.base = load i64, ptr @scr_sa`,
+ ` %${prefix}.bi = ptrtoint ptr ${first} to i64`,
+ ` %${prefix}.off = sub i64 %${prefix}.bi, %${prefix}.base`,
+ ...freeListPushLines(prefix, `%${prefix}.off`, first, last),
+ ` br label %${prefix}.done`,
+ `${prefix}.done:`,
+ ];
+ return threadLocalState(host, prefix, lines);
+}
+
/** Lines that leave in `%.obs` whether the program can observe a
* disposal of this type (folds to a constant). */
function observedLines(prefix: string, observers: FreeObservers): string[] {
@@ -482,8 +536,14 @@ function observedLines(prefix: string, observers: FreeObservers): string[] {
];
}
-function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): string[] {
+function freeLines(
+ host: AllocHost,
+ cyc: boolean,
+ observers: FreeObservers,
+ keep: string | null,
+): string[] {
if (host.inlineAlloc !== true) {
+ if (keep !== null) throw new Error("a kept block needs the inline allocation paths");
if (cyc) {
host.declare(`declare void @scr_cyc_free(ptr)`);
return [` call void @scr_obj_free_note()`, ` call void @scr_cyc_free(ptr %o)`];
@@ -548,16 +608,9 @@ function freeLines(host: AllocHost, cyc: boolean, observers: FreeObservers): str
` %sf.inx = call i1 @llvm.expect.i1(i1 %sf.in, i1 true)`,
` br i1 %sf.inx, label %sf.push, label %sf.sys`,
`sf.push:`,
- ` %sf.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
- ` %sf.sh32 = load i32, ptr %sf.shp`,
- ` %sf.sh = zext i32 %sf.sh32 to i64`,
- ` %sf.c = lshr i64 %sf.off, %sf.sh`,
- ` %sf.c8 = shl i64 %sf.c, 3`,
- ` %sf.fo = add i64 %sf.c8, ${SA_FREE}`,
- ` %sf.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %sf.fo`,
- ` %sf.head = load ptr, ptr %sf.fp`,
- ` store ptr %sf.head, ptr ${blk}`,
- ` store ptr ${blk}, ptr %sf.fp`,
+ ...(keep !== null
+ ? [` store ptr ${blk}, ptr ${keep}`]
+ : freeListPushLines("sf", "%sf.off", blk, blk)),
` br label %sf.done`,
`sf.sys: ; outside the reservation, or the allocator is compiled out`,
` call void @scr_obj_free_note()`,
diff --git a/packages/compiler/src/backend/llvm/bytes-alias.ts b/packages/compiler/src/backend/llvm/bytes-alias.ts
index 9aaeaa6c7..d43620fd9 100644
--- a/packages/compiler/src/backend/llvm/bytes-alias.ts
+++ b/packages/compiler/src/backend/llvm/bytes-alias.ts
@@ -7,9 +7,15 @@ import type { LlvmEmitterContext } from "./expr-context.js";
* buffer. Neither overlaps the other, a native field, array storage or a
* module global's slot, so element stores leave header loads and global
* receiver slots loop-invariant. Untagged accesses and calls still alias
- * every category. */
+ * every category.
+ *
+ * Header loads are also `!invariant.load`: no store or call between the
+ * construction of a view and its release changes them. Without it, a data
+ * pointer load ahead of a bounds branch is sunk into the in-bounds block,
+ * and LICM keeps a conditionally executed load inside the loop. */
export function bytesHeaderAlias(host: LlvmEmitterContext): string {
- return host.memoryAliasAttachment("bytes:header");
+ const tag = host.memoryAliasAttachment("bytes:header");
+ return tag === "" ? "" : `${tag}, !invariant.load !{}`;
}
export function bytesElementAlias(host: LlvmEmitterContext): string {
diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
index cee91c66d..de71a6ef0 100644
--- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
+++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts
@@ -11,7 +11,7 @@ import {
type IrType,
} from "../../ir/ir.js";
import { analyzeIntegerRanges } from "../../ir/integer-ranges.js";
-import { matchIntegerCountedForLoop } from "../../ir/integer-loops.js";
+import { matchIntegerCountedForLoop, matchIntegerPairedForLoop } from "../../ir/integer-loops.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion, type BytesLoopVersion } from "./bytes-loop-versions.js";
import { emitLlvmModule } from "./emitter.js";
@@ -249,6 +249,11 @@ test("emitted loops version the body and tag typed-array memory", () => {
const elements = /store double [^\n]*!tbaa !(\d+)/.exec(body)![1];
const header = /load ptr, ptr [^\n]*!tbaa !(\d+)/.exec(body)![1];
expect(elements).not.toBe(header);
+ // Header loads are invariant for the view's lifetime; element accesses
+ // are not.
+ expect(body).toMatch(/load ptr, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
+ expect(body).toMatch(/load i64, ptr [^\n]*, !tbaa !\d+, !invariant\.load !\{\}$/m);
+ expect(body).not.toMatch(/(load double|store double)[^\n]*invariant\.load/);
});
test("loops to a typed array's length version the other typed arrays they index", () => {
@@ -618,3 +623,78 @@ test("while loop counters below a string's length version its reads", () => {
expect(version.bounded.map((c) => c.slack)).toEqual([0]);
expect(version.fixed).toEqual([]);
});
+
+test("a two-counter loop versions the typed arrays it indexes at either counter", () => {
+ // for (let i = 0, j = k; i < j; i++, j--) { const t = out[i]; out[i] = out[j]; out[j] = t; }
+ const loop: IrStmt & { kind: "for" } = {
+ kind: "for",
+ init: {
+ kind: "block",
+ body: [
+ { kind: "varDecl", localId: "i", init: num(0), loc },
+ { kind: "varDecl", localId: "j", init: ref("k"), loc },
+ ],
+ loc,
+ },
+ cond: { kind: "bin", op: "<", left: ref("i"), right: ref("j"), type: BOOL, loc },
+ update: {
+ kind: "block",
+ body: [
+ { kind: "assign", localId: "i", value: bin("+", ref("i"), num(1)), loc },
+ { kind: "assign", localId: "j", value: bin("-", ref("j"), num(1)), loc },
+ ],
+ loc,
+ },
+ body: [
+ { kind: "varDecl", localId: "t", init: get(ref("out", f64s), ref("i")), loc },
+ set(ref("out", f64s), ref("i"), get(ref("out", f64s), ref("j"))),
+ set(ref("out", f64s), ref("j"), ref("t")),
+ // Another index stays checked.
+ set(ref("src", f64s), bin("+", ref("i"), num(1)), num(0)),
+ ],
+ loc,
+ };
+ const kernel = fn([loop], [local("t", F64)]);
+ const ranges = analyzeIntegerRanges(kernel);
+ const locals = new Map(kernel.locals.map((l) => [l.id, l]));
+ const paired = matchIntegerPairedForLoop(loop, locals, ranges)!;
+ expect(paired).not.toBeNull();
+ const version = matchBytesLoopVersion(
+ loop,
+ null,
+ {
+ locals,
+ invariantGlobals: new Set(),
+ captured: new Set(),
+ ranges,
+ initialized: findInitializedByteLoopBindings(kernel).get(loop) ?? new Set(),
+ proven: new Set(),
+ workers: false,
+ owned: new Set(),
+ scalarStrings: new Set(),
+ },
+ paired,
+ )!;
+ expect(version.paired?.loop).toBe(paired);
+ expect(version.paired?.receivers.map((r) => version.receivers[r]!.ref)).toEqual([
+ ref("out", f64s),
+ ]);
+ expect(version.bounds.size).toBe(4);
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "__main",
+ functions: [
+ { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
+ kernel,
+ ],
+ };
+ const body = /^define internal [^\n]*@sc_bf_kernel[^]*?^}/m.exec(emitLlvmModule(mod))![0];
+ const guard = body.slice(0, body.search(/^bytes\.version\.(fast|slow)\d+:$/m));
+ expect(guard).toMatch(/icmp sge i64 %\S+, 0/);
+ expect(guard).toMatch(/icmp slt i64 %\S+, %\S+/);
+ const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(body)![0];
+ // The fast copy keeps only the `src[i + 1]` check, and no second guard.
+ expect(fast.match(/bytes\.index\.invalid\d+:/g)).toHaveLength(1);
+ expect(fast).not.toContain("counted.fast");
+});
diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
index 704b6723a..2013d03e4 100644
--- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
+++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts
@@ -1,11 +1,15 @@
import { F64, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
import type { IntegerRange, IntegerRanges } from "../../ir/integer-ranges.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { exactInteger, widenInteger } from "./integer-values.js";
-import { emitCountedLoopLimit, emitCountedLoopSafety } from "./counted-loops.js";
+import {
+ emitCountedLoopLimit,
+ emitCountedLoopSafety,
+ emitExactIntegerTest,
+} from "./counted-loops.js";
import { emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import { f64Lit } from "./common.js";
import { emitStringAsciiFlag, emitStringByteLength } from "./string-reads.js";
@@ -79,6 +83,10 @@ export interface BytesLoopVersion {
guarded: ReadonlySet;
/** The counted loop whose iteration space the affine checks cover. */
counted: IntegerCountedForLoop | null;
+ /** A two-counter loop (`for (let i = lo, j = hi; i < j; i++, j--)`) and
+ * the receivers it indexes at either counter. Both counters stay between
+ * the two starts, so the guard requires `lo >= 0` and `hi < length`. */
+ paired: { loop: IntegerPairedForLoop; receivers: number[] } | null;
/** The guard also requires every counter value within [-bound, bound]. */
counterBound: number | null;
/** Integer ranges of affine index operands that hold in the unchecked
@@ -153,6 +161,7 @@ export function matchBytesLoopVersion(
loop: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
facts: BytesLoopFacts,
+ paired: IntegerPairedForLoop | null = null,
): BytesLoopVersion | null {
const written = new Set();
const temps = new Map();
@@ -438,6 +447,9 @@ export function matchBytesLoopVersion(
string,
{ check: BoundedCheck; nodes: { node: Node; access: WhileAccess }[] }
>();
+ const pairedCounters =
+ paired !== null && counted === null ? new Set([paired.low.localId, paired.high.localId]) : null;
+ const pairedNodes = new Map();
const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => {
const ref = root(array);
if (!ref) return;
@@ -448,6 +460,20 @@ export function matchBytesLoopVersion(
else owned.set(receiver, [node]);
}
if (facts.proven.has(node)) return;
+ // An element at either counter of a two-counter loop.
+ if (
+ pairedCounters !== null &&
+ width === 1 &&
+ ref.type.kind === "bytes" &&
+ index.kind === "varRef" &&
+ pairedCounters.has(index.localId)
+ ) {
+ const receiver = receiverOf(ref);
+ const nodes = pairedNodes.get(receiver);
+ if (nodes) nodes.push(node);
+ else pairedNodes.set(receiver, [node]);
+ return;
+ }
const shape = counter !== null ? affine(index) : null;
if (shape && shape.counter !== 0) {
const operands = new Set();
@@ -543,11 +569,16 @@ export function matchBytesLoopVersion(
Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length),
);
const ownedKept = [...owned.entries()].slice(0, MAX_CHECKS);
+ const pairedKept = [...pairedNodes.entries()].slice(
+ 0,
+ Math.max(0, MAX_CHECKS - fixedKept.length - affineKept.length - boundedKept.length),
+ );
if (
fixedKept.length === 0 &&
affineKept.length === 0 &&
boundedKept.length === 0 &&
- ownedKept.length === 0
+ ownedKept.length === 0 &&
+ pairedKept.length === 0
)
return null;
const bounds = new Set();
@@ -616,6 +647,10 @@ export function matchBytesLoopVersion(
if (access.index !== access.ref) narrow(access.index, { min: 0, max: SAFE });
}
}
+ for (const [receiver, nodes] of pairedKept) {
+ used.add(receiver);
+ for (const node of nodes) bounds.add(node);
+ }
const counterChecks: CounterCheck[] = [];
for (const [id, min] of counterMins) {
const info = whileAccesses!.counters.get(id)!;
@@ -658,6 +693,10 @@ export function matchBytesLoopVersion(
counters: counterChecks,
guarded,
counted: affineKept.length > 0 ? counted : null,
+ paired:
+ paired !== null && pairedKept.length > 0
+ ? { loop: paired, receivers: pairedKept.map(([receiver]) => renumber.get(receiver)!) }
+ : null,
counterBound: needsBound ? bound : null,
ranges,
};
@@ -1143,6 +1182,25 @@ export function emitBytesLoopVersionGuard(
conditions.push(both);
}
}
+ if (version.paired !== null) {
+ // Both counters stay within [lo, hi] (exact integers below 2^53): every
+ // access at either one is in bounds when `lo >= 0` and `hi < length`.
+ const { loop } = version.paired;
+ const [low, high] = [loop.low.start, loop.high.start].map((expr) => {
+ const tested = emitExactIntegerTest(host, host.emitExpr(expr).name, -SAFE, SAFE);
+ conditions.push(tested.ok);
+ return tested.integer;
+ });
+ const nonNegative = B.tmp();
+ B.line(`${nonNegative} = icmp sge i64 ${low}, 0`);
+ conditions.push(nonNegative);
+ for (const receiver of version.paired.receivers) {
+ // Lengths are at most 2^53 elements: a signed comparison is exact.
+ const below = B.tmp();
+ B.line(`${below} = icmp slt i64 ${high}, ${lengths[receiver]}`);
+ conditions.push(below);
+ }
+ }
let valid = "true";
for (const cond of conditions) {
if (valid === "true") valid = cond;
@@ -1156,39 +1214,6 @@ export function emitBytesLoopVersionGuard(
else B.condBr(valid, fast, slow);
}
-/** Tests that a double is an exact integer within [min, max] (not -0; NaN
- * fails the ordered comparisons) and gives its i64. The integer is frozen,
- * so it is a fixed value even when the test fails. */
-function emitExactIntegerTest(
- host: LlvmEmitterContext,
- value: string,
- min: number,
- max: number,
-): { ok: string; integer: string } {
- const B = host.B;
- const low = B.tmp(),
- high = B.tmp(),
- inRange = B.tmp(),
- raw = B.tmp(),
- integer = B.tmp(),
- back = B.tmp(),
- bits = B.tmp(),
- backBits = B.tmp(),
- exact = B.tmp(),
- ok = B.tmp();
- B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
- B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
- B.line(`${inRange} = and i1 ${low}, ${high}`);
- B.line(`${raw} = fptosi double ${value} to i64`);
- B.line(`${integer} = freeze i64 ${raw}`);
- B.line(`${back} = sitofp i64 ${integer} to double`);
- B.line(`${bits} = bitcast double ${value} to i64`);
- B.line(`${backBits} = bitcast double ${back} to i64`);
- B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
- B.line(`${ok} = and i1 ${inRange}, ${exact}`);
- return { ok, integer };
-}
-
function scaleCounter(host: LlvmEmitterContext, value: string, scale: number): string {
if (scale === 1) return value;
const scaled = host.B.tmp();
diff --git a/packages/compiler/src/backend/llvm/classes.test.ts b/packages/compiler/src/backend/llvm/classes.test.ts
index bee954afb..f472eec60 100644
--- a/packages/compiler/src/backend/llvm/classes.test.ts
+++ b/packages/compiler/src/backend/llvm/classes.test.ts
@@ -77,6 +77,65 @@ test("leaf layouts keep direct teardown while recursive layouts share the depth
}
});
+test("teardowns of types with children of their own type free their blocks as one segment", () => {
+ const linked = { kind: "object" as const, className: "Linked" };
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "main",
+ functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
+ classes: [
+ {
+ name: "Linked",
+ fields: [
+ { name: "next", type: linked },
+ { name: "other", type: linked },
+ ],
+ methods: [],
+ loc,
+ },
+ ],
+ records: [{ id: "branch", fields: [{ name: "next", type: linked }] }],
+ };
+ const define = (llvm: string, name: string): string =>
+ new RegExp(`^define internal [^@]*@${name}\\([^]*?^}`, "m").exec(llvm)?.[0] ?? "";
+ const linkedDestroy = `${mangleClassRelease("Linked")}_destroy`;
+ const branchDestroy = `${mangleRecordRelease("branch")}_destroy`;
+ for (const workers of [false, true]) {
+ const llvm = emitLlvmModule({ ...mod, workers }, { pointerBits: 64 });
+ // Linked's teardown keeps its block and its children's in a segment: it
+ // releases its own block first and places it by address against its
+ // first child, a child torn down directly appends to the segment, one
+ // past the direct chain appends through the runtime, and the chain's
+ // first teardown frees the segment onto the free list in one step.
+ const seg = define(llvm, `${linkedDestroy}_s`);
+ expect(seg.startsWith("define internal ptr"), "segment form").toBe(true);
+ expect(seg).toMatch(
+ /store ptr %(?:sf\.blk|o), ptr %sg\.own[^]*call ptr @sc_release_Linked_destroy_s\(/,
+ );
+ // The children's releases have a copy for each place of the own block.
+ for (const copy of ["ta", "tb"])
+ expect(seg).toContain(
+ `call ptr @scr_rc_destroy_seg(ptr %v0, ptr @${linkedDestroy}_sc, ptr @${linkedDestroy}, ptr %${copy}0.bt)`,
+ );
+ expect(seg).not.toContain("call void @scr_rc_destroy(");
+ expect(seg).toContain("icmp ult ptr %sg.o, %sg.r1");
+ const first = define(llvm, `${linkedDestroy}_d`);
+ expect(first).toContain(`call ptr @${linkedDestroy}_s(ptr %o, i32 %d, ptr %head)`);
+ expect(first).toContain("store ptr %first, ptr %sgf.fp");
+ expect(first.includes("llvm.threadlocal.address"), "thread-local state").toBe(workers);
+ expect(define(llvm, linkedDestroy)).toContain(`call void @${linkedDestroy}_d(ptr %o, i32 0)`);
+ // A record whose child is another type frees its own block as before.
+ expect(define(llvm, `${branchDestroy}_s`)).toBe("");
+ expect(define(llvm, branchDestroy)).toContain(`call void @${mangleClassRelease("Linked")}(`);
+ }
+ // Without the inline allocation paths (32-bit targets) a teardown frees
+ // through the runtime, block by block.
+ const llvm32 = emitLlvmModule(mod, { pointerBits: 32 });
+ expect(define(llvm32, `${linkedDestroy}_s`)).toBe("");
+ expect(define(llvm32, `${linkedDestroy}_d`)).toContain(`call void @${linkedDestroy}_d(`);
+});
+
test("releases split into an inlinable fast path and an out-of-line remainder", () => {
// An array of the class itself makes the hierarchy cycle-capable.
const kids = { kind: "array" as const, elem: { kind: "object" as const, className: "Node" } };
diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts
index 8f179828d..82e34de90 100644
--- a/packages/compiler/src/backend/llvm/classes.ts
+++ b/packages/compiler/src/backend/llvm/classes.ts
@@ -43,6 +43,9 @@ import {
destroyDefinitions,
directChildReleaseLines,
directDestroyTarget,
+ segmentPrologueLines,
+ segmentTeardownLines,
+ segmentedTeardown,
usedDirectTargets,
type DirectDestroy,
type RegionHooks,
@@ -1082,25 +1085,62 @@ export function emitClassShapes(
bounded &&
(directTargets.has(`object:${cls.name}`) ||
refFields.some((f) => directDestroyTarget(host, f.type, directTargets) !== null));
+ // The teardown's own symbol, and whether it keeps a segment (shapes.ts
+ // segmentedTeardown).
+ const destroy = `${meta.hierarchy ? mangleClassReleaseDirect(cls.name) : mangleClassRelease(cls.name)}_destroy`;
+ const segmented =
+ chained &&
+ !meta.compact &&
+ runtimePrefix === null &&
+ !isStreamRooted &&
+ !meta.threadSlot &&
+ segmentedTeardown(
+ host,
+ destroy,
+ refFields.map((f) => f.type),
+ directTargets,
+ );
// The field-releasing teardown body shared by both release shapes
// (the public one on standalone classes, the DIRECT one on hierarchy
// members). Runs at rc == 0.
const teardown = (lines: string[]): void => {
- refFields.forEach((f, t) => {
- lines.push(
- ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
- ` %v${t} = load ptr, ptr %f${t}`,
- );
+ const release = (prefix: string, f: (typeof refFields)[number], t: number): string[] => {
const direct = bounded ? directDestroyTarget(host, f.type, directTargets) : null;
- if (direct)
- lines.push(
- ...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(f.name)),
- );
- else
- lines.push(
+ if (direct === null)
+ return [
` call void ${releaseSym(host, f.type)}(ptr %v${t}) ; ${llvmCommentText(f.name)}`,
- );
- });
+ ];
+ const own = segmented && direct.destroy === destroy;
+ return directChildReleaseLines(
+ host,
+ `${prefix}${t}`,
+ `%v${t}`,
+ direct,
+ llvmCommentText(f.name),
+ own,
+ );
+ };
+ const loads = refFields.map((f, t) => [
+ ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`,
+ ` %v${t} = load ptr, ptr %f${t}`,
+ ]);
+ if (segmented) {
+ // A segmented teardown loads its fields, releases its own block, and
+ // then releases the fields (shapes.ts segmentTeardownLines).
+ const ownType = refFields.flatMap((f, t) =>
+ directDestroyTarget(host, f.type, directTargets)?.destroy === destroy ? [`%v${t}`] : [],
+ );
+ lines.push(
+ ...segmentPrologueLines(),
+ ...loads.flat(),
+ ...emitObjectFree(host, traced, "weak", "%sg.own"),
+ ...segmentTeardownLines(ownType, (prefix) =>
+ refFields.flatMap((f, t) => release(prefix, f, t)),
+ ),
+ );
+ return;
+ }
+ refFields.forEach((f, t) => lines.push(...loads[t]!, ...release("td", f, t)));
if (runtimePrefix) lines.push(...regCall("td", runtimePrefix.drop, ""));
if (isStreamRooted) lines.push(...stCall("tds", "scr_stream_st_release", ""));
if (meta.threadSlot) lines.push(...slotForgetLines(host, struct, meta, "tdw"));
@@ -1182,10 +1222,12 @@ export function emitClassShapes(
];
if (traced) reld.push(...cycleDeadLines(host, "teardown"));
if (bounded) {
- const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`;
const destroyBody: string[] = [];
teardown(destroyBody);
- defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``);
+ defs.push(
+ ...destroyDefinitions(destroy, destroyBody, chained, segmented ? host : null),
+ ``,
+ );
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`);
} else teardown(reld);
@@ -1207,6 +1249,7 @@ export function emitClassShapes(
bounded,
lifetime,
chained,
+ segmented,
),
``,
);
diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts
index ac4e28708..cca1b603b 100644
--- a/packages/compiler/src/backend/llvm/common.ts
+++ b/packages/compiler/src/backend/llvm/common.ts
@@ -29,6 +29,9 @@ export const SCR_ARR_FIELD = {
* (SCR_ARR_STATES in scr_array.h). */
export const SCR_ARR_STATES = 16;
+/** The `spare` flag of a frozen %ScrArr (SCR_ARR_FROZEN in scr_array.h). */
+export const SCR_ARR_FROZEN = 32;
+
/** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a
* size-type count. */
export function loadArrCount(
diff --git a/packages/compiler/src/backend/llvm/counted-loops.ts b/packages/compiler/src/backend/llvm/counted-loops.ts
index ca2dd6b92..1ff4c6580 100644
--- a/packages/compiler/src/backend/llvm/counted-loops.ts
+++ b/packages/compiler/src/backend/llvm/counted-loops.ts
@@ -151,3 +151,36 @@ function emitExactStart(host: LlvmEmitterContext, expr: IrExpr): string {
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return ok;
}
+
+/** Tests that a double is an exact integer within [min, max] (not -0; NaN
+ * fails the ordered comparisons) and gives its i64. The integer is frozen,
+ * so it is a fixed value even when the test fails. */
+export function emitExactIntegerTest(
+ host: LlvmEmitterContext,
+ value: string,
+ min: number,
+ max: number,
+): { ok: string; integer: string } {
+ const B = host.B;
+ const low = B.tmp(),
+ high = B.tmp(),
+ inRange = B.tmp(),
+ raw = B.tmp(),
+ integer = B.tmp(),
+ back = B.tmp(),
+ bits = B.tmp(),
+ backBits = B.tmp(),
+ exact = B.tmp(),
+ ok = B.tmp();
+ B.line(`${low} = fcmp oge double ${value}, ${f64Lit(min)}`);
+ B.line(`${high} = fcmp ole double ${value}, ${f64Lit(max)}`);
+ B.line(`${inRange} = and i1 ${low}, ${high}`);
+ B.line(`${raw} = fptosi double ${value} to i64`);
+ B.line(`${integer} = freeze i64 ${raw}`);
+ B.line(`${back} = sitofp i64 ${integer} to double`);
+ B.line(`${bits} = bitcast double ${value} to i64`);
+ B.line(`${backBits} = bitcast double ${back} to i64`);
+ B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
+ B.line(`${ok} = and i1 ${inRange}, ${exact}`);
+ return { ok, integer };
+}
diff --git a/packages/compiler/src/backend/llvm/debug-info.ts b/packages/compiler/src/backend/llvm/debug-info.ts
index 4e37ee5bc..3a75c6263 100644
--- a/packages/compiler/src/backend/llvm/debug-info.ts
+++ b/packages/compiler/src/backend/llvm/debug-info.ts
@@ -139,7 +139,6 @@ export class LlvmDebugInfo {
let id: string;
switch (type.kind) {
case "f64":
- case "date":
case "procStream":
id = this.add(
`!DIBasicType(name: ${quoted(type.kind === "f64" ? "number" : type.kind)}, size: 64, encoding: DW_ATE_float)`,
@@ -190,7 +189,7 @@ export class LlvmDebugInfo {
? []
: [
this.add(
- `!DIDerivedType(tag: DW_TAG_member, name: "arm${i}", baseType: ${this.type(arm)}, size: ${arm.kind === "bool" ? 8 : arm.kind === "f64" || arm.kind === "date" ? 64 : this.pointerBits})`,
+ `!DIDerivedType(tag: DW_TAG_member, name: "arm${i}", baseType: ${this.type(arm)}, size: ${arm.kind === "bool" ? 8 : arm.kind === "f64" ? 64 : this.pointerBits})`,
),
],
);
@@ -239,9 +238,7 @@ export class LlvmDebugInfo {
// Scalar forward captures use an array-backed TDZ box. Expose that
// exceptional representation opaquely rather than reporting a bad value.
const opaqueBox =
- local.boxed &&
- local.tdz &&
- (local.type.kind === "f64" || local.type.kind === "bool" || local.type.kind === "date");
+ local.boxed && local.tdz && (local.type.kind === "f64" || local.type.kind === "bool");
const type = opaqueBox
? this.pointer(
this.add(
diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts
index 95612e57c..df37d9c58 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.ts
@@ -505,8 +505,12 @@ export function emitDenseArrayState(
indexExpr: IrExpr | undefined,
kind: "state" | "has",
width: number,
+ /** The index is a dense array loop counter its entry test proved below
+ * the hole-free prefix (provenDenseIndex): the slot is a present value. */
+ proven = false,
): string {
const B = host.B;
+ if (proven) return kind === "state" ? f64Lit(SCR_ARR_VALUE) : "true";
const fn = kind === "state" ? "scr_arr_state" : "scr_arr_has";
const ty = kind === "state" ? "double" : "i1";
host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`);
diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
index f7a4bc892..a88c38575 100644
--- a/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-loops.test.ts
@@ -1,9 +1,10 @@
import { expect, test } from "vitest";
import { BOOL, F64, arrayOf, type IrExpr, type IrLocal, type IrStmt } from "../../ir/ir.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
countedDenseShape,
matchDenseArrayLoop,
+ pairedDenseShape,
type DenseLoopShape,
} from "./dense-array-loops.js";
@@ -42,6 +43,8 @@ const locals = new Map([
["a", { id: "a", name: "a", type: ARR, mutable: false }],
["b", { id: "b", name: "b", type: ARR, mutable: true }],
["c", { id: "c", name: "c", type: ARR, mutable: false }],
+ ["j", { id: "j", name: "j", type: F64, mutable: true }],
+ ["t", { id: "t", name: "t", type: F64, mutable: false }],
]);
/** for (let i = 0; i < a.length; i++) body */
@@ -193,3 +196,100 @@ test("a loop to another invariant limit covers each array whose length it keeps"
expect([...matchLimited(resizing, new Set(["b"]))!.arrays.keys()]).toEqual(["a"]);
expect(matchLimited(resizing)).toBeNull();
});
+
+test("a two-counter loop covers the arrays indexed at either counter", () => {
+ // for (let i = 1, j = k - 1; i < j; i++, j--) { t = a[i]; a[i] = a[j]; a[j] = t; }
+ const paired: IntegerPairedForLoop = {
+ low: { localId: "i", start: num(1), step: 1, startGuarded: false },
+ high: { localId: "j", start: ref("k"), step: -1, startGuarded: true },
+ inclusive: false,
+ guarded: true,
+ range: { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
+ };
+ const pairedShape = pairedDenseShape(paired);
+ const matchPaired = (body: IrStmt[]) =>
+ matchDenseArrayLoop(loop(body), pairedShape, locals, new Set(), new Set(["a", "b"]));
+ const swap: IrStmt[] = [
+ { kind: "varDecl", localId: "t", init: read("a"), loc },
+ write("a", read("a", ref("j"))),
+ write("a", ref("t"), ref("j")),
+ ];
+ const dense = matchPaired(swap);
+ expect([...dense!.arrays.keys()]).toEqual(["a"]);
+ expect(dense!.pair).toEqual({ high: "j", lowStart: num(1), highStart: ref("k") });
+ expect(dense!.range).toEqual({ min: 0, max: 2147483646 });
+ // Stores at any other index may extend the array.
+ expect(matchPaired([...swap, write("a", num(0), ref("k"))])).toBeNull();
+});
+
+test("a present-undefined store is covered only where it stores a covered read's missing arm", () => {
+ // const v = b[i] (as number | undefined);
+ // if (v is undefined) c[i] = undefined; else c[i] = v is number ? v : throw;
+ const unionType = { kind: "union" as const, unionId: "u" };
+ const v: IrExpr = { kind: "varRef", localId: "v", type: unionType, loc };
+ const isTag = (tag: number): IrExpr => ({
+ kind: "unionIsTag",
+ unionId: "u",
+ tag,
+ negated: false,
+ value: v,
+ type: BOOL,
+ loc,
+ });
+ const reader = (array: string, index = ref("i")): IrExpr => ({
+ kind: "call",
+ callee: "%arr.idxOr.f64",
+ args: [arr(array), index],
+ type: unionType,
+ loc,
+ });
+ const undefinedStore: IrStmt = { kind: "arraySetUndefined", arr: arr("c"), index: ref("i"), loc };
+ const guarded = (array: string): IrStmt[] => [
+ { kind: "varDecl", localId: "v", init: reader(array), loc },
+ {
+ kind: "if",
+ cond: isTag(1),
+ then: [undefinedStore],
+ else_: [
+ write("c", {
+ kind: "ternary",
+ cond: isTag(0),
+ then: { kind: "unionNarrow", unionId: "u", tag: 0, value: v, type: F64, loc },
+ else_: {
+ kind: "libCall",
+ fn: "error.nodeThrow",
+ args: [num(1), { kind: "strLit", value: "", type: { kind: "string" }, loc }],
+ type: F64,
+ loc,
+ },
+ type: F64,
+ loc,
+ }),
+ ],
+ loc,
+ },
+ ];
+ const withV = new Map([
+ ...locals,
+ ["v", { id: "v", name: "v", type: unionType, mutable: false }],
+ ]);
+ const matchStores = (body: IrStmt[], initialized: string[]) =>
+ matchDenseArrayLoop(loop(body), shape, withV, new Set(), new Set(initialized));
+ const dense = matchStores(guarded("b"), ["a", "b", "c"]);
+ expect([...dense!.arrays.keys()].sort()).toEqual(["b", "c"]);
+ // A read of an array the entry test does not cover may miss in the fast
+ // copy, and its undefined store would run there.
+ expect(matchStores(guarded("b"), ["a", "c"])).toBeNull();
+ // A present undefined stored on its own leaves out its array and every
+ // array the other name may hold.
+ const alone: IrStmt[] = [{ kind: "exprStmt", expr: read("b"), loc }, undefinedStore];
+ expect(matchStores(alone, ["a", "b", "c"])).toBeNull();
+ const separate = matchDenseArrayLoop(
+ loop(alone),
+ shape,
+ withV,
+ new Set(["c"]),
+ new Set(["a", "b", "c"]),
+ );
+ expect([...separate!.arrays.keys()]).toEqual(["b"]);
+});
diff --git a/packages/compiler/src/backend/llvm/dense-array-loops.ts b/packages/compiler/src/backend/llvm/dense-array-loops.ts
index 184ff1353..b806939b5 100644
--- a/packages/compiler/src/backend/llvm/dense-array-loops.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-loops.ts
@@ -20,16 +20,23 @@
* A counted loop with another invariant limit, such as `j < n` after
* `const n = a.length` or `j < 5`, is covered the same way: the test bounds
* the loop's exclusive limit by 2^31 - 1 and by each indexed array's
- * `filled`, and the loop must provably never change any of their lengths. */
+ * `filled`, and the loop must provably never change any of their lengths.
+ *
+ * A two-counter loop closing in on itself, such as
+ * `for (let i = 1, j = k - 1; i < j; i++, j--)`, indexes only between its
+ * low counter's start and its high counter's: the low counter only rises
+ * and stays below the high one, which only falls. Its test bounds the low
+ * start below by 0 and the high start by 2^31 - 2 and below each indexed
+ * array's `filled`, both starts being exact integers. */
import type { IrExpr, IrLocal, IrStmt } from "../../ir/ir.js";
import type { IntegerRange } from "../../ir/integer-ranges.js";
import { preservesArrayLength } from "../../ir/array-length-stability.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { loadArrCount, SCR_ARR_FIELD } from "./common.js";
-import { emitCountedLoopLimit } from "./counted-loops.js";
+import { emitCountedLoopLimit, emitExactIntegerTest } from "./counted-loops.js";
export const INT32_MAX = 2147483647;
@@ -47,6 +54,17 @@ export interface DenseArrayLoop {
arrays: ReadonlyMap;
/** The counter's range on every pass of the fast copy. */
range: IntegerRange;
+ /** A two-counter loop's high counter and both starts, when the counter
+ * is its low one (`length` and `limit` are then null). */
+ pair: DensePair | null;
+}
+
+/** The high counter of a two-counter dense loop, and the starts its entry
+ * test bounds. */
+export interface DensePair {
+ high: string;
+ lowStart: IrExpr;
+ highStart: IrExpr;
}
/** The fast copy duplicates the loop; keep the duplicated code small. */
@@ -62,6 +80,8 @@ export interface DenseLoopShape {
/** The counter's proven range on every pass of the ordinary loop; the
* fast copy keeps its minimum, which must be at least 0. */
range: IntegerRange;
+ /** A two-counter loop's high counter and starts. */
+ pair?: DensePair;
}
/** `length.length - c` (c an integer literal, 0 when absent). */
@@ -107,6 +127,124 @@ export function countedDenseShape(
return { counter: counted.localId, length: start.receiver, range: counted.range };
}
+/** The shape of a two-counter loop whose low counter rises and high
+ * counter falls: both stay between the two starts, which the entry test
+ * bounds, so the fast copy's counters lie in [0, 2^31 - 2]. */
+export function pairedDenseShape(loop: IntegerPairedForLoop): DenseLoopShape {
+ return {
+ counter: loop.low.localId,
+ length: null,
+ range: { min: 0, max: INT32_MAX - 1 },
+ pair: { high: loop.high.localId, lowStart: loop.low.start, highStart: loop.high.start },
+ };
+}
+
+interface ElementRead {
+ arr: IrExpr;
+ index: IrExpr;
+}
+
+/** The array and index of a call of an element-reader helper
+ * (`%arr.idxOr.*`, ir/int32-arrays.ts isElementReader): `a[i]` as
+ * `number | undefined`. */
+function elementReaderCall(e: IrExpr): ElementRead | null {
+ if (e.kind !== "call" || !e.callee.startsWith("%arr.idxOr.") || e.args.length !== 2) return null;
+ return { arr: e.args[0]!, index: e.args[1]! };
+}
+
+/** The array and index of an element read as `T | undefined`: an
+ * element-reader call, or its body inlined for an int32 array
+ * (`arrayState(a, i) === 1 ? wrap(a[i]) : wrap(undefined)`). */
+function elementRead(e: IrExpr): ElementRead | null {
+ const call = elementReaderCall(e);
+ if (call) return call;
+ if (e.kind !== "ternary") return null;
+ const cond = e.cond;
+ if (cond.kind !== "bin" || cond.op !== "===") return null;
+ const state = cond.left,
+ present = cond.right,
+ wrapped = e.then,
+ missing = e.else_;
+ if (state.kind !== "arrayState" || present.kind !== "numLit" || present.value !== 1) return null;
+ if (wrapped.kind !== "unionWrap" || missing.kind !== "unionWrap") return null;
+ const value = wrapped.value;
+ if (value.kind !== "arrayGet" || missing.value.kind !== "unitLit") return null;
+ const same = (x: IrExpr, y: IrExpr): boolean =>
+ x.kind === "varRef" && y.kind === "varRef" && x.localId === y.localId;
+ return same(state.arr, value.arr) && same(state.index, value.index)
+ ? { arr: state.arr, index: state.index }
+ : null;
+}
+
+/** The present-undefined stores of `a[i] = b[j]!` as lowering spells it,
+ * with the element read each one stores the missing arm of:
+ *
+ * const v = %arr.idxOr(b, j);
+ * if (v is undefined) a[i] = undefined; else a[i] = v is number ? v : throw;
+ *
+ * The read's local is declared once in the same list as the `if`, and only
+ * read after it. */
+function missingElementStores(body: IrStmt[]): Map {
+ const result = new Map();
+ const writes = new Map();
+ const count = (id: string): void => void writes.set(id, (writes.get(id) ?? 0) + 1);
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "assignExpr" || e.kind === "incDec") count(e.localId);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "varDecl" || s.kind === "assign") count(s.localId);
+ return everyStmtChild(s, expr, stmt);
+ };
+ body.every(stmt);
+ const list = (stmts: IrStmt[]): void => {
+ const reads = new Map();
+ for (const s of stmts) {
+ if (s.kind === "varDecl" && s.init && writes.get(s.localId) === 1) {
+ const read = elementRead(s.init);
+ if (read) reads.set(s.localId, read);
+ }
+ if (s.kind === "if") {
+ const store = missingArmStore(s, reads);
+ if (store) result.set(store.stmt, store.read);
+ }
+ // Nested loops keep a loop out of the fast copy (matchDenseArrayLoop),
+ // and other statement lists leave their stores unmatched.
+ if (s.kind === "block") list(s.body);
+ if (s.kind === "if") {
+ list(s.then);
+ if (s.else_) list(s.else_);
+ }
+ }
+ };
+ list(body);
+ return result;
+}
+
+/** The present-undefined store of `if (v is undefined) a[i] = undefined;
+ * else a[i] = v is number ? v : throw;`, with the read `v` holds. */
+function missingArmStore(
+ s: IrStmt & { kind: "if" },
+ reads: ReadonlyMap,
+): { stmt: IrStmt; read: ElementRead } | null {
+ const cond = s.cond;
+ if (cond.kind !== "unionIsTag" || cond.negated || cond.value.kind !== "varRef") return null;
+ const local = cond.value.localId;
+ const read = reads.get(local);
+ if (!read || s.then.length !== 1 || s.else_?.length !== 1) return null;
+ const store = s.then[0]!,
+ other = s.else_[0]!;
+ if (store.kind !== "arraySetUndefined" || other.kind !== "arraySet") return null;
+ const value = other.value;
+ if (value.kind !== "ternary") return null;
+ const test = value.cond,
+ narrowed = value.then;
+ if (test.kind !== "unionIsTag" || test.negated || test.tag === cond.tag) return null;
+ if (test.value.kind !== "varRef" || test.value.localId !== local) return null;
+ if (narrowed.kind !== "unionNarrow" || narrowed.tag !== test.tag) return null;
+ return narrowed.type.kind === "f64" ? { stmt: store, read } : null;
+}
+
/** Match the fast copy of a loop of the given shape. `initialized` holds the
* bindings definitely assigned when the loop starts; `unaliased` the local
* arrays no other binding holds. */
@@ -118,6 +256,8 @@ export function matchDenseArrayLoop(
initialized: ReadonlySet,
): DenseArrayLoop | null {
const { counter, length } = shape;
+ const high = shape.pair?.high ?? null;
+ const isCounter = (id: string): boolean => id === counter || id === high;
if (shape.range.min < 0 || !loop.cond) return null;
let nodes = 0,
nested = false;
@@ -126,13 +266,21 @@ export function matchDenseArrayLoop(
// element write.
const disturbed = new Set();
const access = (arr: IrExpr, index: IrExpr): void => {
- if (arr.kind === "varRef" && index.kind === "varRef" && index.localId === counter)
+ if (arr.kind === "varRef" && index.kind === "varRef" && isCounter(index.localId))
candidates.set(arr.localId, arr);
};
+ const missing = missingElementStores(loop.body);
+ // The element reads behind the guarded present-undefined stores.
+ const guards: ElementRead[] = [];
+ // The arrays of the other present-undefined stores. The fast copy runs
+ // them, and the slot each one writes leaves the hole-free prefix.
+ const undefinedStores: IrExpr[] = [];
const expr = (e: IrExpr): boolean => {
if (++nodes > MAX_LOOP_NODES) return false;
if (e.kind === "assignExpr" || e.kind === "incDec") disturbed.add(e.localId);
if (e.kind === "arrayGet") access(e.arr, e.index);
+ const reader = elementReaderCall(e);
+ if (reader) access(reader.arr, reader.index);
if (e.kind === "arrIntrinsic") {
if (e.method === "getNumber" && e.args[0]) access(e.receiver, e.args[0]);
else if (e.method !== "length" && e.receiver.kind === "varRef")
@@ -147,9 +295,21 @@ export function matchDenseArrayLoop(
if (s.kind === "assign" || s.kind === "varDecl") disturbed.add(s.localId);
if (s.kind === "arraySet") {
access(s.arr, s.index);
- if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || s.index.localId !== counter))
+ if (s.arr.kind === "varRef" && (s.index.kind !== "varRef" || !isCounter(s.index.localId)))
disturbed.add(s.arr.localId);
}
+ if (s.kind === "arraySetUndefined") {
+ // A present undefined at a counter only where it stores a missing
+ // element read (`a[i] = b[j]!`): the fast copy reads a present value
+ // there, so the store never runs in it.
+ const read = missing.get(s);
+ if (read === undefined || s.index.kind !== "varRef" || !isCounter(s.index.localId)) {
+ undefinedStores.push(s.arr);
+ } else {
+ access(s.arr, s.index);
+ guards.push(read);
+ }
+ }
return everyStmtChild(s, expr, stmt);
};
if (!loop.body.every(stmt) || !expr(loop.cond) || nested) return null;
@@ -164,6 +324,15 @@ export function matchDenseArrayLoop(
)
)
return null;
+ // A store through another name, such as `const b = a` or a field, lands
+ // in `a` unless one of the two bindings is a local array no other binding
+ // holds.
+ const mayStoreUndefined = (id: string): boolean =>
+ undefinedStores.some((arr) =>
+ arr.kind === "varRef"
+ ? arr.localId === id || (!unaliased.has(id) && !unaliased.has(arr.localId))
+ : !unaliased.has(id),
+ );
const arrays = new Map();
for (const [id, ref] of candidates) {
const local = locals.get(id);
@@ -173,6 +342,7 @@ export function matchDenseArrayLoop(
local.boxed ||
local.tdz ||
disturbed.has(id) ||
+ mayStoreUndefined(id) ||
(id !== length?.localId && !initialized.has(id))
)
continue;
@@ -180,18 +350,33 @@ export function matchDenseArrayLoop(
// once: the loop must not change any of their lengths.
if (
length === null &&
- !preservesArrayLength(nodesOfLoop, { receiver: id, inRange: counter }, locals, unaliased)
+ !preservesArrayLength(
+ nodesOfLoop,
+ { receiver: id, inRange: counter, inRangeHigh: high },
+ locals,
+ unaliased,
+ )
)
continue;
arrays.set(id, ref);
}
if (arrays.size === 0) return null;
+ // Each guarded store's read must be one the fast copy proves present.
+ for (const { arr, index } of guards)
+ if (
+ arr.kind !== "varRef" ||
+ !arrays.has(arr.localId) ||
+ index.kind !== "varRef" ||
+ !isCounter(index.localId)
+ )
+ return null;
return {
counter,
length,
limit: length === null ? (shape.limit ?? null) : null,
arrays,
range: { min: shape.range.min, max: Math.min(shape.range.max, INT32_MAX - 1) },
+ pair: shape.pair ?? null,
};
}
@@ -207,6 +392,7 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
+ if (loop.pair !== null) return emitPairedDenseGuard(host, loop, loop.pair, header);
if (loop.length === null) {
// A limit at or below the start runs no pass, and passes the test.
const limit = emitCountedLoopLimit(host, loop.limit!);
@@ -254,3 +440,36 @@ export function emitDenseLoopGuard(host: LlvmEmitterContext, loop: DenseArrayLoo
}
return valid;
}
+
+/** A two-counter loop's entry test: both starts are exact integers, the
+ * low one at least 0 and the high one at most 2^31 - 2 and below every
+ * indexed array's hole-free prefix bound. The starts are stable values
+ * (matchIntegerPairedForLoop), so evaluating them here again is unobservable. */
+function emitPairedDenseGuard(
+ host: LlvmEmitterContext,
+ loop: DenseArrayLoop,
+ pair: DensePair,
+ header: (array: string, field: number) => string,
+): string {
+ const B = host.B;
+ const start = (expr: IrExpr, min: number, max: number) =>
+ emitExactIntegerTest(host, host.emitExpr(expr).name, min, max);
+ const low = start(pair.lowStart, 0, INT32_MAX - 1);
+ const high = start(pair.highStart, -1, INT32_MAX - 1);
+ let valid = B.tmp();
+ B.line(`${valid} = and i1 ${low.ok}, ${high.ok}`);
+ for (const ref of loop.arrays.values()) {
+ const array = host.emitStableReceiver(ref, []);
+ const filledPtr = header(array.name, SCR_ARR_FIELD.filled),
+ filled = B.tmp(),
+ wide = B.tmp(),
+ covered = B.tmp(),
+ both = B.tmp();
+ B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`);
+ B.line(`${wide} = zext i32 ${filled} to i64`);
+ B.line(`${covered} = icmp slt i64 ${high.integer}, ${wide}`);
+ B.line(`${both} = and i1 ${valid}, ${covered}`);
+ valid = both;
+ }
+ return valid;
+}
diff --git a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
index c015b85d0..8c3e5de80 100644
--- a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
+++ b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";
import { DYN_HANDLE_KINDS } from "../../ir/ir.js";
-import { DYN_HANDLE_TAG_NUM } from "./dyn.js";
+import { DYN_HANDLE_TAG_NUM, TEXT_CODEC_TAG_NUM } from "./dyn.js";
// SCR_DYNH_TAGS in scr_dyn.h lists the ScrDynHandleTag values by name, each
// with an explicit value, so changes that add tags edit different lines. The
@@ -39,3 +39,9 @@ test("the backend's handle tag numbers match scr_dyn.h", () => {
expect(values.get(tag!), `${kind} (${tag})`).toBe(num);
}
});
+
+test("the backend's codec handle tag numbers match scr_dyn.h", () => {
+ const values = new Map(tags.map((tag) => [tag.name, tag.value]));
+ expect(values.get("SCR_DYNH_TEXT_DECODER")).toBe(TEXT_CODEC_TAG_NUM.TextDecoder);
+ expect(values.get("SCR_DYNH_TEXT_ENCODER")).toBe(TEXT_CODEC_TAG_NUM.TextEncoder);
+});
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index 86626d304..d90239fc3 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -43,6 +43,8 @@ import {
isDynTypedRefType,
isRefCounted,
isUnitType,
+ recordTextCodecClass,
+ textCodecRecordClass,
typeKey,
} from "../../ir/ir.js";
import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
@@ -94,6 +96,17 @@ export const DYN_HANDLE_TAG_NUM: Record = {
cryptoHmac: 53,
};
+/** The handle tags of TextEncoder and TextDecoder records boxed as dyn
+ * (scr_dyn.h; scr_text_codec_dyn.c boxes and unboxes them). */
+export const TEXT_CODEC_TAG_NUM: Record<"TextEncoder" | "TextDecoder", number> = {
+ TextDecoder: 76,
+ TextEncoder: 77,
+};
+
+/** The decoder record fields scr_text_decoder_to_dyn reads, in its
+ * parameter order. */
+const TEXT_DECODER_FIELDS = ["%TextDecoder", "%fatal", "%ignoreBOM", "%state"] as const;
+
/** Handle kinds whose runtime unit boxes them through its own function,
* which installs the tag's ops on first use (scr_crypto.h). Programs that
* never box one of these values do not link its dynamic members. */
@@ -456,7 +469,7 @@ export class LlDyn {
/** The value LLVM type of a dynCheck result / toDyn operand for `t`. */
private valTy(t: IrType): string {
- return t.kind === "f64" || t.kind === "date" || t.kind === "procStream"
+ return t.kind === "f64" || t.kind === "procStream"
? "double"
: t.kind === "bool"
? "i1"
@@ -927,6 +940,21 @@ export class LlDyn {
const name = `sc_dm_${this.dynMatchers.size}`;
this.dynMatchers.set(matcherKey, name);
this.host.journalAllocated?.(name, "dyn.dynMatchHelper", [t, preserveRefs]);
+ const codec = textCodecRecordClass(t, (id) => this.host.recordsById.get(id));
+ if (codec !== null) {
+ // A codec matches only its own handle box.
+ const B = new BlockBuilder();
+ const matched = this.handleTagMatches(B, TEXT_CODEC_TAG_NUM[codec]);
+ B.terminate(`ret i1 ${matched}`);
+ this.defs.push(
+ `define internal zeroext i1 @${name}(ptr %d) ${FN_ATTRS} { ; matches ${key}`,
+ B.render(),
+ `}`,
+ ``,
+ );
+ this.host.journalBuilt?.();
+ return name;
+ }
const recursive = this.isRecursiveShape(t);
const wrapped = recursive || this.holdsRecursiveShape(t);
const B = new BlockBuilder();
@@ -1535,6 +1563,19 @@ export class LlDyn {
return descriptor;
}
+ /** `record.sourceSpread(built, value)`: the built record keeps value's
+ * kept-source order over its own declared names. */
+ emitRecordSourceSpread(B: BlockBuilder, built: string, value: string, shapeId: string): void {
+ const host = this.host;
+ const shape = host.recordsById.get(shapeId)!;
+ this.keepsRecordSources = true;
+ const names = this.recordNameTable(shapeId, shape);
+ host.declare(`declare void @scr_record_source_spread(ptr, ptr, ptr, ptr, ${host.sizeType})`);
+ B.line(
+ `call void @scr_record_source_spread(ptr ${built}, ptr ${value}, ptr ${names.names}, ptr ${names.lens}, ${host.sizeType} ${shape.fields.length})`,
+ );
+ }
+
/** The declared-name tables scr_record_source_note scans: parallel
* constant arrays of field-name bytes and byte lengths in declaration
* order (the order the record serializes), interned per shape. Internal
@@ -1643,6 +1684,24 @@ export class LlDyn {
this.host.journalBuilt?.();
return name;
}
+ const codec = textCodecRecordClass(t, (id) => host.recordsById.get(id));
+ if (codec !== null) {
+ // The record the codec's handle box holds (+1), or the path TypeError.
+ host.declare(`declare ptr @scr_text_codec_from_dyn(ptr, i1 zeroext, ptr, ptr)`);
+ const checked = B.tmp();
+ B.line(
+ `${checked} = call ptr @scr_text_codec_from_dyn(ptr %d, i1 ${codec === "TextDecoder"}, ptr %path, ptr ${want})`,
+ );
+ B.terminate(`ret ptr ${checked}`);
+ this.defs.push(
+ `define internal ptr @${name}(ptr %d, ptr %path) ${FN_ATTRS} {`,
+ B.render(),
+ `}`,
+ ``,
+ );
+ this.host.journalBuilt?.();
+ return name;
+ }
if (isRefCounted(t) && t.kind !== "dyn") {
host.declare(`declare ptr @scr_dyn_typed_ref_unbox(ptr)`);
if (t.kind === "object" && t.className === "%Error") {
@@ -1895,10 +1954,10 @@ export class LlDyn {
};
switch (t.kind) {
case "date": {
- host.declare(`declare double @scr_dyn_native_date_value(ptr)`);
+ host.declare(`declare ptr @scr_dyn_native_date_check(ptr, ptr)`);
const r = B.tmp();
- B.line(`${r} = call double @scr_dyn_native_date_value(ptr %d)`);
- B.terminate(`ret double ${r}`);
+ B.line(`${r} = call ptr @scr_dyn_native_date_check(ptr %d, ptr %path)`);
+ B.terminate(`ret ptr ${r}`);
break;
}
case "url":
@@ -2671,6 +2730,77 @@ export class LlDyn {
return name;
}
+ /** `i1`: %d is a handle box with tag `tag`. */
+ private handleTagMatches(B: BlockBuilder, tag: number): string {
+ const kd = this.kindOf(B, "%d");
+ const isHandle = B.tmp();
+ B.line(`${isHandle} = icmp eq i32 ${kd}, ${DYN_KIND.HANDLE}`);
+ const lHandle = B.newLabel("dm.handle");
+ const lDone = B.newLabel("dm.handle.done");
+ const entry = B.currentLabel();
+ B.condBr(isHandle, lHandle, lDone);
+ B.startBlock(lHandle);
+ const tagPtr = B.tmp();
+ const loaded = B.tmp();
+ B.line(
+ `${tagPtr} = getelementptr inbounds i8, ptr %d, i64 ${this.abiOffset(24, 20)} ; ->v.handle.tag`,
+ );
+ B.line(`${loaded} = load i32, ptr ${tagPtr}`);
+ const same = B.tmp();
+ B.line(`${same} = icmp eq i32 ${loaded}, ${tag}`);
+ B.br(lDone);
+ B.startBlock(lDone);
+ const matched = B.tmp();
+ B.line(`${matched} = phi i1 [ false, %${entry} ], [ ${same}, %${lHandle} ]`);
+ return matched;
+ }
+
+ /** A TextEncoder or TextDecoder record boxes as its handle (+1), which
+ * holds the record by reference. A decoder passes the byte offsets of the
+ * fields its boxed decode reads. */
+ private textCodecToDyn(
+ B: BlockBuilder,
+ t: IrType & { kind: "record" },
+ shape: IrRecordShape,
+ codec: "TextEncoder" | "TextDecoder",
+ ): void {
+ const host = this.host;
+ const rc = vAdapters(host, t);
+ const r = B.tmp();
+ if (codec === "TextEncoder") {
+ host.declare(`declare ptr @scr_text_encoder_to_dyn(ptr, ptr, ptr)`);
+ B.line(
+ `${r} = call ptr @scr_text_encoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release})`,
+ );
+ } else {
+ const struct = mangleRecordStruct(t.shapeId);
+ const base = host.nullableUnions.fieldBase(t.shapeId);
+ const start = B.tmp();
+ B.line(`${start} = ptrtoint ptr %v to ${this.S}`);
+ const offsets = TEXT_DECODER_FIELDS.map((field) => {
+ const index = shape.fields.findIndex((f) => f.name === field);
+ if (index < 0)
+ throw new InternalCompilerError(`llvm emitter bug: TextDecoder record lacks ${field}`);
+ const p = B.tmp();
+ const at = B.tmp();
+ const offset = B.tmp();
+ B.line(
+ `${p} = getelementptr inbounds %${struct}, ptr %v, i64 0, i32 ${index + base} ; .${llvmCommentText(field)}`,
+ );
+ B.line(`${at} = ptrtoint ptr ${p} to ${this.S}`);
+ B.line(`${offset} = sub ${this.S} ${at}, ${start}`);
+ return `${this.S} ${offset}`;
+ });
+ host.declare(
+ `declare ptr @scr_text_decoder_to_dyn(ptr, ptr, ptr, ${this.S}, ${this.S}, ${this.S}, ${this.S})`,
+ );
+ B.line(
+ `${r} = call ptr @scr_text_decoder_to_dyn(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ${offsets.join(", ")})`,
+ );
+ }
+ B.terminate(`ret ptr ${r}`);
+ }
+
/* ── toDynHelper (walkers.ts, ported) ─────────────────────────── */
/** `sc_td_( v) -> ptr` — build a fresh dyn value from a
@@ -2688,11 +2818,10 @@ export class LlDyn {
let sourceAccessor: { name: string; release: string } | null = null;
switch (t.kind) {
case "date": {
- // A Date handle holding the same time value; native Dates are
- // immutable values, and so is the handle.
- host.declare(`declare ptr @scr_dyn_native_date_from_ms(double)`);
+ // A Date handle holding the same Date object.
+ host.declare(`declare ptr @scr_dyn_native_date(ptr)`);
const r = B.tmp();
- B.line(`${r} = call ptr @scr_dyn_native_date_from_ms(double %v)`);
+ B.line(`${r} = call ptr @scr_dyn_native_date(ptr %v)`);
B.terminate(`ret ptr ${r}`);
break;
}
@@ -2846,6 +2975,11 @@ export class LlDyn {
const shape = host.recordsById.get(t.shapeId);
if (!shape)
throw new InternalCompilerError(`llvm emitter bug: to-dyn of unknown shape ${t.shapeId}`);
+ const codec = recordTextCodecClass(shape);
+ if (codec !== null) {
+ this.textCodecToDyn(B, t, shape, codec);
+ break;
+ }
const struct = mangleRecordStruct(t.shapeId);
const base = host.nullableUnions.fieldBase(t.shapeId);
const fieldIndex = new Map(shape.fields.map((f, i) => [f.name, i + base]));
diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts
index b6df7bcca..8f306035a 100644
--- a/packages/compiler/src/backend/llvm/emit-function.ts
+++ b/packages/compiler/src/backend/llvm/emit-function.ts
@@ -304,11 +304,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
// ScrGen owns the frame), so its OUT slot address is fixed.
let out: string | null = null;
const yieldKind = fn.generator?.yieldT.kind;
- if (
- !emitter.wasi &&
- fn.async !== true &&
- (yieldKind === "f64" || yieldKind === "date" || yieldKind === "bool")
- ) {
+ if (!emitter.wasi && fn.async !== true && (yieldKind === "f64" || yieldKind === "bool")) {
emitter.declare(`declare ptr @scr_gen_of_fiber(ptr)`);
const gen = B.tmp();
B.line(`${gen} = call ptr @scr_gen_of_fiber(ptr ${self})`);
@@ -704,7 +700,6 @@ export function emitGenScaffolding(emitter: LlEmitter): string[] {
tr.push(` br label %done ; void body: the done value is undefined (NONE)`);
break;
case "f64":
- case "date":
emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`);
tr.push(` call void @scr_gen_out_f64(ptr %g, double %r)`, ` br label %done`);
break;
@@ -1053,7 +1048,6 @@ export function emitAsyncAwaitCall(
emitter.declare(`declare void @scr_await_void(ptr)`);
return ` call void @scr_await_void(ptr ${promise})`;
case "f64":
- case "date":
emitter.declare(`declare double @scr_await_f64(ptr)`);
return ` ${dest} = call double @scr_await_f64(ptr ${promise})`;
case "bool":
@@ -1108,7 +1102,7 @@ export function emitAsyncAwaitCall(
const kind =
ret.kind === "void"
? "void"
- : ret.kind === "f64" || ret.kind === "date"
+ : ret.kind === "f64"
? "f64"
: ret.kind === "bool"
? "bool"
@@ -1187,7 +1181,6 @@ export function emitWasiFulfill(emitter: LlEmitter, v: LlValue | null): void {
case "void":
return;
case "f64":
- case "date":
emitter.declare(`declare void @scr_gen_out_f64(ptr, double)`);
emitter.B.line(`call void @scr_gen_out_f64(ptr ${gen}, double ${v!.name})`);
return;
@@ -1212,7 +1205,6 @@ export function emitWasiFulfill(emitter: LlEmitter, v: LlValue | null): void {
emitter.B.line(`call void @scr_promise_fulfill_void(ptr ${pr})`);
break;
case "f64":
- case "date":
emitter.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
emitter.B.line(`call void @scr_promise_fulfill_f64(ptr ${pr}, double ${v!.name})`);
break;
diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts
index c2846a9d4..55e31219c 100644
--- a/packages/compiler/src/backend/llvm/emit-statements.ts
+++ b/packages/compiler/src/backend/llvm/emit-statements.ts
@@ -52,13 +52,19 @@ import {
matchIntegerPairedForLoop,
} from "../../ir/integer-loops.js";
import { emitByteWindowGuard, matchByteWindow } from "./byte-windows.js";
-import { countedDenseShape, emitDenseLoopGuard, matchDenseArrayLoop } from "./dense-array-loops.js";
+import {
+ countedDenseShape,
+ emitDenseLoopGuard,
+ matchDenseArrayLoop,
+ pairedDenseShape,
+} from "./dense-array-loops.js";
import {
emitCountedLoopLimit,
emitCountedLoopSafety,
emitPairedLoopSafety,
} from "./counted-loops.js";
import { emitBytesLoopVersionGuard, type BytesLoopVersion } from "./bytes-loop-versions.js";
+import { bytesHeaderAlias } from "./bytes-alias.js";
import { mangleLocal } from "../mangle.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlValue } from "./expr-context.js";
@@ -966,7 +972,9 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
: countedLoop && !countedLoop.guarded && countedLoop.followers.length === 0
? countedDenseShape(s, countedLoop)
- : null;
+ : pairedLoop
+ ? pairedDenseShape(pairedLoop)
+ : null;
const dense =
denseShape && !window
? matchDenseArrayLoop(
@@ -1043,15 +1051,17 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
LOOPS.of(emitter).unaliasedArrays,
)
: countedLoop;
- const version = versionable ? emitter.matchBytesLoopVersion(s, versionCounted) : null;
+ const version = versionable
+ ? emitter.matchBytesLoopVersion(s, versionCounted, versionCounted ? null : pairedLoop)
+ : null;
if (version) {
emitVersionedLoop(emitter, s, version);
break;
}
let countedJoin: string | null = null;
if (
- (countedLoop?.guarded && !LOOPS.of(emitter).provenCountedGuards.has(s)) ||
- pairedLoop?.guarded
+ (countedLoop?.guarded || pairedLoop?.guarded) &&
+ !LOOPS.of(emitter).provenCountedGuards.has(s)
) {
const safe = countedLoop
? emitCountedLoopSafety(emitter, countedLoop)
@@ -1144,11 +1154,15 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.line(`${start} = fptosi double ${value.name} to i64`);
B.line(`store i64 ${start}, ptr ${slot}`);
pairedSlots.push(slot);
+ const dense = LOOPS.of(emitter).denseLoop;
emitter.integerLoopBindings.set(counter.localId, {
slot,
type: "i64",
signed: true,
- range: pairedLoop.range,
+ range:
+ dense?.pair && dense.counter === pairedLoop.low.localId
+ ? dense.range
+ : pairedLoop.range,
});
}
} else if (s.init) {
@@ -1218,10 +1232,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
B.line(`${inBounds} = icmp ult i32 ${narrow}, ${len}`);
} else {
- emitter.markMemoryPointer(lenPtr, "bytes:header");
- B.line(
- `${len} = load ${emitter.sizeType}, ptr ${lenPtr}${emitter.fieldAliasAttachment(lenPtr)}`,
- );
+ B.line(`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`);
B.line(`${inBounds} = icmp ult ${emitter.sizeType} ${index}, ${len}`);
}
B.condBr(inBounds, lb, exit);
@@ -2085,7 +2096,8 @@ function emitVersionedLoop(
if (version.owned.size > 0) emitter.bytesOwned = new Set([...originalOwned, ...version.owned]);
if (version.ranges.size > 0)
emitter.integerRanges = new Map([...originalRanges, ...version.ranges]);
- if (version.counted?.guarded) LOOPS.of(emitter).provenCountedGuards.add(s);
+ if (version.counted?.guarded || version.paired?.loop.guarded)
+ LOOPS.of(emitter).provenCountedGuards.add(s);
emitter.emitStmt(s);
B.br(done);
} finally {
diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts
index 4793663aa..23ca9d9d6 100644
--- a/packages/compiler/src/backend/llvm/emitter.test.ts
+++ b/packages/compiler/src/backend/llvm/emitter.test.ts
@@ -1,7 +1,6 @@
import { expect, test } from "vitest";
import {
BOOL,
- DATE_T,
F64,
PROCSTREAM_T,
STRING,
@@ -287,7 +286,7 @@ test("shared exceptional exits preserve the function's scalar return ABI", () =>
});
test("exceptional exits use each scalar kind's LLVM return type", () => {
- for (const type of [F64, DATE_T, BOOL, PROCSTREAM_T]) {
+ for (const type of [F64, BOOL, PROCSTREAM_T]) {
for (const ownedLocals of [false, true]) {
const module = moduleFor([call(), call(), { kind: "throw", value: literal("failed"), loc }]);
const work = module.functions[1]!;
@@ -499,6 +498,27 @@ test("class brand probes compare the program's key constant before the key text"
const named = work(probe("dyn.typedRefIs"), dyn);
expect(named).toContain("; ->v.typed_ref.type_key");
expect(named).toContain("@scr_dyn_typed_ref_is_key");
+ // Another key pointer is rejected on its length and last eight bytes
+ // ("Example" plus the prefix's ':') before the out-of-line comparison.
+ expect(named).toContain("; ->v.typed_ref.type_key_len");
+ expect(named).toContain("and i32 %t");
+ expect(named).toMatch(/icmp eq i32 %t\d+, 14\b/);
+ const tail = Buffer.from("object:Example").readBigInt64LE(6);
+ expect(named).toContain(`icmp eq i64 %t`);
+ expect(named).toContain(`, ${tail}`);
+ const shortKey: IrExpr = { kind: "strLit", value: "o:E", type: STRING, loc: receiverLoc };
+ const short = work(
+ {
+ kind: "libCall",
+ fn: "dyn.typedRefIs",
+ args: [ref(dyn), shortKey],
+ type: BOOL,
+ loc: receiverLoc,
+ },
+ dyn,
+ );
+ expect(short).toMatch(/icmp eq i32 %t\d+, 3\b/);
+ expect(short).not.toContain("load i64");
// The key-pointer test answers false for capsules boxed elsewhere.
const exact = work(probe("dyn.typedRefKeyIs"), dyn);
expect(exact).toContain("; ->v.typed_ref.type_key");
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index 050c3fb90..8e6b88a3a 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -60,7 +60,7 @@ import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion } from "./bytes-loop-versions.js";
import { globalSlotAlias } from "./bytes-alias.js";
-import type { IntegerCountedForLoop } from "../../ir/integer-loops.js";
+import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
materializeSplitPiece,
type SplitSpan,
@@ -68,7 +68,7 @@ import {
type StringSplit,
} from "./split-loops.js";
import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
-import { addRecordResultEntries } from "./record-results.js";
+import { addRecordResultEntries, resultEntryName } from "./record-results.js";
import { specializeCallbackCalls } from "../../ir/callback-specialization.js";
import {
type ChunkResult,
@@ -82,6 +82,7 @@ import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import {
allocatedLiteral,
fillAllocationLength,
+ int32ArrayKey,
selectInt32Arrays,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
@@ -185,7 +186,7 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
-import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js";
+import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -510,6 +511,15 @@ function provenInt32Arrays(mod: IrModule): ReadonlySet {
return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]);
}
+/** The functions whose calls the backend may emit as calls of their
+ * result-slot entries (record-results.ts). */
+function resultEntryTargets(mod: IrModule): ReadonlySet {
+ const names = new Set(mod.functions.map((fn) => fn.name));
+ const targets = new Set();
+ for (const name of names) if (names.has(resultEntryName(name))) targets.add(name);
+ return targets;
+}
+
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
@@ -545,6 +555,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
),
),
provenInt32Arrays,
+ resultEntryTargets,
)
: mod,
options,
@@ -781,7 +792,7 @@ export class LlEmitter {
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind !== "varDecl" && s.kind !== "assign") return true;
- const key = `${fn.name}\0${s.localId}`;
+ const key = int32ArrayKey(fn.name, s.localId);
const checked = checkedInt32Arrays.has(key);
if (!checked && !int32Arrays.has(key)) return true;
const value = s.kind === "varDecl" ? s.init : s.value;
@@ -799,15 +810,16 @@ export class LlEmitter {
});
}
- /** Whether `arr` is one of the current function's int32 arrays
- * (ir/int32-arrays.ts, kept by the int32 slot analysis). */
+ /** Whether `arr` reads one of the int32 arrays (ir/int32-arrays.ts, kept
+ * by the int32 slot analysis): a local or parameter of the current
+ * function, or a module global. */
int32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef") return false;
if (this.int32KindArray(arr)) return true;
if (this.int32Slots.int32Arrays.size === 0) return false;
return (
this.int32ArrayTemps.has(arr.localId) ||
- this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`)
+ this.int32Slots.int32Arrays.has(int32ArrayKey(this.currentFunctionName, arr.localId))
);
}
@@ -974,8 +986,8 @@ export class LlEmitter {
}
/** Whether an element access at `index` of `arr` lies in the hole-free
- * prefix: the counter of a dense array loop's fast copy indexing one of
- * the arrays its entry test covered. */
+ * prefix: a counter of a dense array loop's fast copy indexing one of the
+ * arrays its entry test covered. */
provenDenseIndex(arr: IrExpr, index: IrExpr): boolean {
const loop = LOOPS.of(this).denseLoop;
return (
@@ -983,8 +995,8 @@ export class LlEmitter {
arr.kind === "varRef" &&
loop.arrays.has(arr.localId) &&
index.kind === "varRef" &&
- index.localId === loop.counter &&
- this.integerLoopBindings.has(loop.counter)
+ (index.localId === loop.counter || index.localId === loop.pair?.high) &&
+ this.integerLoopBindings.has(index.localId)
);
}
@@ -2032,7 +2044,6 @@ export class LlEmitter {
if (POINTER_KINDS.has(t.kind)) return "ptr";
switch (t.kind) {
case "f64":
- case "date":
return "double";
case "bool":
return "i1";
@@ -2165,7 +2176,6 @@ export class LlEmitter {
this.declare(`declare void @scr_promise_fulfill_void(ptr)`);
return [` call void @scr_promise_fulfill_void(ptr ${promise})`];
case "f64":
- case "date":
this.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
return [` call void @scr_promise_fulfill_f64(ptr ${promise}, double %r)`];
case "bool":
@@ -2395,7 +2405,7 @@ export class LlEmitter {
return { name: "", type };
}
const t = B.tmp();
- if (type.kind === "f64" || type.kind === "date") {
+ if (type.kind === "f64") {
this.declare(`declare double @scr_coro_direct_f64(ptr)`);
B.line(`${t} = call double @scr_coro_direct_f64(ptr ${record})`);
} else if (type.kind === "bool") {
@@ -4251,6 +4261,7 @@ export class LlEmitter {
matchBytesLoopVersion(
s: IrStmt & { kind: "for" | "while" },
counted: IntegerCountedForLoop | null,
+ paired: IntegerPairedForLoop | null = null,
): ReturnType {
const fn = LOOPS.of(this).bytesLoopFunction;
if (!fn) return null;
@@ -4260,17 +4271,22 @@ export class LlEmitter {
const invariantGlobals = new Set(this.stableGlobals);
for (const global of this.initializerBindings.get(fn.name) ?? [])
invariantGlobals.add(global.id);
- return matchBytesLoopVersion(s, counted, {
- locals: this.currentLocals,
- invariantGlobals,
- captured: this.captureIds,
- ranges: this.integerRanges,
- initialized,
- proven: this.bytesBounds,
- workers: this.mod.workers === true,
- owned: this.bytesOwned,
- scalarStrings: new Set(this.stringSlices.keys()),
- });
+ return matchBytesLoopVersion(
+ s,
+ counted,
+ {
+ locals: this.currentLocals,
+ invariantGlobals,
+ captured: this.captureIds,
+ ranges: this.integerRanges,
+ initialized,
+ proven: this.bytesBounds,
+ workers: this.mod.workers === true,
+ owned: this.bytesOwned,
+ scalarStrings: new Set(this.stringSlices.keys()),
+ },
+ paired,
+ );
}
/** The alias tag of a whole-slot load of a binding (module globals only). */
@@ -6279,6 +6295,41 @@ export class LlEmitter {
return ok;
}
+ /** Branches on "the array `arr` is frozen" (SCR_ARR_FROZEN in its storage
+ * flags, scr_array.h), or published too when `published`; like
+ * emitPublishedTest, leaves the builder in the cold block and answers the
+ * continuation label. */
+ private emitFrozenArrayTest(arr: string, published: boolean): string {
+ const B = this.B;
+ const sparep = B.tmp(),
+ spare = B.tmp(),
+ bit = B.tmp();
+ let frozen = B.tmp();
+ B.line(
+ `${sparep} = getelementptr inbounds %ScrArr, ptr ${arr}, i32 0, i32 ${SCR_ARR_FIELD.spare}`,
+ );
+ B.line(`${spare} = load i16, ptr ${sparep}`);
+ B.line(`${bit} = and i16 ${spare}, ${SCR_ARR_FROZEN}`);
+ B.line(`${frozen} = icmp ne i16 ${bit}, 0`);
+ if (published) {
+ const rc = B.tmp(),
+ immortal = B.tmp(),
+ either = B.tmp();
+ B.line(`${rc} = load ${this.sizeType}, ptr ${arr}`);
+ B.line(`${immortal} = icmp eq ${this.sizeType} ${rc}, -1`);
+ B.line(`${either} = or i1 ${frozen}, ${immortal}`);
+ frozen = either;
+ }
+ this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
+ const expected = B.tmp();
+ B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`);
+ const cold = B.newLabel("arr.frozen"),
+ ok = B.newLabel("arr.ok");
+ B.condBr(expected, cold, ok);
+ B.startBlock(cold);
+ return ok;
+ }
+
private publishedNamesSym: string | null = null;
/** The class-name table the hierarchy field guards index by preorder. */
@@ -6343,15 +6394,29 @@ export class LlEmitter {
op: number,
index = f64Lit(0),
count = f64Lit(0),
+ third: string | null = null,
): void {
- if (this.publishedTypes?.containerGuarded(arrType) !== true) return;
+ const published = this.publishedTypes;
+ if (published?.containerGuarded(arrType) !== true) return;
const B = this.B;
- const ok = this.emitPublishedTest(arr);
- this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
+ const guards = published.arrayGuards(arrType);
+ const ok = guards.frozen
+ ? this.emitFrozenArrayTest(arr, guards.published)
+ : this.emitPublishedTest(arr);
const threw = B.tmp();
- B.line(
- `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
- );
+ if (third === null) {
+ this.declare(`declare zeroext i1 @scr_throw_published_array(i32, ptr, double, double)`);
+ B.line(
+ `${threw} = call zeroext i1 @scr_throw_published_array(i32 ${op}, ptr ${arr}, double ${index}, double ${count})`,
+ );
+ } else {
+ this.declare(
+ `declare zeroext i1 @scr_throw_published_array_range(i32, ptr, double, double, double)`,
+ );
+ B.line(
+ `${threw} = call zeroext i1 @scr_throw_published_array_range(i32 ${op}, ptr ${arr}, double ${index}, double ${count}, double ${third})`,
+ );
+ }
const unwind = B.newLabel("pub.throw");
B.condBr(threw, unwind, ok);
B.startBlock(unwind);
diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts
index b5d765b16..ee9ff2983 100644
--- a/packages/compiler/src/backend/llvm/expr-async.ts
+++ b/packages/compiler/src/backend/llvm/expr-async.ts
@@ -184,7 +184,7 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic
}
const v = host.emitExpr(e.args[0]!);
const t = e.args[0]!.type;
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_promise_fulfill_f64(ptr, double)`);
B.line(`call void @scr_promise_fulfill_f64(ptr ${p}, double ${v.name})`);
} else if (t.kind === "bool") {
@@ -500,7 +500,7 @@ export function emitAsyncExpr(
host.declare(`declare void @scr_async_gen_hop_done()`);
B.line(`call void @scr_async_gen_hop_done()`);
}
- if (yt.kind === "f64" || yt.kind === "date") {
+ if (yt.kind === "f64") {
if (host.currentCoro !== null) {
const coro = host.currentCoro;
if (coro.out !== null) host.emitGenSlotStore(coro.out, "f64", v.name);
@@ -582,7 +582,7 @@ export function emitAsyncExpr(
return { name: "", type: e.type };
}
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
host.declare(`declare double @scr_gen_take_in_f64()`);
B.line(`${t} = call double @scr_gen_take_in_f64()`);
return { name: t, type: e.type };
@@ -710,12 +710,12 @@ export function emitAsyncExpr(
throw new InternalCompilerError(`llvm emitter bug: genResume ${e.mode} on async`);
const gb = emitBorrowedInput(host, e.gen);
if (e.mode === "value") {
- if (!host.wasi && (e.type.kind === "f64" || e.type.kind === "date"))
+ if (!host.wasi && e.type.kind === "f64")
return { name: host.emitGenSlotTake(host.genOutSlot(gb.name), "f64"), type: e.type };
if (!host.wasi && e.type.kind === "bool")
return { name: host.emitGenSlotTake(host.genOutSlot(gb.name), "bool"), type: e.type };
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
host.declare(`declare double @scr_gen_take_out_f64(ptr)`);
B.line(`${t} = call double @scr_gen_take_out_f64(ptr ${gb.name})`);
return { name: t, type: e.type };
@@ -752,7 +752,7 @@ export function emitAsyncExpr(
store(a.name, e.arg!.type);
};
const parkIn = (name: string, t: IrType): void => {
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_gen_in_f64(ptr, double)`);
B.line(`call void @scr_gen_in_f64(ptr ${g.name}, double ${name})`);
} else if (t.kind === "bool") {
@@ -787,7 +787,7 @@ export function emitAsyncExpr(
const a = host.emitExpr(e.arg);
const t = e.arg.type;
if (isRefCounted(t)) host.toHeap(a);
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare ptr @scr_async_gen_next_f64(ptr, double)`);
call = `call ptr @scr_async_gen_next_f64(ptr ${g.name}, double ${a.name})`;
} else if (t.kind === "bool") {
@@ -806,7 +806,7 @@ export function emitAsyncExpr(
const a = host.emitExpr(e.arg);
const t = e.arg.type;
if (isRefCounted(t)) host.toHeap(a);
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare ptr @scr_async_gen_return_f64(ptr, double)`);
call = `call ptr @scr_async_gen_return_f64(ptr ${g.name}, double ${a.name})`;
} else if (t.kind === "bool") {
@@ -849,7 +849,7 @@ export function emitAsyncExpr(
B.line(`call void @scr_gen_ret_none(ptr ${g.name})`);
} else {
sendArg((name, t) => {
- if (t.kind === "f64" || t.kind === "date") {
+ if (t.kind === "f64") {
host.declare(`declare void @scr_gen_ret_f64(ptr, double)`);
B.line(`call void @scr_gen_ret_f64(ptr ${g.name}, double ${name})`);
} else if (t.kind === "bool") {
@@ -960,7 +960,7 @@ export function emitAsyncExpr(
return { name: "", type: e.type };
}
const t = B.tmp();
- if (e.type.kind === "f64" || e.type.kind === "date") {
+ if (e.type.kind === "f64") {
const f64Await = host.awaitResultFn("f64");
host.declare(`declare double @${f64Await}(ptr)`);
B.line(`${t} = call double @${f64Await}(ptr ${pr.name})`);
@@ -1050,7 +1050,7 @@ export function emitAsyncExpr(
const peek = host.unionPeek(u.name, def.id);
if (host.coroAwait) host.emitCoroAwait(peek);
let awaited: LlValue;
- if (inner.kind === "f64" || inner.kind === "date") {
+ if (inner.kind === "f64") {
const f64Await = host.awaitResultFn("f64");
host.declare(`declare double @${f64Await}(ptr)`);
const x = B.tmp();
@@ -1136,7 +1136,6 @@ export function emitAsyncExpr(
let resolve: string;
const kindNums: Partial> = {
f64: 0,
- date: 0,
bool: 1,
string: 2,
void: 3,
@@ -1191,7 +1190,6 @@ export function emitAsyncExpr(
B.line(`${p} = call ptr @scr_promise_new()`);
const kindNums: Partial> = {
f64: 0,
- date: 0,
bool: 1,
string: 2,
void: 3,
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index a4e7cde2c..6ff52619e 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -44,6 +44,7 @@ import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
+import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { emitCallArrayRead } from "./local-array-reads.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { emitStackMapRead } from "./map-read-lifetimes.js";
@@ -166,6 +167,9 @@ export function emitCallExpr(
return { name: host.emitOwnedNullableStack(a).box, type: a.type };
// An inline union field read copies its tag and bits.
if (host.inlineFieldGet(a)) return host.emitReadReceiver(a);
+ // A scalar keyed record read writes its tag and bits.
+ if (a.kind === "recordKeyGet" && scalarRecordKeyGet(host, a))
+ return { name: emitStackRecordKeyGet(host, a), type: a.type };
}
if (inputs[index]) return host.emitReadReceiver(a);
}
diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts
index 1ba5da818..279b8c044 100644
--- a/packages/compiler/src/backend/llvm/expr-containers.ts
+++ b/packages/compiler/src/backend/llvm/expr-containers.ts
@@ -257,6 +257,16 @@ function emitDiscarded(host: LlvmEmitterContext, e: IrExpr): void {
const start = host.emitExpr(e.args[0]!);
const count = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
const values = items.map((item) => host.emitExpr(item));
+ // Before the values move into the buffer: a throw releases them with the
+ // frame.
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 10,
+ start.name,
+ count,
+ f64Lit(values.length),
+ );
if (acc === "ref") values.forEach((value) => host.toHeap(value));
let buffer = "null";
if (values.length > 0) {
@@ -904,6 +914,12 @@ export function emitArrIntrinsic(
B.line(`${t} = call ptr @${helper}(ptr ${r.name}, ptr ${sep.name})`);
return host.own({ name: t, type: e.type });
}
+ if (elem.kind === "date") {
+ host.declare(`declare ptr @scr_date_arr_join(ptr, ptr)`);
+ const t = B.tmp();
+ B.line(`${t} = call ptr @scr_date_arr_join(ptr ${r.name}, ptr ${sep.name})`);
+ return host.own({ name: t, type: e.type });
+ }
host.declare(`declare ptr @scr_arr_join(ptr, ptr)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_arr_join(ptr ${r.name}, ptr ${sep.name})`);
@@ -928,6 +944,16 @@ export function emitArrIntrinsic(
case "sortPrimitive":
case "toSortedPrimitive": {
if (e.method === "sortPrimitive") host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ if (elem.kind === "date") {
+ // Dates order by their toString() text.
+ host.declare(`declare ptr @scr_arr_sort_by_text(ptr, i1 zeroext, ptr)`);
+ host.declare(`declare ptr @scr_date_object_to_string(ptr)`);
+ const sorted = B.tmp();
+ B.line(
+ `${sorted} = call ptr @scr_arr_sort_by_text(ptr ${r.name}, i1 ${e.method === "toSortedPrimitive" ? 1 : 0}, ptr @scr_date_object_to_string)`,
+ );
+ return host.own({ name: sorted, type: e.type });
+ }
host.declare(`declare ptr @scr_arr_sort_primitive(ptr, i1 zeroext)`);
const result = B.tmp();
B.line(
@@ -978,7 +1004,25 @@ export function emitArrIntrinsic(
case "fill":
case "fillUndefined": {
const args = e.args.map((arg) => host.emitExpr(arg));
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ // fill's range is its last two arguments; copyWithin's target,
+ // start and end are its three.
+ if (e.method === "copyWithin")
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 9,
+ args[0]!.name,
+ args[1]!.name,
+ args[2]!.name,
+ );
+ else
+ host.emitPublishedArrayGuard(
+ r.name,
+ e.receiver.type,
+ 8,
+ args[args.length - 2]!.name,
+ args[args.length - 1]!.name,
+ );
if (host.checkedFills.has(e)) {
// A new int32 array with checked stores, filled with a value that
// may not be an int32: widen it first then.
@@ -1042,7 +1086,7 @@ export function emitArrIntrinsic(
// removes to the end (+Infinity, the slice convention).
const start = host.emitExpr(e.args[0]!);
const cnt = e.args[1] ? host.emitExpr(e.args[1]).name : F64_INF;
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, cnt, f64Lit(0));
host.declare(`declare ptr @scr_arr_splice(ptr, double, double)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_arr_splice(ptr ${r.name}, double ${start.name}, double ${cnt})`);
@@ -1052,7 +1096,12 @@ export function emitArrIntrinsic(
const start = host.emitExpr(e.args[0]!);
const count = host.emitExpr(e.args[1]!);
const items = emitBorrowedInput(host, e.args[2]!);
- host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
+ if (host.publishedTypes?.containerGuarded(e.receiver.type) === true) {
+ host.declare(`declare double @scr_arr_len(ptr)`);
+ const n = B.tmp();
+ B.line(`${n} = call double @scr_arr_len(ptr ${items.name})`);
+ host.emitPublishedArrayGuard(r.name, e.receiver.type, 10, start.name, count.name, n);
+ }
host.declare(`declare ptr @scr_arr_splice_insert(ptr, double, double, ptr)`);
const t = B.tmp();
B.line(
diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts
index 07a538184..26905a7ef 100644
--- a/packages/compiler/src/backend/llvm/expr-dynamic.ts
+++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts
@@ -18,6 +18,7 @@ import {
type IrExpr,
type IrType,
isDynTypedRefType,
+ textCodecRecordClass,
isRefCounted,
isUnitType,
typeEquals,
@@ -86,7 +87,10 @@ export function emitDynamicExpr(
// Bytes already box their shared mutable storage directly. A second
// capsule would split identity between checked and native views.
const errorRef = v.type.kind === "object" && host.shapeHost.isErrorClass(v.type.className);
- if (!errorRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
+ // A TextEncoder or TextDecoder record boxes as its own handle, which
+ // keeps the record's identity (toDynHelper).
+ const codecRef = textCodecRecordClass(v.type, (id) => host.recordsById.get(id)) !== null;
+ if (!errorRef && !codecRef && ((e.liveRef && v.type.kind !== "bytes") || identityRef)) {
if (v.type.kind === "union") {
const adapter = host.liveDynUnionRefAdapter(v.type);
const boxed = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts
index 1eec6a965..2c6ce35db 100644
--- a/packages/compiler/src/backend/llvm/expr-primitives.ts
+++ b/packages/compiler/src/backend/llvm/expr-primitives.ts
@@ -1076,9 +1076,10 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayState on non-array");
+ const proven = host.provenDenseIndex(e.arr, e.index);
const t = host.checkedInt32Array(e.arr)
? host.emitByElementKind(arr.name, "double", (kind) =>
- emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind)),
+ emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind), proven),
)!
: emitDenseArrayState(
host,
@@ -1087,6 +1088,7 @@ export function emitContainerExpr(
e.index,
"state",
host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem),
+ proven,
);
return { name: t, type: e.type };
}
diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts
index bca728e9d..59ab625e5 100644
--- a/packages/compiler/src/backend/llvm/expr-records.ts
+++ b/packages/compiler/src/backend/llvm/expr-records.ts
@@ -7,6 +7,7 @@ import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { f64Lit, llvmCommentText } from "./common.js";
import { borrowableInputs, emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js";
import { regexLiteralTemplate } from "./expr-primitives.js";
+import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
import { preservesRegexInputs } from "./reference-effects.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
@@ -365,6 +366,114 @@ export function emitRecordKeyGet(
return host.own({ name: t, type: e.type });
}
+/** A keyed read whose result is a scalar union (`r[k]` on a
+ * `Record`, at `number | undefined`) and whose every hit
+ * is a number or boolean arm of it, or an inline field of the same union.
+ * Projections (`??`, `!`, tag tests) read it as a private stack box, so
+ * the read allocates nothing. */
+export function scalarRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolean {
+ if (e.kind !== "recordKeyGet" || e.missNaN === true || e.type.kind !== "union") return false;
+ const resultType = e.type;
+ const def = host.unionsById.get(resultType.unionId);
+ if (!isScalarUnionDef(def) || undefinedArmTag(resultType, host.unionsById) < 0) return false;
+ const isArm = (t: IrType): boolean =>
+ (t.kind === "f64" || t.kind === "bool") && def!.arms.some((a) => typeEquals(a, t));
+ const shape = host.recordShape(e.shapeId);
+ if (e.overflowOnly !== true) {
+ for (const f of shape.fields) {
+ const inline = host.recordFieldInline(e.shapeId, f.name);
+ if (!isArm(f.type) && !(inline && typeEquals(f.type, resultType))) return false;
+ }
+ }
+ return shape.indexValue === undefined || isArm(shape.indexValue);
+}
+
+/** A scalarRecordKeyGet read as a private stack box: the tag and bits of
+ * the declared field or overflow entry the key names, or the undefined arm
+ * for a miss. The box must never reach an RC entry point. */
+export function emitStackRecordKeyGet(
+ host: LlvmEmitterContext,
+ e: IrExpr & { kind: "recordKeyGet" },
+): string {
+ const B = host.B;
+ if (e.type.kind !== "union")
+ throw new InternalCompilerError("llvm emitter bug: stack keyed read of a non-union");
+ const resultType = e.type;
+ const def = host.unionsById.get(resultType.unionId)!;
+ const armTag = (t: IrType): number => def.arms.findIndex((a) => typeEquals(a, t));
+ const obj = host.emitExpr(e.obj);
+ const key = host.emitExpr(e.key);
+ const box = B.slot();
+ B.entryAllocas.push(`${box} = alloca %ScrUnion`);
+ const tagPtr = B.tmp(),
+ bitsPtr = B.tmp();
+ B.line(`store ${host.sizeType} -1, ptr ${box}`);
+ B.line(`${tagPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 1`);
+ B.line(`${bitsPtr} = getelementptr inbounds %ScrUnion, ptr ${box}, i64 0, i32 5`);
+ const join = B.newLabel("rks.j");
+ const storeScalar = (t: IrType, value: string): void => {
+ const bits = B.tmp();
+ if (t.kind === "f64") B.line(`${bits} = bitcast double ${value} to i64`);
+ else B.line(`${bits} = zext i1 ${value} to i64`);
+ B.line(`store i32 ${armTag(t)}, ptr ${tagPtr}`);
+ B.line(`store i64 ${bits}, ptr ${bitsPtr}`);
+ };
+ const shape = host.recordShape(e.shapeId);
+ for (const f of e.overflowOnly === true ? [] : shape.fields) {
+ const hit = B.tmp();
+ B.line(
+ `${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`,
+ );
+ const lh = B.newLabel("rks.h");
+ const ln = B.newLabel("rks.n");
+ B.condBr(hit, lh, ln);
+ B.startBlock(lh);
+ const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, f.name);
+ if (inline) {
+ const { tag, bits } = loadInlineUnion(B, ptr);
+ const present = presentBits(B, tag, bits, undefinedArmTag(type, host.unionsById));
+ B.line(`store i32 ${tag}, ptr ${tagPtr}`);
+ B.line(`store i64 ${present}, ptr ${bitsPtr}`);
+ } else {
+ storeScalar(type, host.loadRecordField(ptr, type, false));
+ }
+ B.br(join);
+ B.startBlock(ln);
+ }
+ const iv = shape.indexValue;
+ if (iv) {
+ const ovf = host.recordOvfPtr(obj.name, e.shapeId);
+ const outTy = iv.kind === "f64" ? "double" : "i8";
+ const outSlot = B.slot();
+ B.entryAllocas.push(`${outSlot} = alloca ${outTy}`);
+ B.line(`store ${outTy} ${iv.kind === "f64" ? f64Lit(0) : "0"}, ptr ${outSlot}`);
+ const getter = `scr_map_get_str_${iv.kind === "f64" ? "f64" : "bool"}`;
+ host.declare(`declare zeroext i1 @${getter}(ptr, ptr, ptr)`);
+ const found = B.tmp();
+ B.line(`${found} = call zeroext i1 @${getter}(ptr ${ovf}, ptr ${key.name}, ptr ${outSlot})`);
+ const lh = B.newLabel("rks.h");
+ const ln = B.newLabel("rks.n");
+ B.condBr(found, lh, ln);
+ B.startBlock(lh);
+ const raw = B.tmp();
+ B.line(`${raw} = load ${outTy}, ptr ${outSlot}`);
+ let value = raw;
+ if (iv.kind === "bool") {
+ value = B.tmp();
+ B.line(`${value} = trunc i8 ${raw} to i1`);
+ }
+ storeScalar(iv, value);
+ B.br(join);
+ B.startBlock(ln);
+ }
+ // A miss is the undefined arm.
+ B.line(`store i32 ${undefinedArmTag(resultType, host.unionsById)}, ptr ${tagPtr}`);
+ B.line(`store i64 0, ptr ${bitsPtr}`);
+ B.br(join);
+ B.startBlock(join);
+ return box;
+}
+
export function keyedRecordReadInto(
host: LlvmEmitterContext,
slot: string,
diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
index aee7be180..1acc75cec 100644
--- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
+++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts
@@ -16,6 +16,7 @@ import {
KEY_ORDER_FIELD,
shapeKeyOrders,
isDynTypedRefType,
+ textCodecRecordClass,
isRefCounted,
shapeHasAccessorSlots,
typeKey,
@@ -121,12 +122,7 @@ export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): stri
if (inner.kind === "void") {
host.declare(`declare void @scr_promise_fulfill_void(ptr)`);
B.line(`call void @scr_promise_fulfill_void(ptr %dst)`);
- } else if (
- inner.kind === "f64" ||
- inner.kind === "date" ||
- inner.kind === "procStream" ||
- inner.kind === "bool"
- ) {
+ } else if (inner.kind === "f64" || inner.kind === "procStream" || inner.kind === "bool") {
const fn = inner.kind !== "bool" ? "scr_promise_fulfill_f64" : "scr_promise_fulfill_bool";
host.declare(
`declare void @${fn}(ptr, ${host.llType(inner)}${inner.kind === "bool" ? " zeroext" : ""})`,
@@ -597,12 +593,21 @@ export function liveDynUnionRefAdapter(
if (!union) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of unknown union ${t.unionId}`);
}
+ // Codec records box as their own handles, by reference, through toDynHelper.
+ const codecArm = (arm: IrType): boolean =>
+ textCodecRecordClass(arm, (id) => host.recordsById.get(id)) !== null;
const mutableArms = union.arms
.map((arm, tag) => ({ arm, tag }))
.filter(
- ({ arm }) => arm.kind !== "bytes" && (streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
+ ({ arm }) =>
+ arm.kind !== "bytes" &&
+ !codecArm(arm) &&
+ (streamTypedRefEligible(arm) || isDynTypedRefType(arm)),
);
- if (mutableArms.length === 0 && !union.arms.some((arm) => arm.kind === "bytes")) {
+ if (
+ mutableArms.length === 0 &&
+ !union.arms.some((arm) => arm.kind === "bytes" || codecArm(arm))
+ ) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of immutable union ${key}`);
}
@@ -712,7 +717,13 @@ export function streamTypedRefBoxValue(
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${value})`);
return boxed;
}
- if (t.kind === "bytes" || (!streamTypedRefEligible(t) && !isDynTypedRefType(t))) {
+ // A codec record boxes as its own handle, by reference (toDynHelper), as
+ // it does outside a live view.
+ if (
+ t.kind === "bytes" ||
+ textCodecRecordClass(t, (id) => host.recordsById.get(id)) !== null ||
+ (!streamTypedRefEligible(t) && !isDynTypedRefType(t))
+ ) {
const valueTy = host.llType(t);
B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(t)}(${valueTy} ${value})`);
return boxed;
diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts
index eaf8f59f6..dada467a9 100644
--- a/packages/compiler/src/backend/llvm/lib-dispatch.ts
+++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts
@@ -9,7 +9,7 @@ import { emitTestLibCall } from "./lib-test.js";
import { InternalCompilerError } from "../../errors.js";
import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js";
-import type { IrExpr, IrType } from "../../ir/ir.js";
+import { F64, type IrExpr, type IrType } from "../../ir/ir.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { emitJsLibCall } from "./jsval.js";
import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js";
@@ -25,6 +25,9 @@ import { rawBytes } from "./lib-abi.js";
import { emitInlineAtomicsOp } from "./expr-bytes.js";
import { regexLiteralCaptures } from "./regex-matchers.js";
+/** ScrDyn.v.typed_ref.type_key_len: the low 27 bits of its bit-field word. */
+const TYPE_KEY_LEN_MASK = (1 << 27) - 1;
+
export function emitAssertInspectLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "assert.shapeStr" || e.fn === "assert.shapeRe") {
@@ -236,6 +239,15 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
host.emitPendingCheck();
return output;
}
+ if (e.fn === "record.sourceSpread") {
+ const built = host.emitReadReceiver(e.args[0]!);
+ const value = host.emitReadReceiver(e.args[1]!);
+ const type = e.args[0]!.type;
+ if (type.kind !== "record")
+ throw new InternalCompilerError("llvm emitter bug: record.sourceSpread of a non-record");
+ host.dyn.emitRecordSourceSpread(B, built.name, value.name, type.shapeId);
+ return { name: "", type: e.type };
+ }
if (e.fn === "dyn.typedRefIs" || e.fn === "dyn.typedRefKeyIs") {
const literal = e.args[1]!.kind === "strLit" ? e.args[1]!.value : null;
if (e.fn === "dyn.typedRefKeyIs" && literal === null)
@@ -281,6 +293,38 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
B.line(`store i1 true, ptr ${slot}`);
B.condBr(same, done, compare);
B.startBlock(compare);
+ // A capsule with another key pointer usually belongs to another
+ // class: reject it on its key length and last eight bytes before
+ // the out-of-line name comparison, so a class table's chain of
+ // probes costs a few instructions per class it skips.
+ B.line(`store i1 false, ptr ${slot}`);
+ const bytes = Buffer.from(literal, "utf8");
+ const lenSlot = B.tmp(),
+ lenWord = B.tmp(),
+ len = B.tmp(),
+ lenEq = B.tmp();
+ B.line(
+ `${lenSlot} = getelementptr inbounds i8, ptr ${value.name}, i64 ${host.abiOffset(88, 52)} ; ->v.typed_ref.type_key_len`,
+ );
+ B.line(`${lenWord} = load i32, ptr ${lenSlot}`);
+ B.line(`${len} = and i32 ${lenWord}, ${TYPE_KEY_LEN_MASK}`);
+ B.line(`${lenEq} = icmp eq i32 ${len}, ${bytes.length}`);
+ const full = B.newLabel("brand.full");
+ if (bytes.length >= 8) {
+ const tail = B.newLabel("brand.tail");
+ B.condBr(lenEq, tail, done);
+ B.startBlock(tail);
+ const tailPtr = B.tmp(),
+ word = B.tmp(),
+ wordEq = B.tmp();
+ B.line(`${tailPtr} = getelementptr inbounds i8, ptr ${keyPtr}, i64 ${bytes.length - 8}`);
+ B.line(`${word} = load i64, ptr ${tailPtr}, align 1`);
+ B.line(`${wordEq} = icmp eq i64 ${word}, ${bytes.readBigInt64LE(bytes.length - 8)}`);
+ B.condBr(wordEq, full, done);
+ } else {
+ B.condBr(lenEq, full, done);
+ }
+ B.startBlock(full);
}
}
if (e.fn === "dyn.typedRefIs") {
@@ -594,16 +638,66 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
+ // The runtime's Date functions take and answer time values: a Date
+ // operand passes its time value, read at once from the borrowed object,
+ // and a Date result is a new Date object.
const args = READ_ONLY_STRING_LIB_FNS.has(e.fn)
? emitBorrowedInputs(host, e.args)
: e.args.map((a) =>
- borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
- ? emitBorrowedInput(host, a)
- : host.emitExpr(a),
+ a.type.kind === "date"
+ ? { name: emitDateTimeArg(host, a), type: F64 }
+ : borrowsJsonInputs(e.fn) || borrowsIteratorInputs(e.fn)
+ ? emitBorrowedInput(host, a)
+ : host.emitExpr(a),
);
+ // getTime and valueOf answer the time value the argument already is.
+ if ((e.fn === "date.getTime" || e.fn === "date.valueOf") && e.args[0]?.type.kind === "date")
+ return { name: args[0]!.name, type: e.type };
// The deep comparisons' number test: computed here, with no runtime call.
if (e.fn === "assert.sameValue")
return { name: emitSameValueF64(host, args[0]!.name, args[1]!.name), type: e.type };
+ const callArgs = args;
+ if (e.type.kind === "date") {
+ const ms = emitSymbolLibCall(host, { ...e, type: F64 }, sym, callArgs);
+ host.declare(`declare ptr @scr_date_new(double)`);
+ const date = B.tmp();
+ B.line(`${date} = call ptr @scr_date_new(double ${ms.name})`);
+ return host.own({ name: date, type: e.type });
+ }
+ return emitSymbolLibCall(host, e, sym, callArgs);
+}
+
+/** The time value of a Date argument. A Date that a runtime Date function
+ * produces for this argument alone (`new Date(ms).getTime()`, a
+ * time-value local's read) passes the function's time value and creates
+ * no Date object. */
+function emitDateTimeArg(host: LlvmEmitterContext, date: IrExpr): string {
+ // A time-value local's read is its time value itself.
+ if (date.kind === "libCall" && date.fn === "date.fromTime")
+ return host.emitExpr(date.args[0]!).name;
+ if (date.kind === "libCall" && date.fn.startsWith("date."))
+ return emitGenericLibCall(host, { ...date, type: F64 }).name;
+ return emitDateTime(host, emitBorrowedInput(host, date).name);
+}
+
+/** A Date object's time value (ScrDate.ms). */
+function emitDateTime(host: LlvmEmitterContext, date: string): string {
+ const B = host.B;
+ const field = B.tmp();
+ const ms = B.tmp();
+ B.line(`${field} = getelementptr inbounds i8, ptr ${date}, i64 8 ; ->ms`);
+ B.line(`${ms} = load double, ptr ${field}`);
+ return ms;
+}
+
+/** The call of runtime function `sym` with evaluated arguments. */
+function emitSymbolLibCall(
+ host: LlvmEmitterContext,
+ e: LibCallExpr,
+ sym: string,
+ args: readonly LlValue[],
+): LlValue {
+ const B = host.B;
const argDecls = args.map((a) => {
const ty = host.llType(a.type);
return ty === "i1" ? "i1 zeroext" : ty;
@@ -805,6 +899,19 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return { name: t, type: e.type };
}
if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type });
+ if (e.fn === "record.isFrozen") {
+ // The frozen bit of the record's rc word (scr_rc.h SCR_RC_FROZEN) is
+ // its second-highest bit; an immortal word sets the highest one too.
+ const value = host.emitReadReceiver(e.args[0]!);
+ const S = host.sizeType;
+ const rc = host.B.tmp(),
+ top = host.B.tmp(),
+ t = host.B.tmp();
+ host.B.line(`${rc} = load ${S}, ptr ${value.name}`);
+ host.B.line(`${top} = lshr ${S} ${rc}, ${S === "i64" ? 62 : 30}`);
+ host.B.line(`${t} = icmp eq ${S} ${top}, 1`);
+ return { name: t, 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")
@@ -913,6 +1020,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "blob":
case "wasm":
case "array":
+ case "collection":
case "record":
case "disposable":
case "punycode":
diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts
index 3e8a9bff5..1a3973b07 100644
--- a/packages/compiler/src/backend/llvm/lib-process.ts
+++ b/packages/compiler/src/backend/llvm/lib-process.ts
@@ -1,10 +1,11 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
-import { F64, RUNTIME_ERROR_CLASSES, STRING } from "../../ir/ir.js";
+import { F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
import { f64Lit } from "./common.js";
import { mockTimerSym } from "./lib-shared.js";
+import { FN_ATTRS } from "./shapes.js";
export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
@@ -460,9 +461,58 @@ export function emitAsyncContextLibCall(host: LlvmEmitterContext, e: LibCallExpr
for (const a of e.args) host.emitExpr(a);
return { name: "", type: e.type };
}
+ if (e.fn === "als.bindCallback") {
+ // The closure moves into the bound closure, whose entry is the
+ // per-signature thunk; with nothing to bind the runtime answers the
+ // closure itself.
+ if (e.type.kind !== "func")
+ throw new InternalCompilerError("llvm emitter bug: als.bindCallback on non-func");
+ const cb = host.emitExpr(e.args[0]!);
+ const always = host.emitExpr(e.args[1]!);
+ host.toHeap(cb);
+ const thunk = boundCallbackThunk(host, e.type);
+ host.declare("declare ptr @scr_als_bind_closure(ptr, ptr, i1 zeroext)");
+ const t = B.tmp();
+ B.line(
+ `${t} = call ptr @scr_als_bind_closure(ptr ${cb.name}, ptr @${thunk}, i1 ${always.name})`,
+ );
+ return host.own({ name: t, type: e.type });
+ }
return host.emitGenericLibCall(e);
}
+/** The entry of a callback bound to an AsyncLocalStorage context, one per
+ * callback signature: enters the captured context, calls the target with
+ * the same arguments (the callValue ABI) and restores the caller's
+ * context, also when the target leaves an exception pending. */
+function boundCallbackThunk(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
+ const key = `alsbound:${typeKey(cbT)}`;
+ let sym = host.resolveThunks.get(key);
+ if (sym) return sym;
+ sym = `sc_alsbound_${host.resolveThunks.size}`;
+ host.resolveThunks.set(key, sym);
+ host.declare("declare ptr @scr_als_bound_target(ptr)");
+ host.declare("declare ptr @scr_als_bound_enter(ptr)");
+ host.declare("declare void @scr_als_bound_leave(ptr, ptr)");
+ const ret = host.llType(cbT.ret);
+ const params = cbT.params.map((p, i) => `${host.llType(p)} %a${i}`);
+ const call = `call ${ret} %fn(${["ptr %cb", ...params].join(", ")})`;
+ host.resolveThunkDefs.push(
+ `define internal ${ret} @${sym}(${["ptr %self", ...params].join(", ")}) ${FN_ATTRS} { ; AsyncLocalStorage-bound callback ${typeKey(cbT)}`,
+ `entry:`,
+ ` %cb = call ptr @scr_als_bound_target(ptr %self)`,
+ ` %prev = call ptr @scr_als_bound_enter(ptr %self)`,
+ ` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
+ ` %fn = load ptr, ptr %fnp`,
+ ret === "void" ? ` ${call}` : ` %r = ${call}`,
+ ` call void @scr_als_bound_leave(ptr %prev, ptr %cb)`,
+ ret === "void" ? ` ret void` : ` ret ${ret} %r`,
+ `}`,
+ ``,
+ );
+ return sym;
+}
+
export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "process.forkTarget") {
diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts
index af32c6936..61b2b9c5d 100644
--- a/packages/compiler/src/backend/llvm/lib-shared.ts
+++ b/packages/compiler/src/backend/llvm/lib-shared.ts
@@ -333,6 +333,7 @@ export const LIB_FN_SYMS: Record = {
"date.parse": "scr_date_parse",
"date.getTime": "scr_date_get_time",
"date.valueOf": "scr_date_get_time",
+ "date.fromTime": "scr_date_get_time",
"date.parseGetTime": "scr_date_parse",
"date.utc": "scr_date_utc",
"date.newLocal": "scr_date_new_local",
@@ -560,6 +561,7 @@ export const LIB_FN_SYMS: Record = {
"text.decoderEncoding": "scr_text_decoder_encoding",
"text.decoderName": "scr_text_decoder_name",
"text.encodeInto": "scr_text_encode_into",
+ "text.codecIs": "scr_text_codec_is",
"buffer.isAscii": "scr_buffer_is_ascii",
"buffer.isUtf8": "scr_buffer_is_utf8",
"buffer.transcode": "scr_buffer_transcode",
@@ -677,6 +679,7 @@ export const LIB_FN_SYMS: Record = {
"insp.circular": "scr_insp_circular",
// Undeclared source properties kept beside a checked record (scr_json.c).
"record.sourceHas": "scr_record_source_has",
+ "record.sourceSpread": "scr_record_source_spread",
// WHATWG URL + URLSearchParams (scr_url.c / scr_url_params.c):
// constructions +1; url.new, the fileURLToPath pair, the win32
// pathToFileURL flavor, and sp.fromPairs throw catchably (may-throw).
@@ -958,6 +961,7 @@ export const LIB_FN_SYMS: Record = {
"fetch.streamIs": "scr_fetch_stream_is",
"zlib.streamIs": "scr_zlib_stream_is",
"fetch.streamNew": "scr_fetch_stream_new",
+ "fetch.transformNew": "scr_fetch_transform_new",
"fetch.streamFrom": "scr_fetch_stream_from",
"fetch.readerRead": "scr_fetch_reader_read",
"json.parse": "scr_json_parse",
@@ -1019,6 +1023,12 @@ export const LIB_FN_SYMS: Record = {
"class.descriptorHasValue": "scr_class_descriptor_has_value",
"class.throwReadOnly": "scr_class_throw_read_only",
"class.throwNotExtensible": "scr_class_throw_not_extensible",
+ "record.freeze": "scr_record_freeze",
+ "record.throwFrozen": "scr_record_throw_frozen",
+ "array.freeze": "scr_arr_freeze",
+ "array.isFrozen": "scr_arr_is_frozen",
+ "collection.preventExtensions": "scr_map_prevent_extensions",
+ "collection.isExtensible": "scr_map_is_extensible",
"dyn.nativeSetNew": "scr_dyn_native_set_new",
"dyn.nativeSetIs": "scr_dyn_native_set_is",
"dyn.nativeMapIs": "scr_dyn_native_map_is",
@@ -1061,6 +1071,7 @@ export const LIB_FN_SYMS: Record = {
"crypto.generateKeyPairSync": "scr_crypto_generate_key_pair_sync",
"crypto.generateKeyPairCb": "scr_crypto_generate_key_pair_async",
"crypto.signOneshot": "scr_crypto_sign_oneshot",
+ "crypto.diffieHellman": "scr_crypto_diffie_hellman",
"crypto.verifyOneshot": "scr_crypto_verify_oneshot",
"crypto.createSign": "scr_crypto_create_sign",
"crypto.createVerify": "scr_crypto_create_verify",
@@ -1478,6 +1489,10 @@ export const LIB_FN_SYMS: Record = {
"als.exitRun": "scr_als_exit_run",
"als.enterWith": "scr_als_enter_with",
"als.disable": "scr_als_disable",
+ "als.newOptions": "scr_als_new_options",
+ "als.name": "scr_als_name",
+ "als.bindCallbackDyn": "scr_als_bind_dyn",
+ "als.snapshot": "scr_als_snapshot",
"dc.chanBindStore": "scr_dc_chan_bind_store",
"dc.chanUnbindStore": "scr_dc_chan_unbind_store",
"dc.chanRunStores": "scr_dc_chan_run_stores",
@@ -1641,6 +1656,7 @@ export function mockTimerSym(
export const USES_TIMERS_LIB_FNS = new Set([
"crypto.generateKeyPairCb",
+ "crypto.diffieHellman",
"crypto.checkPrimeCb",
"crypto.generatePrimeCb",
"process.emitWarning",
@@ -1691,6 +1707,7 @@ export const USES_TIMERS_LIB_FNS = new Set([
"fetch.abortAny",
"fetch.streamNew",
"fetch.streamFrom",
+ "fetch.transformNew",
"readable.new",
// fs.ReadStream / fs.WriteStream open, read and write on the worker pool.
"fs.createReadStream",
diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts
index 1c540295a..80ed2c440 100644
--- a/packages/compiler/src/backend/llvm/local-array-reads.ts
+++ b/packages/compiler/src/backend/llvm/local-array-reads.ts
@@ -797,7 +797,33 @@ export function emitLocalArrayRead(
);
return null;
});
- else if (inline)
+ else if (
+ inline &&
+ loopIndex !== null &&
+ read.element.kind === "f64" &&
+ host.provenDenseIndex(read.array, read.index)
+ ) {
+ // In a dense array loop's fast copy, the entry test proved the counter
+ // below the array's hole-free prefix: the slot holds a present value,
+ // and the read has no missing arm.
+ const dataPtr = B.tmp(),
+ data = B.tmp(),
+ valuePtr = B.tmp(),
+ value = B.tmp(),
+ width = int32 ? "i32" : "i64";
+ B.line(
+ `${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
+ );
+ host.markMemoryPointer(dataPtr, "array:header");
+ B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
+ B.line(
+ `${valuePtr} = getelementptr inbounds ${width}, ptr ${data}, ${host.sizeType} ${loopIndex}`,
+ );
+ host.markMemoryPointer(valuePtr, "array:elements");
+ B.line(`${value} = load ${width}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
+ storeValue(value);
+ B.br(join);
+ } else if (inline)
emitDenseReferenceArrayRead(
host,
array,
diff --git a/packages/compiler/src/backend/llvm/publish.ts b/packages/compiler/src/backend/llvm/publish.ts
index 60270adda..62fdd75ec 100644
--- a/packages/compiler/src/backend/llvm/publish.ts
+++ b/packages/compiler/src/backend/llvm/publish.ts
@@ -596,10 +596,10 @@ export class PublishWalkers {
}
}
-/** Unpublishable types stored as scalars (dates are time values, process
- * streams file descriptors): no pointer to push, and always a value. */
+/** Unpublishable types stored as scalars (process streams are file
+ * descriptors): no pointer to push, and always a value. */
function scalarRefusal(t: IrType): boolean {
- return t.kind === "date" || t.kind === "procStream";
+ return t.kind === "procStream";
}
/** The Node.js implementation's refusal for a value of type `t`. */
diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts
index 5e1077ee5..2fe9dd66b 100644
--- a/packages/compiler/src/backend/llvm/receivers.ts
+++ b/packages/compiler/src/backend/llvm/receivers.ts
@@ -18,6 +18,7 @@ import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
+import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js";
/** Read a receiver whose value is consumed immediately by a field or tag
@@ -95,6 +96,8 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null;
if (narrowed) return narrowed;
if (canStackUnion(e, emitter.boxedUnionsById)) return emitStackUnion(emitter, e).value;
+ if (e.kind === "recordKeyGet" && scalarRecordKeyGet(emitter, e))
+ return { name: emitStackRecordKeyGet(emitter, e), type: e.type };
if (e.kind === "unionNarrow") {
const union = emitter.emitUnionProjection(e.value);
emitUnionNarrowGuard(emitter, union.name, e.unionId, e.tag);
@@ -410,6 +413,7 @@ export function isStackUnionSource(emitter: LlEmitter, e: IrExpr): boolean {
emitter.optionalArrayReads.get(e) !== null ||
isScalarOptionalSource(e, emitter.unionSources) ||
emitter.scalarResultCall(e) ||
- emitter.inlineFieldGet(e)
+ emitter.inlineFieldGet(e) ||
+ scalarRecordKeyGet(emitter, e)
);
}
diff --git a/packages/compiler/src/backend/llvm/shapes.test.ts b/packages/compiler/src/backend/llvm/shapes.test.ts
index f7327a688..7af16ab83 100644
--- a/packages/compiler/src/backend/llvm/shapes.test.ts
+++ b/packages/compiler/src/backend/llvm/shapes.test.ts
@@ -14,6 +14,7 @@ import {
type ShapeHost,
vAdapters,
} from "./shapes.js";
+import { mangleRecordRelease } from "../mangle.js";
import { NullableUnions } from "./nullable-unions.js";
import { InlineUnionFields } from "./inline-unions.js";
@@ -315,6 +316,42 @@ describe("emitted object releases", () => {
root.indexOf("call void @scr_cyc_on_release(ptr %o)"),
);
});
+
+ test("freezable records count their references without the frozen bit", () => {
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: "frozen.ts",
+ entry: "main",
+ functions: [],
+ records: [
+ { id: "plain", fields: [{ name: "n", type: F64 }] },
+ { id: "frozen", fields: [{ name: "n", type: F64 }], freezable: true },
+ ],
+ };
+ const body = (defs: string, name: string): string =>
+ new RegExp(`^define internal void @${name}\\([^]*?^}`, "m").exec(defs)?.[0] ?? "";
+ for (const [pointerBits, notFrozen, sharedBelow] of [
+ [64, "-4611686018427387905", "-4611686018427387907"],
+ [32, "-1073741825", "-1073741827"],
+ ] as const) {
+ const { host } = declarationHost(pointerBits);
+ const defs = emitRecordShapes({ ...host, objectAudit: false }, mod).defs.join("\n");
+ const S = pointerBits === 64 ? "i64" : "i32";
+ const plain = body(defs, mangleRecordRelease("plain"));
+ expect(plain).toContain(`%shared = icmp ult ${S} %rcm2, -3`);
+ expect(plain).not.toContain("%refs");
+ // The immortal word's count is ~FROZEN, which the compare sends to the
+ // immortal test, like counts 0 and 1.
+ const fast = body(defs, mangleRecordRelease("frozen"));
+ expect(fast).toContain(`%refs = and ${S} %rc, ${notFrozen}`);
+ expect(fast).toContain(`%rcm2 = sub ${S} %refs, 2`);
+ expect(fast).toContain(`%shared = icmp ult ${S} %rcm2, ${sharedBelow}`);
+ expect(fast).toContain(`%imm = icmp eq ${S} %rc, -1`);
+ const slow = body(defs, `${mangleRecordRelease("frozen")}_slow`);
+ expect(slow).toContain(`%refs = and ${S} %n, ${notFrozen}`);
+ expect(slow).toContain(`%dead = icmp eq ${S} %refs, 0`);
+ }
+ });
});
describe("live-object audit notes", () => {
diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts
index d13f80a78..b3f5084d3 100644
--- a/packages/compiler/src/backend/llvm/shapes.ts
+++ b/packages/compiler/src/backend/llvm/shapes.ts
@@ -34,6 +34,7 @@ import {
cycTypeDescriptor,
emitObjectAlloc,
emitObjectFree,
+ emitSegmentFree,
CYC_APPEND,
cycleAppendDecls,
cycleAppendHelper,
@@ -1099,6 +1100,7 @@ export function arrNewCall(
elem.kind === "stats" || // fs.Stats snapshots: scr_stats_* adapters, no trace
elem.kind === "dyn" || // native collection seeds/drains: traced REF
elem.kind === "regex" || // RegExp values: scr_regex_* adapters, no trace (no refs inside)
+ elem.kind === "date" || // Date objects: scr_date_* adapters, no trace (no refs inside)
(elem.kind === "array" && traceAdapter(host, elem) !== null);
if (!useRef) {
if (small) {
@@ -1162,7 +1164,6 @@ const SCR_ARR_OPS_RELEASE_NOTHING = 2;
export function boxNewCall(host: ShapeHost, t: IrType): string {
const plain: Partial> = {
f64: 0,
- date: 0,
procStream: 0,
bool: 1,
string: 2,
@@ -1187,11 +1188,7 @@ export function boxNewCall(host: ShapeHost, t: IrType): string {
/** Box accessor suffix (boxAccess): scalars unboxed, ref kinds pointers. */
export function boxAccess(t: IrType): "f64" | "bool" | "ref" {
- return t.kind === "f64" || t.kind === "date" || t.kind === "procStream"
- ? "f64"
- : t.kind === "bool"
- ? "bool"
- : "ref";
+ return t.kind === "f64" || t.kind === "procStream" ? "f64" : t.kind === "bool" ? "bool" : "ref";
}
/* ── record shapes ────────────────────────────────────────────────────── */
@@ -1207,7 +1204,6 @@ export function llFieldType(
if (POINTER_KINDS.has(t.kind)) return "ptr";
switch (t.kind) {
case "f64":
- case "date":
case "procStream":
return "double";
case "bool":
@@ -1405,17 +1401,23 @@ export interface DirectDestroy {
/** The release of child `value` (a loaded field, possibly NULL) inside a
* teardown whose chain length is `%d`: the child's own release for a shared
* or immortal reference, its teardown for the last one. Labels use `tag`;
- * the lines end in the open block `${tag}.next`. */
+ * the lines end in the open block `${tag}.next`.
+ *
+ * With `segment` (the child has the teardown's own type, in a segmented
+ * teardown; segmentPrologueLines), the child's teardown appends its blocks
+ * to the teardown's segment. */
export function directChildReleaseLines(
host: ShapeHost,
tag: string,
value: string,
target: DirectDestroy,
comment: string,
+ segment = false,
): string[] {
const S = host.sizeType;
const C = target.count ?? S;
- host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
+ if (segment) host.declare(`declare ptr @scr_rc_destroy_seg(ptr, ptr, ptr, ptr)`);
+ else host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const lines = [
` %${tag}.nn = icmp ne ptr ${value}, null`,
` br i1 %${tag}.nn, label %${tag}.chk, label %${tag}.next`,
@@ -1447,16 +1449,139 @@ export function directChildReleaseLines(
` br i1 %${tag}.short, label %${tag}.direct, label %${tag}.bounded`,
`${tag}.direct:`,
` %${tag}.d = add i32 %d, 1`,
- ` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
+ );
+ if (!segment) {
+ lines.push(
+ ` call void @${target.destroy}_d(ptr ${value}, i32 %${tag}.d)`,
+ ` br label %${tag}.next`,
+ `${tag}.bounded:`,
+ ` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
+ ` br label %${tag}.next`,
+ `${tag}.next:`,
+ );
+ return lines;
+ }
+ lines.push(
+ ` %${tag}.t = load ptr, ptr %sg.t`,
+ ` %${tag}.dt = call ptr @${target.destroy}_s(ptr ${value}, i32 %${tag}.d, ptr %${tag}.t)`,
+ ` store ptr %${tag}.dt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.bounded:`,
- ` call void @scr_rc_destroy(ptr ${value}, ptr @${target.destroy})`,
+ ` %${tag}.bt = load ptr, ptr %sg.t`,
+ ` %${tag}.rt = call ptr @scr_rc_destroy_seg(ptr ${value}, ptr @${target.destroy}_sc, ptr @${target.destroy}, ptr %${tag}.bt)`,
+ ` store ptr %${tag}.rt, ptr %sg.t`,
` br label %${tag}.next`,
`${tag}.next:`,
);
return lines;
}
+/* Teardown segments. A typed teardown that frees each block onto its free
+ * list as it goes frees a tree in walk order, and the free lists, which are
+ * LIFO, hand the blocks to the next allocations in the reverse order: a tree
+ * built again in the same order as the last (each generation of a structure
+ * a loop rebuilds) is laid out mirrored, and every walk over it, the next
+ * teardown included, runs through memory backwards, which defeats the
+ * hardware prefetchers. The runtime frees large dead sets of the cycle
+ * collector in address order for the same reason (scr_mem_order_for_free).
+ *
+ * So the teardown of a type with children of its own type (the `_s` form,
+ * segmentedTeardown) keeps the blocks it frees in a segment, linked through
+ * their first words, which it builds in order through a tail pointer: the
+ * `_s` form takes the segment's last link (the first word of its last block,
+ * or the head slot of an empty segment) and returns the new one. A node
+ * first releases its own block (emitObjectFree, keeping it), then appends it
+ * ahead of its children's blocks when its address is the lower, behind them
+ * otherwise (segmentTeardownLines), and the children append theirs in field
+ * order, so a structure
+ * built parent first (object literals allocate before their fields) or
+ * children first (constructor arguments) comes back in ascending order. The
+ * chain's first teardown (`_d`) frees the whole segment onto the class's
+ * free list in one step (emitSegmentFree), and the walks over the next
+ * generation run forward. A teardown past DIRECT_DESTROY_CHAIN appends
+ * through scr_rc_destroy_seg, under the same depth budget as
+ * scr_rc_destroy. Blocks outside the calling thread's arena are freed as
+ * before; one type's blocks are all of one class. */
+
+/** Whether the teardown of a type whose reference fields have `fieldTypes`
+ * (with direct targets `targets`) and whose own teardown is `destroy` keeps
+ * a segment: a child field has its own type, and the inline allocation paths
+ * are in use. */
+export function segmentedTeardown(
+ host: ShapeHost,
+ destroy: string,
+ fieldTypes: readonly IrType[],
+ targets: ReadonlyMap,
+): boolean {
+ if (host.inlineAlloc !== true) return false;
+ return fieldTypes.some((t) => directDestroyTarget(host, t, targets)?.destroy === destroy);
+}
+
+/** The first lines of a segmented teardown, in its entry block: the
+ * segment's last link (`%sg.t`, from the `%tail` argument) and the kept own
+ * block (`%sg.own`, for emitObjectFree). */
+export function segmentPrologueLines(): string[] {
+ return [
+ ` %sg.t = alloca ptr`,
+ ` %sg.own = alloca ptr`,
+ ` store ptr %tail, ptr %sg.t`,
+ ` store ptr null, ptr %sg.own`,
+ ];
+}
+
+/** The rest of a segmented teardown after emitObjectFree kept (or freed)
+ * its own block: the block goes ahead of its children's when its address is
+ * below the first non-NULL one of `children` (loaded fields of the
+ * teardown's own type, which stand for their blocks) or there is none, and
+ * behind them otherwise; then the children's releases (`releases`, given a
+ * label prefix), and the segment's last link is returned. Each order has its
+ * own copy of the releases, so neither keeps the decision, or the block once
+ * it is appended, across the children's teardowns. */
+export function segmentTeardownLines(
+ children: readonly string[],
+ releases: (prefix: string) => string[],
+): string[] {
+ const lines = [` %sg.o = load ptr, ptr %sg.own`, ` %sg.kept = icmp ne ptr %sg.o, null`];
+ let ref = "null";
+ [...children].reverse().forEach((child, i) => {
+ lines.push(
+ ` %sg.nn${i} = icmp ne ptr ${child}, null`,
+ ` %sg.r${i} = select i1 %sg.nn${i}, ptr ${child}, ptr ${ref}`,
+ );
+ ref = `%sg.r${i}`;
+ });
+ lines.push(
+ ` %sg.leaf = icmp eq ptr ${ref}, null`,
+ ` %sg.lower = icmp ult ptr %sg.o, ${ref}`,
+ ` %sg.first = or i1 %sg.leaf, %sg.lower`,
+ ` %sg.ahead = and i1 %sg.kept, %sg.first`,
+ ` br i1 %sg.ahead, label %sg.front, label %sg.back`,
+ `sg.front:`,
+ ...segmentAppendLines("a"),
+ ...releases("ta"),
+ ` br label %sg.ret`,
+ `sg.back:`,
+ ...releases("tb"),
+ ` %sg.keep = icmp ne ptr %sg.o, null`,
+ ` br i1 %sg.keep, label %sg.last, label %sg.ret`,
+ `sg.last:`,
+ ...segmentAppendLines("l"),
+ ` br label %sg.ret`,
+ `sg.ret:`,
+ ` %sg.tr = load ptr, ptr %sg.t`,
+ ` ret ptr %sg.tr`,
+ );
+ return lines;
+}
+
+function segmentAppendLines(tag: string): string[] {
+ return [
+ ` %sg.t${tag} = load ptr, ptr %sg.t`,
+ ` store ptr %sg.o, ptr %sg.t${tag}`,
+ ` store ptr %sg.o, ptr %sg.t`,
+ ];
+}
+
/** The direct-teardown target of a reference of `type`, if `targets` (keyed
* `record:` / `object:`) has one. Nullable unions hold the arm's
* pointer, NULL or an immortal sentinel, which the child release handles. */
@@ -1499,12 +1624,42 @@ export function usedDirectTargets(
/** The teardown definitions for `destroy`: with `chained`, the body takes
* the chain length `%d` (directChildReleaseLines) and the runtime's
- * one-argument entry starts a chain. */
+ * one-argument entry starts a chain. With `segment` (the host, for a
+ * segmented teardown), the body is a segment teardown's
+ * (segmentPrologueLines to segmentTeardownLines), defined as `_s`, with
+ * `_sc` for scr_rc_destroy_seg, and `_d` starts a segment and frees it. */
export function destroyDefinitions(
destroy: string,
body: readonly string[],
chained: boolean,
+ segment: ShapeHost | null = null,
): string[] {
+ if (segment !== null)
+ return [
+ `define internal ptr @${destroy}_s(ptr %o, i32 %d, ptr %tail) ${FN_ATTRS} {`,
+ `entry:`,
+ ...body,
+ `}`,
+ `define internal ptr @${destroy}_sc(ptr %o, ptr %tail) ${FN_ATTRS} {`,
+ `entry:`,
+ ` %t = call ptr @${destroy}_s(ptr %o, i32 0, ptr %tail)`,
+ ` ret ptr %t`,
+ `}`,
+ `define internal void @${destroy}_d(ptr %o, i32 %d) ${FN_ATTRS} {`,
+ `entry:`,
+ ` %head = alloca ptr`,
+ ` store ptr null, ptr %head`,
+ ` %last = call ptr @${destroy}_s(ptr %o, i32 %d, ptr %head)`,
+ ` %first = load ptr, ptr %head`,
+ ...emitSegmentFree(segment, "sgf", "%first", "%last"),
+ ` ret void`,
+ `}`,
+ `define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
+ `entry:`,
+ ` call void @${destroy}_d(ptr %o, i32 0)`,
+ ` ret void`,
+ `}`,
+ ];
if (!chained)
return [
`define internal void @${destroy}(ptr %o) ${FN_ATTRS} {`,
@@ -1527,6 +1682,17 @@ export function destroyDefinitions(
];
}
+/** The frozen bit of a freezable record's rc word (scr_rc.h SCR_RC_FROZEN):
+ * bit 62 on 64-bit targets, bit 30 on 32-bit ones. */
+function recordFrozenBit(sizeType: RcCount): bigint {
+ return 1n << (sizeType === "i64" ? 62n : 30n);
+}
+
+/** `value` as a signed LLVM integer literal of `sizeType`. */
+function sizeLiteral(sizeType: RcCount, value: bigint): string {
+ return BigInt.asIntN(sizeType === "i64" ? 64 : 32, value).toString();
+}
+
/** Common NULL/immortal/decrement skeleton for ordinary object releases.
* `freeBody` owns the zero-ref teardown and must leave the current block at
* its end; traced objects also get the possible-cycle-root branch.
@@ -1543,13 +1709,17 @@ export function releaseBody(
bounded = true,
immortal = false,
chained = false,
+ segmented = false,
+ frozen = false,
): string[] {
const S = host.sizeType;
if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const destroy = `${fnName}_destroy`;
+ // A freezable record's count is the word without its frozen bit.
+ const notFrozen = sizeLiteral(S, ~recordFrozenBit(S));
const lines = [
- ...(bounded ? destroyDefinitions(destroy, freeBody, chained) : []),
- ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal),
+ ...(bounded ? destroyDefinitions(destroy, freeBody, chained, segmented ? host : null) : []),
+ ...releaseFastPath(host, fnName, `${fnName}_slow`, traced, comment, immortal, S, frozen),
`define internal void @${fnName}_slow(ptr %o) noinline ${FN_ATTRS} {${comment ? ` ; ${comment}` : ""}`,
`entry:`,
` %isnull = icmp eq ptr %o, null`,
@@ -1561,7 +1731,9 @@ export function releaseBody(
`dec:`,
` %n = sub ${S} %rc, 1`,
` store ${S} %n, ptr %o`,
- ` %dead = icmp eq ${S} %n, 0`,
+ ...(frozen
+ ? [` %refs = and ${S} %n, ${notFrozen}`, ` %dead = icmp eq ${S} %refs, 0`]
+ : [` %dead = icmp eq ${S} %n, 0`]),
` br i1 %dead, label %free, label %${traced ? "root" : "done"}`,
`free:`,
...(traced ? cycleDeadLines(host, "teardown") : []),
@@ -1591,9 +1763,14 @@ export function releaseFastPath(
comment = "",
immortal = false,
count: RcCount = host.sizeType,
+ frozen = false,
): string[] {
const S = count;
if (immortal) return immortalRcBody(host, fnName, "void", comment);
+ // A freezable record compares its count without the frozen bit: an
+ // immortal word's count is then ~FROZEN, which the same compare sends to
+ // `slow`.
+ const notFrozen = ~recordFrozenBit(S);
return [
`define internal void @${fnName}(ptr %o) ${FN_ATTRS} {${comment ? ` ; ${comment} (fast path)` : ""}`,
`entry:`,
@@ -1601,8 +1778,13 @@ export function releaseFastPath(
` br i1 %isnull, label %done, label %check`,
`check:`,
` %rc = load ${S}, ptr %o`,
- ` %rcm2 = sub ${S} %rc, 2`,
- ` %shared = icmp ult ${S} %rcm2, -3`,
+ ...(frozen
+ ? [
+ ` %refs = and ${S} %rc, ${sizeLiteral(S, notFrozen)}`,
+ ` %rcm2 = sub ${S} %refs, 2`,
+ ` %shared = icmp ult ${S} %rcm2, ${sizeLiteral(S, notFrozen - 2n)}`,
+ ]
+ : [` %rcm2 = sub ${S} %rc, 2`, ` %shared = icmp ult ${S} %rcm2, -3`]),
` br i1 %shared, label %dec, label %slow`,
`dec:`,
` %n = sub ${S} %rc, 1`,
@@ -1780,26 +1962,58 @@ export function emitRecordShapes(
const chained =
directTargets.has(`record:${shape.id}`) ||
refMembers.some((m) => directDestroyTarget(host, m.type, directTargets) !== null);
- for (const m of refMembers) {
- freeBody.push(
- ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
- ` %v${t} = load ptr, ptr %f${t}`,
+ const destroy = `${mangleRecordRelease(shape.id)}_destroy`;
+ const bounded = refMembers.some((member) => needsBoundedRelease(member.type));
+ const segmented =
+ bounded &&
+ segmentedTeardown(
+ host,
+ destroy,
+ refMembers.map((m) => m.type),
+ directTargets,
);
- const direct = directDestroyTarget(host, m.type, directTargets);
- if (direct) {
- freeBody.push(
- ...directChildReleaseLines(host, `td${t}`, `%v${t}`, direct, llvmCommentText(m.name)),
- );
- } else {
- freeBody.push(
- ` call void ${releaseSym(host, m.type)}(ptr %v${t}) ; ${llvmCommentText(m.name)}`,
- );
- }
- t++;
- }
// Closed layouts can carry a kept record source (record-sources.ts).
const observers = recordMayKeepSource(shape) ? "weak-or-source" : "weak";
- freeBody.push(...emitObjectFree(host, traced, observers));
+ // A segmented teardown loads its fields, releases its own block, and
+ // then releases the fields (segmentTeardownLines).
+ const loads: string[] = [];
+ const ownType: string[] = [];
+ const release = (prefix: string, m: (typeof refMembers)[number], i: number): string[] => {
+ const direct = directDestroyTarget(host, m.type, directTargets);
+ if (direct === null)
+ return [` call void ${releaseSym(host, m.type)}(ptr %v${i}) ; ${llvmCommentText(m.name)}`];
+ const own = segmented && direct.destroy === destroy;
+ return directChildReleaseLines(
+ host,
+ `${prefix}${i}`,
+ `%v${i}`,
+ direct,
+ llvmCommentText(m.name),
+ own,
+ );
+ };
+ for (const m of refMembers) {
+ const fieldLines = [
+ ` %f${t} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`,
+ ` %v${t} = load ptr, ptr %f${t}`,
+ ];
+ if (segmented) {
+ loads.push(...fieldLines);
+ if (directDestroyTarget(host, m.type, directTargets)?.destroy === destroy)
+ ownType.push(`%v${t}`);
+ } else freeBody.push(...fieldLines, ...release("td", m, t));
+ t++;
+ }
+ if (segmented)
+ freeBody.push(
+ ...segmentPrologueLines(),
+ ...loads,
+ ...emitObjectFree(host, traced, observers, "%sg.own"),
+ ...segmentTeardownLines(ownType, (prefix) =>
+ refMembers.flatMap((m, i) => release(prefix, m, i)),
+ ),
+ );
+ else freeBody.push(...emitObjectFree(host, traced, observers));
defs.push(
...releaseBody(
host,
@@ -1807,9 +2021,11 @@ export function emitRecordShapes(
traced,
freeBody,
"",
- refMembers.some((member) => needsBoundedRelease(member.type)),
+ bounded,
false,
chained,
+ segmented,
+ shape.freezable === true,
),
``,
);
diff --git a/packages/compiler/src/backend/llvm/stack-captures.ts b/packages/compiler/src/backend/llvm/stack-captures.ts
index f0812b7b0..40e1a0428 100644
--- a/packages/compiler/src/backend/llvm/stack-captures.ts
+++ b/packages/compiler/src/backend/llvm/stack-captures.ts
@@ -105,13 +105,7 @@ export class StackCaptures {
// sites, including duplicated finally bodies and versioned loops.
if (!this.locals.has(id) || this.remaining < 48) return null;
const ref = isRefCounted(type);
- if (
- !ref &&
- type.kind !== "f64" &&
- type.kind !== "date" &&
- type.kind !== "procStream" &&
- type.kind !== "bool"
- )
+ if (!ref && type.kind !== "f64" && type.kind !== "procStream" && type.kind !== "bool")
return null;
this.remaining -= 48;
const B = host.B;
diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts
index c7a58a7ea..ae2b904db 100644
--- a/packages/compiler/src/backend/llvm/stack-objects.ts
+++ b/packages/compiler/src/backend/llvm/stack-objects.ts
@@ -44,8 +44,9 @@ export type StackObjectFieldOwner = (fields: readonly StackObjectField[]) => voi
* standalone scalar class whose constructor never lets `this` escape
* (record-results.ts). The caller proves that every use of the value only
* projects it. Record fields may own references; the frame releases them
- * (stackObjectFields). Published shapes keep heap storage: their field
- * guards treat the immortal header as a frozen object. */
+ * (stackObjectFields). Published and freezable shapes keep heap storage:
+ * their field guards treat the immortal header as a frozen object, and a
+ * frozen record keeps its frozen bit in a counted header. */
export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackObjectSource {
// A ThreadSlots key keeps its slot index for its lifetime and has its
// values released when it is freed: it lives on the heap.
@@ -53,6 +54,8 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO
return false;
if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true)
return false;
+ if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true)
+ return false;
// A producer with a result-slot entry builds its fresh result in the
// caller's storage.
if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee));
diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts
index 331ea2199..58df3e845 100644
--- a/packages/compiler/src/backend/llvm/walkers.ts
+++ b/packages/compiler/src/backend/llvm/walkers.ts
@@ -234,6 +234,10 @@ export class LlWalkers {
this.host.declare(`declare void @scr_jb_put_json_str(ptr, ptr)`);
B.line(`call void @scr_jb_put_json_str(ptr %b, ptr %v)`);
break;
+ case "date":
+ this.host.declare(`declare void @scr_jb_put_date(ptr, ptr)`);
+ B.line(`call void @scr_jb_put_date(ptr %b, ptr %v) ; toJSON(): ISO text, null if invalid`);
+ break;
case "dyn":
// A checked-dynamic value serializes itself (runtime walker).
// Bare stringify of dyn stays frontend-fenced, and overflow
@@ -927,6 +931,17 @@ export class LlWalkers {
case "string":
this.putScrStr(B, buf, this.unionPeek(B, u, unionId));
break;
+ case "date": {
+ host.declare(`declare ptr @scr_date_object_to_string(ptr)`);
+ host.declare(`declare void @scr_str_release(ptr)`);
+ const text = B.tmp();
+ B.line(
+ `${text} = call ptr @scr_date_object_to_string(ptr ${this.unionPeek(B, u, unionId)})`,
+ );
+ this.putScrStr(B, buf, text);
+ B.line(`call void @scr_str_release(ptr ${text})`);
+ break;
+ }
case "f64": {
host.declare(`declare double @scr_union_get_f64(ptr)`);
host.declare(`declare void @scr_jb_put_number(ptr, double)`);
diff --git a/packages/compiler/src/backend/native-codegen-core.ts b/packages/compiler/src/backend/native-codegen-core.ts
index 5b9437cde..dd17466eb 100644
--- a/packages/compiler/src/backend/native-codegen-core.ts
+++ b/packages/compiler/src/backend/native-codegen-core.ts
@@ -6,19 +6,29 @@ export const NATIVE_CODEGEN_LLVM_VERSION = "22.1.8";
export type NativeCodegenOutputKind = "asm" | "obj";
-/** LLVM text per partition, and the most partitions one executable uses. */
+/** LLVM text that stays one module, LLVM text per partition, the fewest
+ * partitions (MIN_PROGRAM_PARTITIONS, of at least MIN_PARTITION_BYTES each)
+ * a divided module gets, and the most one executable uses. */
+const SINGLE_PARTITION_BYTES = 1024 * 1024;
const PROGRAM_PARTITION_BYTES = 4 * 1024 * 1024;
+const MIN_PARTITION_BYTES = 512 * 1024;
+const MIN_PROGRAM_PARTITIONS = 8;
const MAX_PROGRAM_PARTITIONS = 64;
-/** Executable objects this large are divided into partitions that the helper
- * simplifies, optimizes and compiles concurrently, importing small functions
- * across partitions for inlining, and reuses individually across builds. The
+/** Executable objects larger than SINGLE_PARTITION_BYTES are divided into
+ * partitions that the helper simplifies, optimizes and compiles concurrently,
+ * importing small functions across partitions for inlining, and reuses
+ * individually across builds. A divided module gets at least
+ * MIN_PROGRAM_PARTITIONS so that mid-size programs use several cores. The
* count is a power of two that depends only on the module's size, on every
* target, so artifacts never vary with the host and ordinary edits keep every
* function in its partition. */
export function nativeProgramPartitions(llvmBytes: number): number {
- const required = Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES);
- if (required < 2) return 1;
+ if (llvmBytes <= SINGLE_PARTITION_BYTES) return 1;
+ const required = Math.max(
+ Math.ceil(llvmBytes / PROGRAM_PARTITION_BYTES),
+ Math.min(MIN_PROGRAM_PARTITIONS, Math.ceil(llvmBytes / MIN_PARTITION_BYTES)),
+ );
return Math.min(MAX_PROGRAM_PARTITIONS, 2 ** Math.ceil(Math.log2(required)));
}
diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts
index 545711a60..5463b868e 100644
--- a/packages/compiler/src/backend/native-codegen.test.ts
+++ b/packages/compiler/src/backend/native-codegen.test.ts
@@ -352,11 +352,13 @@ test("sanitized native artifacts fail before helper resolution", async () => {
test("program partitions depend only on module size", () => {
const megabyte = 1024 * 1024;
- expect(nativeProgramPartitions(4 * megabyte)).toBe(1);
- expect(nativeProgramPartitions(4 * megabyte + 1)).toBe(2);
+ expect(nativeProgramPartitions(megabyte)).toBe(1);
+ expect(nativeProgramPartitions(megabyte + 1)).toBe(4);
+ expect(nativeProgramPartitions(2 * megabyte)).toBe(4);
+ expect(nativeProgramPartitions(2 * megabyte + 1)).toBe(8);
+ expect(nativeProgramPartitions(32 * megabyte)).toBe(8);
// Powers of two keep every function in its partition across small edits.
- expect(nativeProgramPartitions(13 * megabyte)).toBe(4);
- expect(nativeProgramPartitions(16 * megabyte)).toBe(4);
+ expect(nativeProgramPartitions(32 * megabyte + 1)).toBe(16);
expect(nativeProgramPartitions(100 * megabyte)).toBe(32);
expect(nativeProgramPartitions(1024 * megabyte)).toBe(64);
expect(nativePartitionPaths("/build.d/program.o", 3)).toEqual([
diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts
index e018037fd..c073bb9a6 100644
--- a/packages/compiler/src/backend/native-link-info-core.ts
+++ b/packages/compiler/src/backend/native-link-info-core.ts
@@ -81,6 +81,9 @@ export interface NativeLinkFeatures {
dnsPromises: boolean;
/** dgram sockets as untyped values: scr_dgram_dyn.c. */
dgramDyn: boolean;
+ /** TextEncoder and TextDecoder records as untyped values:
+ * scr_text_codec_dyn.c. */
+ textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts
index 61de82e72..53e160984 100644
--- a/packages/compiler/src/backend/native/contracts.ts
+++ b/packages/compiler/src/backend/native/contracts.ts
@@ -220,6 +220,10 @@ export interface CcOptions {
* scr_dgram_dyn.c over the dgram unit, so typed dgram binaries keep
* their exact link line. */
dgramDyn?: boolean;
+ /** A TextEncoder or TextDecoder record converts to or from an untyped
+ * value: compiles scr_text_codec_dyn.c, so typed codec binaries keep
+ * their exact link line. */
+ textCodecDyn?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts
index 233b9cac2..1578a0c80 100644
--- a/packages/compiler/src/backend/native/executable.ts
+++ b/packages/compiler/src/backend/native/executable.ts
@@ -302,6 +302,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dgram, "scr_dgram.c"),
unit((u) => u.opts.dgram && u.opts.dnsPromises, "scr_dns_promises.c"),
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
+ unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
diff --git a/packages/compiler/src/backend/publication.test.ts b/packages/compiler/src/backend/publication.test.ts
index 149ea4bb9..7b3e5f5cc 100644
--- a/packages/compiler/src/backend/publication.test.ts
+++ b/packages/compiler/src/backend/publication.test.ts
@@ -85,3 +85,34 @@ test("published types close over fields, containers and whole hierarchies", () =
computePublishedTypes({ ...withStores, functions: withStores.functions.slice(1) }),
).toBeNull();
});
+
+test("freezable array types carry the array write guard without a publish", () => {
+ const numbers = arrayOf(F64);
+ const push: IrStmt = {
+ kind: "exprStmt",
+ expr: {
+ kind: "arrIntrinsic",
+ method: "push",
+ receiver: ref("a", numbers),
+ args: [{ kind: "numLit", value: 1, type: F64, loc }],
+ type: F64,
+ loc,
+ },
+ loc,
+ };
+ const mod: IrModule = {
+ irVersion: 17,
+ sourceFile: loc.file,
+ entry: "main",
+ functions: [fn("main", []), fn("pushNumber", [push])],
+ freezableArrays: ["array"],
+ };
+ const guarded = computePublishedTypes(mod)!;
+ expect(guarded.containerGuarded(numbers)).toBe(true);
+ expect(guarded.arrayGuards(numbers)).toEqual({ published: false, frozen: true });
+ expect(guarded.containerGuarded(arrayOf(STRING))).toBe(false);
+ // A frozen array's push throws, so the function may throw.
+ expect(computeMayThrow(mod).fns.has("pushNumber")).toBe(true);
+ const { freezableArrays: _frozen, ...plain } = mod;
+ expect(computePublishedTypes(plain)).toBeNull();
+});
diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts
index 473a0cba4..38e429a47 100644
--- a/packages/compiler/src/backend/publication.ts
+++ b/packages/compiler/src/backend/publication.ts
@@ -46,6 +46,10 @@ export class PublishedTypes {
readonly records = new Set();
/** typeKeys of array, map and set types. */
readonly containers = new Set();
+ /** typeKeys of the array types the program may freeze
+ * (IrModule.freezableArrays): a frozen array refuses writes like a
+ * published one, so their stores carry the same guard. */
+ readonly frozenArrays = new Set();
/** Constructor stores into a `this` nothing else can reach yet
* (immutability.ts): never a published target, so never guarded. */
privateStores: ReadonlySet = new Set();
@@ -64,10 +68,16 @@ export class PublishedTypes {
}
containerGuarded(t: IrType): boolean {
- return (
- (t.kind === "array" || t.kind === "map" || t.kind === "set") &&
- this.containers.has(typeKey(t))
- );
+ if (t.kind !== "array" && t.kind !== "map" && t.kind !== "set") return false;
+ const key = typeKey(t);
+ return this.containers.has(key) || this.frozenArrays.has(key);
+ }
+
+ /** Whether an array of type `t` may be published, and whether it may be
+ * frozen: the two states its write guard tests. */
+ arrayGuards(t: IrType): { published: boolean; frozen: boolean } {
+ const key = typeKey(t);
+ return { published: this.containers.has(key), frozen: this.frozenArrays.has(key) };
}
}
@@ -87,7 +97,13 @@ export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): Publi
)
roots.push(node.type);
});
- if (roots.length === 0) return null;
+ const frozenArrays = mod.freezableArrays ?? [];
+ if (roots.length === 0 && frozenArrays.length === 0) return null;
+ if (roots.length === 0) {
+ const frozen = new PublishedTypes();
+ for (const key of frozenArrays) frozen.frozenArrays.add(key);
+ return frozen;
+ }
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const children = new Map();
@@ -100,6 +116,7 @@ export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): Publi
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const out = new PublishedTypes();
+ for (const key of frozenArrays) out.frozenArrays.add(key);
out.privateStores = computeConstructionFacts(mod, nodes).privateStores;
const seen = new Set();
const stack = [...roots];
diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts
index 44c3d6290..9846e8d89 100644
--- a/packages/compiler/src/backend/runtime-pack-targets.test.ts
+++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts
@@ -118,6 +118,7 @@ const features = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index e3bc999a7..e2b24f8ca 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -54,6 +54,7 @@ const BASE: NativeLinkFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/backend/warm-cache.ts b/packages/compiler/src/backend/warm-cache.ts
index b48cf6739..02acb5462 100644
--- a/packages/compiler/src/backend/warm-cache.ts
+++ b/packages/compiler/src/backend/warm-cache.ts
@@ -61,6 +61,7 @@ export async function warmNativeCaches(
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts
index c4d1e3f50..ff7cedb22 100644
--- a/packages/compiler/src/compat/fetch-profile.ts
+++ b/packages/compiler/src/compat/fetch-profile.ts
@@ -900,7 +900,7 @@ export const NODE24_FETCH_COMPAT_PROFILE = {
{
name: "WritableStream/TransformStream",
reason:
- "native support currently covers readable fetch bodies; general Web Streams graphs are unsupported",
+ "native support covers readable fetch bodies and the identity `new TransformStream()`; the WritableStream constructor, transformers, and general Web Streams graphs are unsupported",
},
{
name: "Readable byte/BYOB streams",
diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts
index 625ee93af..2fd8b6fb0 100644
--- a/packages/compiler/src/diagnostics/diagnostic.ts
+++ b/packages/compiler/src/diagnostics/diagnostic.ts
@@ -656,7 +656,7 @@ export function genericSignatureTypeDiag(typeText: string, loc: SrcLoc): ScrDiag
export function indexSignatureTypeDiag(typeText: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC2006",
- message: `values of type '${typeText}' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown')`,
+ message: `values of type '${typeText}' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Dates, Promises, or 'unknown')`,
loc,
milestone: "M2",
hint: "string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering",
diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts
index 5cf8c6588..e066c0412 100644
--- a/packages/compiler/src/executable/executable-cache.test.ts
+++ b/packages/compiler/src/executable/executable-cache.test.ts
@@ -58,6 +58,7 @@ const native: EarlyExecutableNativeFeatures = {
dgram: false,
dnsPromises: false,
dgramDyn: false,
+ textCodecDyn: false,
watch: false,
foreignFfi: false,
nodeTest: false,
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index 7b7febc06..96922ca13 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -92,6 +92,7 @@ export interface EarlyExecutableNativeFeatures {
dgram: boolean;
dnsPromises: boolean;
dgramDyn: boolean;
+ textCodecDyn: boolean;
watch: boolean;
foreignFfi: boolean;
nodeTest: boolean;
@@ -235,6 +236,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgram",
"dnsPromises",
"dgramDyn",
+ "textCodecDyn",
"watch",
"foreignFfi",
"nodeTest",
diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts
index 314cdfd24..f191ad942 100644
--- a/packages/compiler/src/frontend/lowering/array-values.ts
+++ b/packages/compiler/src/frontend/lowering/array-values.ts
@@ -15,6 +15,7 @@ import {
typeKey,
unionContainerArmsOk,
} from "../../ir/ir.js";
+import { everyExpr } from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
import type * as ts from "../ts7/adapter.js";
@@ -193,6 +194,81 @@ export function unionArrayValueRead(
return result;
}
+/** Expression kinds that evaluate without running other code or writing
+ * a binding. */
+const PURE_KINDS = new Set([
+ "varRef",
+ "numLit",
+ "strLit",
+ "boolLit",
+ "unitLit",
+ "bin",
+ "unary",
+ "logical",
+ "ternary",
+ "toBool",
+ "fieldGet",
+ "recordGet",
+ "arrayGet",
+ "arrayState",
+ "arrayHas",
+ "unionIsTag",
+ "unionNarrow",
+ "unionWrap",
+]);
+
+/** The locals evaluating `value` may assign, and whether it may run other
+ * code (anything but the pure kinds and the read-only element helpers),
+ * which can assign locals and fields through closures. */
+function valueEffects(lowerer: Lowerer, value: IrExpr): { writes: Set; opaque: boolean } {
+ const readers = new Set();
+ for (const [key, name] of lowerer.arrHofHelpers) if (key.startsWith("idxOr:")) readers.add(name);
+ const writes = new Set();
+ let opaque = false;
+ everyExpr(value, {
+ expr: (e) => {
+ if (e.kind === "assignExpr" || e.kind === "incDec") writes.add(e.localId);
+ else if (e.kind === "call" ? !readers.has(e.callee) : !PURE_KINDS.has(e.kind)) opaque = true;
+ return true;
+ },
+ stmt: (s) => {
+ if (s.kind === "varDecl" || s.kind === "assign") writes.add(s.localId);
+ else opaque = true;
+ return true;
+ },
+ });
+ return { writes, opaque };
+}
+
+/** Whether evaluating `e` after `value` gives what it gives before it: a
+ * literal, or a local and field read `value` cannot change. */
+function unaffectedBy(e: IrExpr, effects: { writes: Set; opaque: boolean }): boolean {
+ switch (e.kind) {
+ case "numLit":
+ case "strLit":
+ return true;
+ case "varRef":
+ return !effects.writes.has(e.localId);
+ case "bin":
+ return unaffectedBy(e.left, effects) && unaffectedBy(e.right, effects);
+ case "fieldGet":
+ case "recordGet":
+ return !effects.opaque && unaffectedBy(e.obj, effects);
+ default:
+ return false;
+ }
+}
+
+/** Whether `e` reads a `let` or `var` binding, which code the value runs
+ * can assign through a closure. */
+function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean {
+ return !everyExpr(e, {
+ expr: (x) =>
+ x.kind !== "varRef" || lowerer.ctx.locals.find((l) => l.id === x.localId)?.mutable === false,
+ stmt: () => true,
+ });
+}
+
/** Store a stabilized value without changing the array's element ABI. */
export function arrayValueStore(
lowerer: Lowerer,
@@ -210,10 +286,31 @@ export function arrayValueStore(
// owned union temporary to release on every path.
const unionId = value.type.unionId;
let stable = value;
- let prefix: IrStmt[] = [];
+ const prefix: IrStmt[] = [];
+ // The stores below evaluate the receiver and the index after the value.
+ // JavaScript evaluates them first (`out[n++] = src[n]` reads src[1]), so
+ // either may need its value held before the value runs.
+ if (value.kind !== "varRef") {
+ const valueFx = valueEffects(lowerer, value);
+ const hold = (e: IrExpr, name: string, effects: typeof valueFx): IrExpr => {
+ if (unaffectedBy(e, effects) && !(effects.opaque && readsMutableLocal(lowerer, e)))
+ return e;
+ const temp = lowerer.declareHiddenLocal(name, e.type);
+ prefix.push({ kind: "varDecl", localId: temp.id, init: e, loc });
+ return varRef(temp.id, temp.type, loc);
+ };
+ // The receiver runs before the index and the value, so the index's
+ // effects can change it too (`a[(a = b, 0)] = c[0]!`).
+ const indexFx = valueEffects(lowerer, index);
+ arr = hold(arr, "%arrayTarget", {
+ writes: new Set([...valueFx.writes, ...indexFx.writes]),
+ opaque: valueFx.opaque || indexFx.opaque,
+ });
+ index = hold(index, "%arrayIndex", valueFx);
+ }
if (value.kind !== "varRef") {
const temp = lowerer.declareHiddenLocal("%arrayValue", value.type);
- prefix = [{ kind: "varDecl", localId: temp.id, init: value, loc }];
+ prefix.push({ kind: "varDecl", localId: temp.id, init: value, loc });
stable = varRef(temp.id, temp.type, loc);
}
const write: IrStmt = {
diff --git a/packages/compiler/src/frontend/lowering/builtins/async-context.ts b/packages/compiler/src/frontend/lowering/builtins/async-context.ts
index 18bd93a86..a130df59b 100644
--- a/packages/compiler/src/frontend/lowering/builtins/async-context.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/async-context.ts
@@ -2,7 +2,7 @@ import { isBuiltinClassInstance } from "./receiver-types.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
-import { locOf } from "../../program.js";
+import { isJsSourceFile, locOf } from "../../program.js";
import {
BOOL,
DYN,
@@ -184,16 +184,89 @@ export function lowerDiagnosticsChannelMethodCall(
);
}
+/** Whether `node` names the AsyncLocalStorage class itself. */
+function isAsyncLocalStorageClass(lowerer: Lowerer, node: ts.Expression): boolean {
+ if (!ts.isIdentifier(node)) return false;
+ const symbol = lowerer.resolveValueSymbol(node);
+ return (
+ symbol !== null &&
+ lowerer.isStdlibSymbol(symbol) &&
+ ts.symbolName(symbol) === "AsyncLocalStorage"
+ );
+}
+
+/** The static AsyncLocalStorage members: bind(fn) answers fn bound to the
+ * active context (its own type for a typed function; a dyn function in
+ * JavaScript files, where the bound function's name and receiver are
+ * observable, and for dyn values); snapshot() answers a dyn function
+ * (cb, ...args) that runs cb in the context active at the snapshot. */
+function lowerAsyncLocalStorageStaticCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+): IrExpr {
+ const name = access.name.text;
+ const loc = locOf(call);
+ const always: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
+ if (name === "bind" && call.arguments.nodes.length === 1) {
+ const arg = call.arguments.nodes[0]!;
+ if (!isJsSourceFile(ts.getSourceFileOfNode(call))) {
+ const fn = lowerer.lowerExpr(arg);
+ if (fn.type.kind === "func")
+ return { kind: "libCall", fn: "als.bindCallback", args: [fn, always], type: fn.type, loc };
+ }
+ return asyncLocalStorageResult(lowerer, call, {
+ kind: "libCall",
+ fn: "als.bindCallbackDyn",
+ args: [lowerDiagnosticsMessage(lowerer, arg), always],
+ type: DYN,
+ loc,
+ });
+ }
+ if (name === "snapshot" && call.arguments.nodes.length === 0)
+ return { kind: "libCall", fn: "als.snapshot", args: [], type: DYN, loc };
+ lowerer.noLowering(
+ `AsyncLocalStorage.${name}`,
+ call,
+ "AsyncLocalStorage.bind(fn) and AsyncLocalStorage.snapshot() are the supported static members",
+ lowerer.checker.getSymbolAtLocation(access.name),
+ );
+}
+
+/** new AsyncLocalStorage(options): the options object as a dyn value
+ * (plain property literals build it member by member). */
+export function lowerAsyncLocalStorageOptions(lowerer: Lowerer, node: ts.Expression): IrExpr {
+ return lowerTracingHandlers(lowerer, node);
+}
+
+/** `.name` on AsyncLocalStorage receivers: the constructor's name option
+ * (als.name), converted to the property's type. Null otherwise. */
+export function lowerAsyncLocalStorageProperty(
+ lowerer: Lowerer,
+ access: ts.PropertyAccessExpression,
+): IrExpr | null {
+ if (access.questionDotToken || access.name.text !== "name") return null;
+ if (!isBuiltinClassInstance(lowerer, access.expression, "AsyncLocalStorage")) return null;
+ if (!lowerer.isStdlibMember(access)) return null;
+ const loc = locOf(access);
+ const receiver = lowerer.lowerExprExpecting(access.expression, F64);
+ const value: IrExpr = { kind: "libCall", fn: "als.name", args: [receiver], type: DYN, loc };
+ const expected = lowerer.mapTypeOf(lowerer.typeOf(access));
+ return !expected || expected.kind === "dyn" ? value : lowerer.coerceInto(access, value, expected);
+}
+
/** Method calls on AsyncLocalStorage receivers: run(store, fn, ...args),
* exit(fn, ...args), getStore(), enterWith(store), disable() — over the
- * f64 store handle (als.* libCalls; values cross as dyn values). Null
- * for non-ALS receivers. */
+ * f64 store handle (als.* libCalls; values cross as dyn values) — and the
+ * static bind/snapshot members. Null for other receivers. */
export function lowerAsyncLocalStorageMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
+ if (isAsyncLocalStorageClass(lowerer, access.expression))
+ return lowerAsyncLocalStorageStaticCall(lowerer, call, access);
if (!isBuiltinClassInstance(lowerer, access.expression, "AsyncLocalStorage")) return null;
if (!lowerer.isStdlibMember(access)) return null;
const name = access.name.text;
diff --git a/packages/compiler/src/frontend/lowering/builtins/crypto.ts b/packages/compiler/src/frontend/lowering/builtins/crypto.ts
index 051ec18c4..15c057462 100644
--- a/packages/compiler/src/frontend/lowering/builtins/crypto.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/crypto.ts
@@ -1454,6 +1454,7 @@ function lowerPkiCall(
],
sign: ["crypto.signOneshot", 3, 3, "sign(algorithm, data, key)"],
verify: ["crypto.verifyOneshot", 4, 4, "verify(algorithm, data, key, signature)"],
+ diffieHellman: ["crypto.diffieHellman", 1, 2, "diffieHellman(options[, callback])"],
createSign: ["crypto.createSign", 1, 1, "createSign(algorithm)"],
createVerify: ["crypto.createVerify", 1, 1, "createVerify(algorithm)"],
};
@@ -1474,6 +1475,7 @@ function lowerPkiCall(
return { kind: "libCall", fn, args: values, type: VOID, loc };
const call: IrExpr = { kind: "libCall", fn, args: values, type: DYN, loc };
const result = lowerer.mapTypeOf(lowerer.typeOf(expr));
- if (result === null || result.kind === "dyn") return call;
+ // diffieHellman's callback form answers undefined.
+ if (result === null || result.kind === "dyn" || result.kind === "void") return call;
return lowerer.coerceInto(expr, call, result);
}
diff --git a/packages/compiler/src/frontend/lowering/builtins/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts
index a598f4ee6..0204386ab 100644
--- a/packages/compiler/src/frontend/lowering/builtins/dates.ts
+++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts
@@ -53,7 +53,13 @@ export function lowerDateCall(
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
- if (call.questionDotToken || access.questionDotToken) return null;
+ // An optional chain lowers the call again once it has narrowed the
+ // receiver (Lowerer.chainHandled).
+ if (
+ (call.questionDotToken && !lowerer.chainHandled.has(call)) ||
+ (access.questionDotToken && !lowerer.chainHandled.has(access))
+ )
+ return null;
const loc = locOf(call);
const member = lowerer.stdlibGlobalMember(access, "Date");
if (member === "now" || member === "parse" || member === "UTC")
diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts
index dacb4d324..c9fd954b5 100644
--- a/packages/compiler/src/frontend/lowering/call-signatures.ts
+++ b/packages/compiler/src/frontend/lowering/call-signatures.ts
@@ -611,7 +611,6 @@ export function checkDefaultParamBodyType(
if (
bodyType.kind === "void" ||
bodyType.kind === "regex" ||
- bodyType.kind === "date" ||
bodyType.kind === "dyn" ||
isUnitType(bodyType)
) {
diff --git a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
index ae8cce4ca..5e5f24e32 100644
--- a/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
+++ b/packages/compiler/src/frontend/lowering/cjs-exports-object.ts
@@ -381,7 +381,7 @@ function esModuleExportIgnoresThis(
depth: number,
): boolean {
const moduleSym = lowerer.checker.getSymbolAtLocation(sf);
- const exported = moduleSym?.getExports().get(name as ts.__String);
+ const exported = moduleSym?.getExports().get(ts.escapedSymbolName(name));
if (exported !== undefined) {
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
index ab3ebb34e..abf03811e 100644
--- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
+++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts
@@ -282,6 +282,11 @@ interface PropertyDispatch {
fn: IrFunction;
classes: Set;
reflect?: boolean;
+ /** The helper's fallthrough statements as created, and the same access
+ * applied to the receiver itself: what the fallthrough reduces to for a
+ * value that is neither a class capsule nor a function, whose bag is the
+ * value. */
+ plain?: { tail: readonly IrStmt[]; access: IrStmt[] };
}
export function classInstanceOf(
@@ -2724,7 +2729,28 @@ export class ClassDynamicDispatch {
loc,
});
}
- dispatch = { name, write: !!write, fn: helper, classes: new Set() };
+ const receiverAccess: IrStmt[] = read
+ ? [{ kind: "return", value: { ...read, value: varRef("p.0", DYN, loc) }, loc }]
+ : [
+ {
+ kind: "exprStmt",
+ expr: {
+ ...write!,
+ args: [varRef("p.0", DYN, loc), write!.args[1]!, varRef("p.1", DYN, loc)],
+ },
+ loc,
+ },
+ { kind: "return", value: null, loc },
+ ];
+ dispatch = {
+ name,
+ write: !!write,
+ fn: helper,
+ classes: new Set(),
+ ...(name !== "constructor" && name !== "toString"
+ ? { plain: { tail: [...helper.body], access: receiverAccess } }
+ : {}),
+ };
this.properties.set(key, dispatch);
this.generated.add(helper);
lowerer.liftedFns.push(helper);
@@ -4896,6 +4922,7 @@ export class ClassDynamicDispatch {
);
}
if (lowerer) this.guardClassTables(lowerer);
+ if (lowerer) this.guardPropertyHelpers(lowerer);
}
/** Each tracked class's bag initializer over a typed receiver (`this`),
@@ -4985,6 +5012,87 @@ export class ClassDynamicDispatch {
}
}
+ /** Fast exits for the generated property helpers: when every class
+ * branch tests a capsule key or a class key and the fallthrough is still
+ * the bag access the helper was created with, a value of another kind
+ * (a plain object, array or primitive) normalizes to itself, has itself
+ * as its bag, and so gets the plain access directly, without the
+ * normalization, bag and static-bag calls. */
+ private guardPropertyHelpers(lowerer: Lowerer): void {
+ // Every class table the fallthrough or normalization calls must answer
+ // a plain value with the value itself: its kind exit is in place.
+ const exits = (name: string): boolean => {
+ const helper = lowerer.liftedFnNamed(name);
+ const first = helper?.body[0];
+ const param = helper?.params[0]?.localId;
+ return (
+ param !== undefined &&
+ first?.kind === "if" &&
+ first.cond.kind === "dynTest" &&
+ first.cond.negated === true &&
+ first.then.length === 1 &&
+ first.then[0]!.kind === "return" &&
+ first.then[0]!.value !== null &&
+ returnsParam(first.then[0]!.value, param)
+ );
+ };
+ const tables = new Set([
+ "%dyn.class.properties",
+ READ_PROPERTIES,
+ READ_PROPERTIES_OR_EMPTY,
+ "%dyn.class.normalize",
+ "%dyn.class.staticProperties",
+ ]);
+ const callsOnlyGuardedTables = (statements: IrStmt[]): boolean =>
+ everyStmtList(statements, {
+ stmt: () => true,
+ expr: (expression) =>
+ expression.kind !== "call" || (tables.has(expression.callee) && exits(expression.callee)),
+ });
+ for (const dispatch of this.properties.values()) {
+ const plain = dispatch.plain;
+ if (!plain) continue;
+ const fn = dispatch.fn;
+ const param = fn.params[0]?.localId;
+ if (param === undefined) continue;
+ const head = fn.body.length - plain.tail.length;
+ if (head < 0 || plain.tail.some((stmt, i) => fn.body[head + i] !== stmt)) continue;
+ let ok = true;
+ for (const stmt of fn.body.slice(0, head)) {
+ if (
+ stmt.kind === "assign" &&
+ stmt.localId === param &&
+ stmt.value.kind === "call" &&
+ stmt.value.callee === "%dyn.class.normalize"
+ )
+ continue;
+ if (stmt.kind === "if" && stmt.else_ === null && branchKind(stmt.cond, param) !== null)
+ continue;
+ ok = false;
+ break;
+ }
+ // Class branches never run for a plain value; the rest always does.
+ if (!ok || !callsOnlyGuardedTables(fn.body.filter((stmt) => stmt.kind !== "if"))) continue;
+ const loc = fn.loc;
+ const not = (test: "classCapsule" | "funcKind"): IrExpr => ({
+ kind: "dynTest",
+ test,
+ negated: true,
+ value: varRef(param, DYN, loc),
+ type: BOOL,
+ loc,
+ });
+ fn.body.unshift({
+ kind: "if",
+ cond: not("classCapsule"),
+ then: [{ kind: "if", cond: not("funcKind"), then: plain.access, else_: null, loc }],
+ else_: null,
+ loc,
+ });
+ delete dispatch.plain;
+ }
+ }
+
private normalizedDispatches(): IrFunction[] {
return [
...(this.propertyBag ? [this.propertyBag] : []),
diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts
index 035c2c2a5..0ec97ee69 100644
--- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts
+++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts
@@ -46,6 +46,13 @@ export function collectClassMethodMutations(
const typedClasses: (ts.ClassDeclaration | ts.ClassExpression)[] = [];
const symbolMethods = new Set();
let unknownKey: ts.Node | undefined;
+ /** Every write with an unknown key: each may install a replacement of any
+ * method name, and the key-order witness asks what each one installs. */
+ const unknownKeySites: ts.Node[] = [];
+ const markUnknownKey = (site: ts.Node): void => {
+ unknownKey = site;
+ unknownKeySites.push(site);
+ };
let numericKey: ts.Node | undefined;
let unknownOwnField = false;
// `sym:iterator` for `Symbol.iterator`: the name a class method declared
@@ -83,7 +90,7 @@ export function collectClassMethodMutations(
for (const key of computedMethods)
for (const name of literalValues(lowerer.typeOf(key)) ?? []) methods.add(String(name));
const markName = (name: string, site: ts.Node, arrow = false): void => {
- if (name === "prototype" || name === "__proto__") unknownKey = site;
+ if (name === "prototype" || name === "__proto__") markUnknownKey(site);
if (methods.has(name)) lowerer.noteMethodAccess(name, site);
if (!arrow) receiverFields.add(name);
};
@@ -125,11 +132,13 @@ export function collectClassMethodMutations(
};
// Whether no function value can inhabit `type`. Primitives qualify, and so
// do non-callable object types a function is not assignable to: one that
- // requires a property functions lack, or a weak (all-optional) type that
+ // requires a property functions lack, a weak (all-optional) type that
// shares no property with functions (TypeScript's weak-type rule rejects
- // callable sources too).
- const functionFree = (type: ts.Type): boolean => {
- if (type.isUnionType()) return ts.constituentTypes(type).every(functionFree);
+ // callable sources too), or a dictionary with no properties whose index
+ // signatures hold function-free values (functions have no index
+ // signature). `seen` stops recursive dictionary types.
+ const functionFree = (type: ts.Type, seen: Set = new Set()): boolean => {
+ if (type.isUnionType()) return ts.constituentTypes(type).every((t) => functionFree(t, seen));
if (
(type.flags &
(ts.TypeFlags.StringLike |
@@ -149,7 +158,12 @@ export function collectClassMethodMutations(
)
return false;
const properties = lowerer.checker.getPropertiesOfType(type);
- if (properties.length === 0) return false;
+ if (properties.length === 0) {
+ if (seen.has(type)) return true;
+ seen.add(type);
+ const indexes = lowerer.checker.getIndexInfosOfType(type);
+ return indexes.length > 0 && indexes.every((index) => functionFree(index.type, seen));
+ }
const required = properties.filter(
(property) => (property.flags & ts.SymbolFlags.Optional) === 0,
);
@@ -442,11 +456,12 @@ export function collectClassMethodMutations(
(lowerer.isStdlibGlobal(node, "Object") || lowerer.isStdlibGlobal(node, "Reflect")) &&
(!ts.isPropertyAccessExpression(node.parent) || node.parent.expression !== node)
)
- unknownKey = node;
+ markUnknownKey(node);
for (const node of reflection) {
const objectMethod = lowerer.stdlibGlobalMember(node, "Object");
const reflectMethod = lowerer.stdlibGlobalMember(node, "Reflect");
- if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf") unknownKey = node;
+ if (objectMethod === "setPrototypeOf" || reflectMethod === "setPrototypeOf")
+ markUnknownKey(node);
const single =
objectMethod === "defineProperty" ||
reflectMethod === "defineProperty" ||
@@ -456,7 +471,7 @@ export function collectClassMethodMutations(
if (!single && !multiple) continue;
const call = node.parent;
if (!ts.isCallExpression(call) || call.expression !== node) {
- unknownKey = node;
+ markUnknownKey(node);
continue;
}
const receiver = call.arguments.nodes[0];
@@ -611,7 +626,7 @@ export function collectClassMethodMutations(
// Numeric writes can replace numeric names but not ordinary names.
numericKey = site;
} else if (!(flags & ts.TypeFlags.ESSymbolLike)) {
- unknownKey = site;
+ markUnknownKey(site);
}
}
if (numericKey) {
@@ -620,7 +635,10 @@ export function collectClassMethodMutations(
}
}
if (unknownKey) {
- for (const method of methods) lowerer.noteMethodAccess(method, unknownKey);
+ for (const method of methods) {
+ lowerer.prototypeMethodAccesses.set(method, unknownKey);
+ for (const site of unknownKeySites) lowerer.noteMethodSite(method, site);
+ }
for (const method of symbolMethods) lowerer.replacedSymbolMethods.add(method);
}
if (!unknownKey && !unknownOwnField)
diff --git a/packages/compiler/src/frontend/lowering/completion-callbacks.ts b/packages/compiler/src/frontend/lowering/completion-callbacks.ts
new file mode 100644
index 000000000..1f4013d5e
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/completion-callbacks.ts
@@ -0,0 +1,155 @@
+/* Completion callbacks in programs that use AsyncLocalStorage. Node runs
+ * the callback of an operation a callback-style API starts (fs.readFile,
+ * crypto.randomBytes, zlib.gzip, dns.lookup, server.listen, net.connect,
+ * http.get, stream.finished, stream.pipeline, ...) with the stores active
+ * when the call started it. The runtime invokes these callbacks from
+ * the event loop, where the context is empty, so in programs whose
+ * scheduling carries contexts (RuntimeFeatures asyncContext) each such
+ * argument is bound to the active context at the call: als.bindCallback
+ * for typed closures, als.bindCallbackDyn for dyn functions. Other
+ * programs are left unchanged. */
+import { BOOL, type IrExpr, type IrModule, type IrStmt } from "../../ir/ir.js";
+import type { IrLibFn } from "../../ir/builtin-signatures.js";
+import { mapExprChildren, mapStmtChildren } from "../../ir/traverse.js";
+import { moduleRuntimeFeatures } from "../../ir/runtime-features.js";
+
+/** The argument that is the completion callback: its index; "last" for
+ * calls whose callback is the final argument when one is passed; or
+ * "arguments" for the fs callback calls, whose argument is the call's
+ * dyn argument vector, where the runtime finds the callback. */
+type CallbackPosition = number | "last" | "arguments";
+
+const COMPLETION_CALLBACKS: ReadonlyMap = new Map<
+ IrLibFn,
+ CallbackPosition
+>([
+ ["cp.execFile", 2],
+ ["crypto.generateKeyCb", 2],
+ ["crypto.hkdfCb", 5],
+ ["crypto.pbkdf2Cb", 5],
+ ["crypto.randomBytesCb", 1],
+ ["crypto.randomFillCb", 4],
+ ["crypto.randomFillCbDyn", 4],
+ ["crypto.randomIntCb", 3],
+ ["crypto.scryptCb", 4],
+ ["dgram.bindCb", 3],
+ ["dgram.closeCb", 1],
+ ["dgram.connectCb", 3],
+ ["dns.lookupDyn", 2],
+ ["fs.callbackCall", "arguments"],
+ ["fs.callbackGlob", "arguments"],
+ ["fs.renameCb", 2],
+ ["http.requestAgentCb", "last"],
+ ["http.requestCb", "last"],
+ ["http.requestConnCb", "last"],
+ ["http.requestDynCb", "last"],
+ ["http.requestUrlCb", "last"],
+ ["net.connectCb", 2],
+ ["net.listenCb", 2],
+ ["net.listenOptsCb", 4],
+ ["net.listenOptsReusePortCb", 5],
+ ["net.listenPathCb", 2],
+ ["process.stderrWriteBytesCb", 2],
+ ["process.stdoutWriteBytesCb", 2],
+ // stream.finished and stream.pipeline callbacks, which Node binds to
+ // the context of the call (eos' and pipeline's AsyncResource.bind).
+ ["stream.finished", 1],
+ ["stream.finishedDyn", 1],
+ ["stream.finishedOpts", "last"],
+ ["stream.finishedOptsSignal", "last"],
+ ["stream.pipeline", "last"],
+ ["stream.pipelineDyn", "last"],
+ ["stream.pipelineStages", "last"],
+ ["stream.pipelineStagesDyn", "last"],
+ ["zlib.cbOpts", 3],
+ ["zlib.cbOptsDyn", 3],
+ ["zlib.deflateCb", 1],
+ ["zlib.deflateRawCb", 1],
+ ["zlib.gunzipCb", 1],
+ ["zlib.gzipCb", 1],
+ ["zlib.inflateCb", 1],
+ ["zlib.inflateRawCb", 1],
+ ["zlib.unzipCb", 1],
+]);
+
+/** Completion callbacks that fire from other contexts' code (a stream event
+ * that another context's call emits), so an empty context at the call
+ * binds too: the callback then runs with no store, as in Node. */
+const BIND_EMPTY_CONTEXT: ReadonlySet = new Set([
+ "stream.finished",
+ "stream.finishedDyn",
+ "stream.finishedOpts",
+ "stream.finishedOptsSignal",
+ "stream.pipeline",
+ "stream.pipelineDyn",
+ "stream.pipelineStages",
+ "stream.pipelineStagesDyn",
+]);
+
+/** `cb` bound to the context active where it is evaluated. Unless
+ * `bindEmpty`, an empty context leaves it unbound (`always` false): the
+ * loop already runs those callbacks with an empty context. */
+function bindToActiveContext(cb: IrExpr, bindEmpty = false): IrExpr {
+ const always: IrExpr = { kind: "boolLit", value: bindEmpty, type: BOOL, loc: cb.loc };
+ if (cb.type.kind === "func")
+ return {
+ kind: "libCall",
+ fn: "als.bindCallback",
+ args: [cb, always],
+ type: cb.type,
+ loc: cb.loc,
+ };
+ if (cb.type.kind === "dyn")
+ return {
+ kind: "libCall",
+ fn: "als.bindCallbackDyn",
+ args: [cb, always],
+ type: cb.type,
+ loc: cb.loc,
+ };
+ return cb;
+}
+
+/** An element of a dyn argument vector that can be the callback: a boxed
+ * closure binds as a typed closure; any other dyn value that is not a
+ * boxed primitive binds as a dyn function (non-functions pass through). */
+function bindArgumentElement(e: IrExpr): IrExpr {
+ if (e.kind === "dynFrom") {
+ return e.value.type.kind === "func" ? { ...e, value: bindToActiveContext(e.value) } : e;
+ }
+ return e.type.kind === "dyn" ? bindToActiveContext(e) : e;
+}
+
+function bindCall(e: IrExpr): IrExpr {
+ if (e.kind !== "libCall") return e;
+ const position = COMPLETION_CALLBACKS.get(e.fn);
+ if (position === undefined) return e;
+ if (position === "arguments") {
+ const index = e.fn === "fs.callbackCall" ? 1 : 0;
+ const vector = e.args[index];
+ if (vector === undefined || vector.kind !== "dynArrLit") return e;
+ const args = e.args.slice();
+ args[index] = {
+ kind: "dynArrLit",
+ elems: vector.elems.map(bindArgumentElement),
+ type: vector.type,
+ loc: vector.loc,
+ };
+ return { ...e, args };
+ }
+ const index = position === "last" ? e.args.length - 1 : position;
+ const cb = e.args[index];
+ if (cb === undefined || (position === "last" && cb.type.kind !== "func")) return e;
+ const args = e.args.slice();
+ args[index] = bindToActiveContext(cb, BIND_EMPTY_CONTEXT.has(e.fn));
+ return { ...e, args };
+}
+
+/** Binds the completion callbacks of `mod` when its scheduling carries
+ * AsyncLocalStorage contexts. */
+export function bindCompletionCallbacks(mod: IrModule): void {
+ if (!moduleRuntimeFeatures(mod).asyncContext) return;
+ const rewriteExpr = (e: IrExpr): IrExpr => bindCall(mapExprChildren(e, rewriteExpr, rewriteStmt));
+ const rewriteStmt = (s: IrStmt): IrStmt => mapStmtChildren(s, rewriteExpr, rewriteStmt);
+ for (const fn of mod.functions) fn.body = fn.body.map(rewriteStmt);
+}
diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
index 86f214bc1..edf65ab2b 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts
@@ -2358,10 +2358,11 @@ function reduceHelper(
* the left element first. Undefined values sink without reaching the user
* comparator. The callback sequence differs from V8's TimSort, so exact
* order and count parity are not claimed; results agree for consistent
- * comparators. Without a comparator, string, number and boolean arrays use
- * native stable ordering: their String conversions cannot call user code.
- * Strings compare UTF-16 units; numbers compare their decimal spellings
- * ([10, 9, 1] → [1, 10, 9]). Other element types still need a comparator. */
+ * comparators. Without a comparator, string, number, boolean and Date
+ * arrays use native stable ordering: their String conversions cannot call
+ * user code. Strings compare UTF-16 units; numbers compare their decimal
+ * spellings ([10, 9, 1] → [1, 10, 9]); Dates compare their toString() text.
+ * Other element types still need a comparator. */
export function lowerArraySortCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -2374,7 +2375,7 @@ export function lowerArraySortCall(
const copyFirst = method === "toSorted";
if (
call.arguments.nodes.length === 0 &&
- (elem.kind === "string" || elem.kind === "f64" || elem.kind === "bool")
+ (elem.kind === "string" || elem.kind === "f64" || elem.kind === "bool" || elem.kind === "date")
) {
return {
kind: "arrIntrinsic",
@@ -2389,7 +2390,7 @@ export function lowerArraySortCall(
lowerer.noLowering(
`.${method} with ${call.arguments.nodes.length} arguments`,
call,
- "default ordering supports string[], number[] and boolean[]; other element types need a comparator",
+ "default ordering supports string[], number[], boolean[] and Date[]; other element types need a comparator",
);
}
const receiver = lowerer.lowerExpr(access.expression);
diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts
index 3ab2e84e2..4ef203edd 100644
--- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts
+++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts
@@ -1081,14 +1081,25 @@ export function lowerArrayMethodCall(
(lowerer.unions
.get(elem.unionId)
?.arms.every(
- (a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a),
+ (a) =>
+ a.kind === "f64" ||
+ a.kind === "string" ||
+ a.kind === "bool" ||
+ a.kind === "date" ||
+ isUnitType(a),
) ??
false);
- if (elem.kind !== "f64" && elem.kind !== "string" && elem.kind !== "bool" && !joinableUnion) {
+ if (
+ elem.kind !== "f64" &&
+ elem.kind !== "string" &&
+ elem.kind !== "bool" &&
+ elem.kind !== "date" &&
+ !joinableUnion
+ ) {
lowerer.unsupported(
"SC1090",
call,
- "'.join()' on arrays of this element type (number, string, and boolean arrays join — unions of those with undefined/null arms too, the units printing empty like JS)",
+ "'.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS)",
);
}
const receiver = lowerer.lowerExpr(access.expression);
diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts
index 995cb177a..3722603f8 100644
--- a/packages/compiler/src/frontend/lowering/containers/bytes.ts
+++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts
@@ -1,5 +1,5 @@
import { keyedHelperName } from "../stable-names.js";
-import { boolLit, dynUndefinedExpr, numLit, varRef } from "../../../ir/build.js";
+import { boolLit, dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js";
import { BUF_NUM_METHODS } from "./buffer-numeric-methods.js";
import * as ts from "../../ts7/adapter.js";
import {
@@ -903,6 +903,126 @@ function lowerDataViewNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
return lowerArrayBufferView(lowerer, args, "dv", locOf(expr));
}
+/** `receiver.set(src, offset?)` on a lowered typed-array receiver. */
+function bytesSetIntrinsic(
+ lowerer: Lowerer,
+ receiver: IrExpr,
+ argNodes: readonly ts.Expression[],
+ loc: SrcLoc,
+): IrExpr {
+ const src = lowerer.lowerExpr(argNodes[0]!);
+ const method = src.type.kind === "bytes" ? "setFrom" : "setFromDyn";
+ const args = [src.type.kind === "bytes" ? src : lowerer.coerceInto(argNodes[0]!, src, DYN)];
+ if (argNodes.length === 2)
+ args.push(
+ lowerOptionalArgument(lowerer, argNodes[1]!, F64, {
+ kind: "numLit",
+ value: 0,
+ type: F64,
+ loc,
+ }),
+ );
+ return { kind: "bytesIntrinsic", method, receiver, args, type: VOID, loc };
+}
+
+/** `Uint8Array.prototype.set.call(target, src, offset?)`, through any typed
+ * array constructor's prototype (they share %TypedArray%.prototype.set). A
+ * target the checker types as a typed array or Buffer lowers like
+ * `target.set(src, offset)`. Any other target, a DataView included, is
+ * checked at run time: a typed array calls its `set`, and
+ * anything else throws Node's TypeError "this is not a typed array.",
+ * after every argument is evaluated. */
+export function lowerBytesPrototypeSetCall(
+ lowerer: Lowerer,
+ call: ts.CallExpression,
+ access: ts.PropertyAccessExpression,
+): IrExpr | null {
+ if (
+ call.questionDotToken ||
+ access.questionDotToken ||
+ access.name.text !== "call" ||
+ !ts.isPropertyAccessExpression(access.expression)
+ )
+ return null;
+ const method = access.expression;
+ const proto = method.expression;
+ if (
+ method.questionDotToken ||
+ method.name.text !== "set" ||
+ !ts.isPropertyAccessExpression(proto) ||
+ proto.questionDotToken ||
+ proto.name.text !== "prototype" ||
+ !ts.isIdentifier(proto.expression) ||
+ !lowerer.isStdlibMember(method)
+ )
+ return null;
+ const ctor = lowerer.resolveValueSymbol(proto.expression);
+ if (!ctor || !lowerer.isStdlibSymbol(ctor) || !own(BYTES_CTORS, ts.symbolName(ctor))) return null;
+ const argNodes = call.arguments.nodes;
+ if (argNodes.length < 2 || argNodes.length > 3 || argNodes.some(ts.isSpreadElement)) return null;
+ const loc = locOf(call);
+ const target = lowerer.lowerExpr(argNodes[0]!);
+ // A DataView lowers to the same bytes type, so the checker type decides:
+ // only a typed array (or a Buffer) takes the method's path.
+ const targetSymbol = lowerer.typeOf(argNodes[0]!).getSymbol();
+ if (
+ target.type.kind === "bytes" &&
+ targetSymbol !== undefined &&
+ lowerer.isStdlibSymbol(targetSymbol) &&
+ (own(BYTES_CTORS, ts.symbolName(targetSymbol)) !== undefined ||
+ ts.symbolName(targetSymbol) === "Buffer")
+ )
+ return bytesSetIntrinsic(lowerer, target, argNodes.slice(1), loc);
+ const slot = (value: IrExpr): { decl: IrStmt; read: IrExpr } => {
+ const local = lowerer.declareHiddenLocal("%setArg", DYN);
+ return {
+ decl: { kind: "varDecl", localId: local.id, init: value, loc },
+ read: varRef(local.id, DYN, loc),
+ };
+ };
+ const slots = [
+ slot(lowerer.coerceInto(argNodes[0]!, target, DYN)),
+ ...argNodes.slice(1).map((arg) => slot(lowerer.lowerExprExpecting(arg, DYN))),
+ ];
+ const isTypedArray: IrExpr = {
+ kind: "libCall",
+ fn: "util.typeIs",
+ args: [slots[0]!.read, { kind: "strLit", value: "isTypedArray", type: STRING, loc }],
+ type: BOOL,
+ loc,
+ };
+ return {
+ kind: "seqExpr",
+ stmts: [
+ ...slots.map((s) => s.decl),
+ {
+ kind: "if",
+ cond: { kind: "unary", op: "!", operand: isTypedArray, type: BOOL, loc },
+ then: [
+ {
+ kind: "exprStmt",
+ expr: nodeThrowExpr(1, "", "this is not a typed array.", VOID, loc),
+ loc,
+ },
+ ],
+ else_: null,
+ loc,
+ },
+ ],
+ result: {
+ kind: "dynInvoke",
+ recv: slots[0]!.read,
+ method: "set",
+ calleeName: ts.getTextOfNode(method),
+ args: slots.slice(1).map((s) => s.read),
+ type: DYN,
+ loc,
+ },
+ type: DYN,
+ loc,
+ };
+}
+
/** Method calls on typed-array/Buffer receivers: slice (copy), subarray
* (shared view), toReversed, copyWithin, set(src,
* offset?), and the u8-only Buffer surface: toString(enc?) plus the
@@ -1106,21 +1226,7 @@ export function lowerBytesMethodCall(
lowerer.noLowering(`.set with ${nArgs} arguments on typed arrays`, call);
}
const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
- const src = lowerer.lowerExpr(call.arguments.nodes[0]!);
- const method = src.type.kind === "bytes" ? "setFrom" : "setFromDyn";
- const args = [
- src.type.kind === "bytes" ? src : lowerer.coerceInto(call.arguments.nodes[0]!, src, DYN),
- ];
- if (nArgs === 2)
- args.push(
- lowerOptionalArgument(lowerer, call.arguments.nodes[1]!, F64, {
- kind: "numLit",
- value: 0,
- type: F64,
- loc,
- }),
- );
- return { kind: "bytesIntrinsic", method, receiver, args, type: VOID, loc };
+ return bytesSetIntrinsic(lowerer, receiver, call.arguments.nodes, loc);
}
if (name === "toString") {
// Buffer's toString(encoding?) — utf8 by default. A 0-arg toString
diff --git a/packages/compiler/src/frontend/lowering/date-values.ts b/packages/compiler/src/frontend/lowering/date-values.ts
new file mode 100644
index 000000000..ef5eafebe
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/date-values.ts
@@ -0,0 +1,207 @@
+import * as ts from "../ts7/adapter.js";
+import type { Lowerer } from "./lowerer.js";
+
+/** Date methods whose receiver is only read for its time value: a Date
+ * local used as their receiver can store the time value instead of a Date
+ * object (builtins/dates.ts lowers them). */
+const TIME_VALUE_METHODS = new Set([
+ "getTime",
+ "valueOf",
+ "toISOString",
+ "toJSON",
+ "toString",
+ "toDateString",
+ "toTimeString",
+ "toUTCString",
+ "getFullYear",
+ "getUTCFullYear",
+ "getMonth",
+ "getUTCMonth",
+ "getDate",
+ "getUTCDate",
+ "getDay",
+ "getUTCDay",
+ "getHours",
+ "getUTCHours",
+ "getMinutes",
+ "getUTCMinutes",
+ "getSeconds",
+ "getUTCSeconds",
+ "getMilliseconds",
+ "getUTCMilliseconds",
+ "getTimezoneOffset",
+]);
+
+const RELATIONAL = new Set([
+ ts.SyntaxKind.LessThanToken,
+ ts.SyntaxKind.LessThanEqualsToken,
+ ts.SyntaxKind.GreaterThanToken,
+ ts.SyntaxKind.GreaterThanEqualsToken,
+]);
+
+/** True when a local `const` (or a `let` that is never assigned again)
+ * initialized with `new Date(...)` can keep the Date's time value instead
+ * of a Date object: no use observes the Date's identity. Every use reads the time value as
+ * a Date method receiver, a String() or template operand, a relational or
+ * string `+` operand, or `new Date(d)`'s argument, inside the declaring
+ * function, or inside the block or loop that scopes a module-level
+ * binding (no closure captures it). Top-level module bindings keep the
+ * object. Other uses box the time value into a new Date at the use. */
+export function isTimeValueDateDeclaration(
+ lowerer: Lowerer,
+ decl: ts.VariableDeclaration,
+): boolean {
+ const known = lowerer.timeValueDateDecisions.get(decl);
+ if (known !== undefined) return known;
+ const answer = decideTimeValueDate(lowerer, decl);
+ lowerer.timeValueDateDecisions.set(decl, answer);
+ return answer;
+}
+
+function decideTimeValueDate(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
+ if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) return false;
+ // Only a Date the declaration creates: another reference to an existing
+ // Date can mutate it (`const d = a; a.setTime(0)`), and d must see that.
+ if (!createsDate(lowerer, decl.initializer)) return false;
+ const sf = decl.getSourceFile();
+ if (sf.isDeclarationFile || /\.[cm]?jsx?$/.test(sf.fileName)) return false;
+ const list = decl.parent;
+ if (!ts.isVariableDeclarationList(list)) return false;
+ const isConst = (list.flags & ts.NodeFlags.Const) !== 0;
+ const isLet = (list.flags & ts.NodeFlags.Let) !== 0;
+ if (!isConst && !isLet) return false;
+ if (!ts.isVariableStatement(list.parent) && !ts.isForStatement(list.parent)) return false;
+ const fn = enclosingFunction(decl);
+ // The uses to check: the function body, or for a binding inside a block
+ // or loop at module level (a local of the module's initializer, not a
+ // module binding) the block or loop that scopes it.
+ let scope: ts.Node | undefined;
+ if (fn !== null) scope = fn.body;
+ else if (!inClassStaticBlock(decl)) scope = moduleBlockScope(decl);
+ if (scope === undefined) return false;
+ // The lib Date interface by its symbol first: mapping every candidate
+ // binding's type would intern its unions before the lowering does.
+ const typeSymbol = lowerer.typeOf(decl.name).getSymbol();
+ if (
+ typeSymbol === undefined ||
+ ts.symbolName(typeSymbol) !== "Date" ||
+ !typeSymbol.declarations.some(
+ (d) => ts.isInterfaceDeclaration(d) && lowerer.isStdlibFile(d.getSourceFile()),
+ )
+ )
+ return false;
+ if (lowerer.mapTypeOf(lowerer.typeOf(decl.name))?.kind !== "date") return false;
+ const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
+ if (!symbol) return false;
+ let ok = true;
+ const visit = (node: ts.Node): void => {
+ if (!ok) return;
+ if (ts.isIdentifier(node) && node !== decl.name && node.text === decl.name.text) {
+ if (lowerer.checker.getSymbolAtLocation(node) === symbol) {
+ if (enclosingFunction(node) !== fn || !readsTimeValue(lowerer, node)) ok = false;
+ }
+ return;
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(scope);
+ return ok;
+}
+
+/** True for `new Date(...)` of the standard Date, through parentheses and
+ * type-only wrappers: a Date no other reference can reach. */
+function createsDate(lowerer: Lowerer, init: ts.Expression): boolean {
+ let e = init;
+ while (
+ ts.isParenthesizedExpression(e) ||
+ ts.isAsExpression(e) ||
+ ts.isSatisfiesExpression(e) ||
+ ts.isTypeAssertion(e) ||
+ ts.isNonNullExpression(e)
+ )
+ e = e.expression;
+ return ts.isNewExpression(e) && lowerer.isStdlibGlobal(e.expression, "Date");
+}
+
+/** The block or for statement that scopes a module-level binding which is
+ * not a module binding: a declaration in a block or loop body, or in a
+ * for statement's initializer. Undefined for a top-level declaration. */
+function moduleBlockScope(decl: ts.VariableDeclaration): ts.Node | undefined {
+ const list = decl.parent;
+ if (!list || !ts.isVariableDeclarationList(list)) return undefined;
+ if (ts.isForStatement(list.parent)) return list.parent;
+ const statement = list.parent;
+ if (!ts.isVariableStatement(statement) || ts.isSourceFile(statement.parent)) return undefined;
+ for (let cur: ts.Node | undefined = statement.parent; cur; cur = cur.parent) {
+ if (ts.isBlock(cur) || ts.isCaseBlock(cur)) return cur;
+ if (ts.isSourceFile(cur)) return undefined;
+ }
+ return undefined;
+}
+
+function inClassStaticBlock(node: ts.Node): boolean {
+ for (let cur = node.parent; cur; cur = cur.parent) {
+ if (ts.isClassStaticBlockDeclaration(cur)) return true;
+ if (ts.isSourceFile(cur)) return false;
+ }
+ return false;
+}
+
+function enclosingFunction(node: ts.Node): ts.FunctionLikeDeclaration | null {
+ for (let cur = node.parent; cur; cur = cur.parent) {
+ if (
+ ts.isFunctionDeclaration(cur) ||
+ ts.isFunctionExpression(cur) ||
+ ts.isArrowFunction(cur) ||
+ ts.isMethodDeclaration(cur) ||
+ ts.isConstructorDeclaration(cur) ||
+ ts.isGetAccessorDeclaration(cur) ||
+ ts.isSetAccessorDeclaration(cur)
+ )
+ return cur;
+ if (ts.isClassStaticBlockDeclaration(cur) || ts.isSourceFile(cur)) return null;
+ }
+ return null;
+}
+
+/** A use of the binding that reads only its time value. */
+function readsTimeValue(lowerer: Lowerer, id: ts.Identifier): boolean {
+ let node: ts.Node = id;
+ while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
+ const parent = node.parent;
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
+ const call = parent.parent;
+ return (
+ !parent.questionDotToken &&
+ ts.isCallExpression(call) &&
+ call.expression === parent &&
+ !call.questionDotToken &&
+ TIME_VALUE_METHODS.has(parent.name.text) &&
+ lowerer.isStdlibMember(parent)
+ );
+ }
+ if (ts.isTemplateSpan(parent)) return parent.expression === node;
+ if (ts.isCallExpression(parent)) {
+ return (
+ parent.arguments.nodes.length === 1 &&
+ parent.arguments.nodes[0] === node &&
+ ts.isIdentifier(parent.expression) &&
+ lowerer.isStdlibGlobal(parent.expression, "String")
+ );
+ }
+ if (ts.isNewExpression(parent)) {
+ return (
+ parent.arguments?.nodes.length === 1 &&
+ parent.arguments.nodes[0] === node &&
+ ts.isIdentifier(parent.expression) &&
+ lowerer.isStdlibGlobal(parent.expression, "Date")
+ );
+ }
+ if (ts.isBinaryExpression(parent)) {
+ const other = parent.left === node ? parent.right : parent.left;
+ const otherKind = lowerer.mapTypeOf(lowerer.typeOf(other))?.kind;
+ if (RELATIONAL.has(parent.operatorToken.kind)) return otherKind === "date";
+ if (parent.operatorToken.kind === ts.SyntaxKind.PlusToken) return otherKind === "string";
+ }
+ return false;
+}
diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts
index 17aef7b85..b26a3dd9f 100644
--- a/packages/compiler/src/frontend/lowering/expressions/binary.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts
@@ -764,10 +764,21 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr
(lowerer.unions
.get(elem.unionId)
?.arms.every(
- (a) => a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a),
+ (a) =>
+ a.kind === "f64" ||
+ a.kind === "string" ||
+ a.kind === "bool" ||
+ a.kind === "date" ||
+ isUnitType(a),
) ??
false);
- if (elem.kind === "f64" || elem.kind === "string" || elem.kind === "bool" || joinableUnion) {
+ if (
+ elem.kind === "f64" ||
+ elem.kind === "string" ||
+ elem.kind === "bool" ||
+ elem.kind === "date" ||
+ joinableUnion
+ ) {
const sep: IrExpr = { kind: "strLit", value: ",", type: STRING, loc: e.loc };
return {
kind: "arrIntrinsic",
@@ -2195,14 +2206,41 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
loc,
};
if (bothStr) return { kind: "strCmp", op: cmpOp, left, right, type: BOOL, loc };
- // A Date converts to a new checked-dynamic Date on each crossing, so
- // comparisons keep their Date fence instead of comparing copies.
- if (
- left.type.kind !== "date" &&
- right.type.kind !== "date" &&
- lowerer.dynConvertible(left.type) &&
- lowerer.dynConvertible(right.type)
- ) {
+ // Dates compare by time value, the number hint of ToPrimitive. Both
+ // operands evaluate before either converts, so a right operand that
+ // mutates the left Date (`a < (a.setTime(t), b)`) is seen: with such
+ // a right operand, both Dates bind first and their times read after.
+ if (left.type.kind === "date" && right.type.kind === "date") {
+ const time = (d: IrExpr): IrExpr => ({
+ kind: "libCall",
+ fn: "date.getTime",
+ args: [d],
+ type: F64,
+ loc,
+ });
+ if (isSafeToRepeat(right))
+ return { kind: "bin", op: cmpOp, left: time(left), right: time(right), type: BOOL, loc };
+ const leftDate = lowerer.declareHiddenLocal("%date", left.type);
+ const rightDate = lowerer.declareHiddenLocal("%date", right.type);
+ return {
+ kind: "seqExpr",
+ stmts: [
+ { kind: "varDecl", localId: leftDate.id, init: left, loc },
+ { kind: "varDecl", localId: rightDate.id, init: right, loc },
+ ],
+ result: {
+ kind: "bin",
+ op: cmpOp,
+ left: time(varRef(leftDate.id, left.type, loc)),
+ right: time(varRef(rightDate.id, right.type, loc)),
+ type: BOOL,
+ loc,
+ },
+ type: BOOL,
+ loc,
+ };
+ }
+ if (lowerer.dynConvertible(left.type) && lowerer.dynConvertible(right.type)) {
return {
kind: "libCall",
fn: "dyn.compare",
@@ -2416,6 +2454,7 @@ export function lowerStrictEquality(
idLeft.type.kind === "map" ||
idLeft.type.kind === "set" ||
idLeft.type.kind === "regex" ||
+ idLeft.type.kind === "date" ||
idLeft.type.kind === "url" ||
idLeft.type.kind === "object" ||
idLeft.type.kind === "record" ||
@@ -2452,14 +2491,7 @@ export function lowerStrictEquality(
loc,
};
}
- // A Date converts to a new checked-dynamic Date on each crossing; its
- // identity has no representation, so equality keeps the fence.
- if (
- idLeft.type.kind !== "date" &&
- idRight.type.kind !== "date" &&
- lowerer.dynConvertible(idLeft.type) &&
- lowerer.dynConvertible(idRight.type)
- ) {
+ if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) {
return {
kind: "dynScalarEq",
left: lowerer.coerceToExpected(idLeft, DYN),
diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts
index 0ea94ec7a..9a7ebf401 100644
--- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts
@@ -1028,7 +1028,7 @@ function lowerGuardedRecordWrite(
const value = stabilized(lowerer, lowerer.lowerExpr(expr.right), "%recordValue", receiver.stmts);
const write = lowerer.withExpressionOverride(target.expression, receiver.present, () =>
lowerer.withExpressionOverride(target.argumentExpression, key, () =>
- lowerer.withExpressionOverride(expr.right, value, () => lowerElementWrite(lowerer, expr)),
+ lowerer.withExpressionOverride(expr.right, value, () => lowerer.lowerElementWrite(expr)),
),
);
return {
diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
index ef0d8a2d1..11373e3e3 100644
--- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts
@@ -9,6 +9,7 @@ import { type Lowerer, PoisonError } from "../lowerer.js";
import {
BOOL,
BYTES_ELEMENT_NAME,
+ DATE_T,
DYN,
F64,
type IrExpr,
@@ -171,6 +172,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// when the checker types this USE as a single arm, maybeNarrow
// bridges the tagged representation with a unionNarrow.
const local = lowerer.resolveLocal(expr);
+ // A Date local holding its time value reads as that Date: only uses
+ // that read the time value reach here (date-values.ts).
+ if (local && lowerer.timeValueDateLocals.has(local)) {
+ const time: IrExpr = { kind: "varRef", localId: local.id, type: F64, loc };
+ return { kind: "libCall", fn: "date.fromTime", args: [time], type: DATE_T, loc };
+ }
if (local) {
// A switch can dispatch past a lexical declaration. Keep this
// previously refused codec form out until switch-scope TDZ boxes
@@ -744,6 +751,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
return { kind: "libCall", fn: "webcrypto.object", args: [], type: DYN, loc };
const nativeAlias = stdlibGlobalNameOf(lowerer, expr);
if (nativeAlias === "Buffer" && expr.text !== "Buffer") {
+ // An alias through a cast of globalThis carries the cast's static type,
+ // which the identity token below does not have.
+ const aliasSymbol = identifierReadSymbol(lowerer, expr);
+ if (aliasSymbol && lowerer.castGlobalAliases.has(aliasSymbol)) {
+ lowerer.unsupported(
+ "SC1090",
+ expr,
+ "the Buffer constructor read through a cast of globalThis as a value",
+ "test it in a condition, read its typeof, or call its members",
+ );
+ }
return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc };
}
if (lowerer.isStdlibGlobal(expr, "fetch")) {
diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
index 7b12297a8..9894cfb71 100644
--- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts
@@ -134,6 +134,7 @@ import {
import { expandoMemberRead } from "../lower-expando.js";
import { lowerProtocolPropertyRead } from "../class-protocol-dispatch.js";
import { lowerReadlineProperty } from "../builtins/readline.js";
+import { lowerAsyncLocalStorageProperty } from "../builtins/async-context.js";
import { lowerEventStaticProperty } from "../builtins/numbers.js";
import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "../lower-stream.js";
import { lowerValueStreamProperty } from "../builtins/hash-stream.js";
@@ -833,6 +834,8 @@ export function lowerPropertyAccessExpr(
// diagnostics_channel Channel receivers — name/hasSubscribers
// over the f64 channel handle.
lowerer.lowerDiagnosticsChannelProperty(expr) ??
+ // AsyncLocalStorage receivers — the name option.
+ lowerAsyncLocalStorageProperty(lowerer, expr) ??
// readline Interface receivers — the history and option members
// over the f64 interface handle.
lowerReadlineProperty(lowerer, expr) ??
@@ -1499,6 +1502,25 @@ export function lowerPropertyAccessExpr(
) {
return afterReceiver(recvLowered, dynUndefinedExpr(loc), loc);
}
+ // A typed array under a checker `any` (a JS binding its own initializer's
+ // inference refers to: hono's etag digest `nextChunk`) reads through its
+ // box, as the method-call twin dispatches. The box shares the native
+ // storage, so it needs no fresh-result restriction.
+ if (
+ recvLowered.type.kind === "bytes" &&
+ (lowerer.typeOf(expr.expression).flags & ts.TypeFlags.Any) !== 0
+ ) {
+ return lowerer.maybeNarrow(
+ {
+ kind: "dynKeyGet",
+ value: lowerer.coerceToExpected(recvLowered, DYN),
+ key: { kind: "strLit", value: expr.name.text, type: STRING, loc },
+ type: DYN,
+ loc,
+ },
+ expr,
+ );
+ }
lowerer.unsupported(
"SC1090",
expr,
diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts
index f1590e9cc..1651bf0d9 100644
--- a/packages/compiler/src/frontend/lowering/expressions/relational.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts
@@ -338,6 +338,22 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
type: BOOL,
loc,
};
+ // A codec in untyped storage is its record's handle box.
+ const codec = (["TextEncoder", "TextDecoder"] as const).find((name) =>
+ lowerer.isStdlibGlobal(expr.right, name),
+ );
+ if (codec && lowerer.mapTypeOf(lowerer.typeOf(expr.left))?.kind === "dyn") {
+ return {
+ kind: "libCall",
+ fn: "text.codecIs",
+ args: [
+ lowerer.lowerExprExpecting(expr.left, DYN),
+ { kind: "boolLit", value: codec === "TextDecoder", type: BOOL, loc },
+ ],
+ type: BOOL,
+ loc,
+ };
+ }
if (lowerer.isStdlibGlobal(expr.right, "ReadableStream")) {
const value = lowerer.lowerExprExpecting(expr.left, DYN);
return { kind: "libCall", fn: "fetch.streamIs", args: [value], type: BOOL, loc };
diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts
index cc3ac06fb..fbbc068b9 100644
--- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts
@@ -88,6 +88,12 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc
if (builtinClassRefOf(lowerer, expr.expression) !== null) {
return { kind: "strLit", value: "function", type: STRING, loc };
}
+ // The Buffer constructor and its aliases (`const B = globalThis.Buffer`,
+ // `(globalThis as { Buffer?: B }).Buffer`): a value read of an alias is an
+ // identity token, so its typeof is decided here.
+ if (stdlibGlobalNameOf(lowerer, expr.expression) === "Buffer") {
+ return { kind: "strLit", value: "function", type: STRING, loc };
+ }
if (ts.isPropertyAccessExpression(expr.expression)) {
const member = expr.expression;
const prototype = member.expression;
diff --git a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts
index 6454bc1fd..4f4532a6a 100644
--- a/packages/compiler/src/frontend/lowering/expressions/union-fields.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/union-fields.ts
@@ -2,6 +2,7 @@ import { nodeThrowExpr, varRef } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import { narrowStoredClassValue } from "../class-unions.js";
+import { markSloppyRecordWrites } from "../frozen-records.js";
import {
BOOL,
type IrExpr,
@@ -246,6 +247,8 @@ export function lowerUnionFieldWrite(
value: slotValue,
loc,
};
+ // A sloppy-mode write to a frozen record leaves it unchanged.
+ if (write.kind === "recordSet") markSloppyRecordWrites(lowerer, write, access);
branch = { kind: "seqExpr", stmts: [write], result, type: result.type, loc };
} else {
branch = nodeThrowExpr(
diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts
index 04ca23a96..7cc77a2db 100644
--- a/packages/compiler/src/frontend/lowering/expressions/updates.ts
+++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts
@@ -191,6 +191,10 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio
operatorUnsupported(lowerer, expr, "+", [raw.type]);
}
case ts.SyntaxKind.ExclamationToken: {
+ // `!runtimeBuffer` over an alias of the Buffer global: Node always
+ // defines Buffer (the condition twin is lowerCondition's).
+ if (lowerer.isStdlibGlobal(expr.operand, "Buffer"))
+ return { kind: "boolLit", value: false, type: BOOL, loc };
// `!x` is ToBoolean-then-negate: f64/string operands go through toBool.
const operand = lowerer.ensureBool(
deferredSlotOr(
diff --git a/packages/compiler/src/frontend/lowering/frozen-records.ts b/packages/compiler/src/frontend/lowering/frozen-records.ts
new file mode 100644
index 000000000..30eb9e455
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/frozen-records.ts
@@ -0,0 +1,354 @@
+import * as ts from "../ts7/adapter.js";
+import {
+ BOOL,
+ VOID,
+ shapeHasAccessorSlots,
+ typeKey,
+ type IrExpr,
+ type IrFunction,
+ type IrStmt,
+ type IrType,
+ type SrcLoc,
+} from "../../ir/ir.js";
+import { numLit, strLit, varRef } from "../../ir/build.js";
+import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
+import type { Lowerer } from "./lowerer.js";
+import { recordHasOwnHelper } from "./lower-calls.js";
+import { isStrictCode } from "./strict-mode.js";
+
+/* Object.freeze of records and arrays that other references can still
+ * write. Only the types a program may freeze pay for it. A record keeps its
+ * frozen bit in the rc word (scr_rc.h SCR_RC_FROZEN), which its release
+ * ignores, and every write to it checks the bit first
+ * (guardFrozenWrites). An array keeps it in its storage flags
+ * (scr_array.h SCR_ARR_FROZEN), which the backend's array write guards test
+ * for the array types the module lists (IrModule.freezableArrays). A type is
+ * freezable when a static Object.freeze names it, or, in a program that
+ * freezes untyped values, when a value of it or of a type reaching it can be
+ * held as a live untyped reference. */
+
+type RecordType = Extract;
+type RecordWrite = Extract;
+type FreezeFn = "record.freeze" | "array.freeze" | "collection.preventExtensions";
+
+/** Object.freeze of a record or an array, or Object.freeze, seal or
+ * preventExtensions of a Map or Set: `fn` sets the value's bit, and the
+ * call answers the value. */
+export function lowerNativeFreeze(
+ lowerer: Lowerer,
+ fn: FreezeFn,
+ target: IrExpr,
+ loc: SrcLoc,
+): IrExpr {
+ const type = target.type;
+ const slot = lowerer.declareHiddenLocal("%freezeTarget", type);
+ const receiver = (): IrExpr => varRef(slot.id, type, loc);
+ return {
+ kind: "seqExpr",
+ stmts: [
+ { kind: "varDecl", localId: slot.id, init: target, loc },
+ {
+ kind: "exprStmt",
+ expr: { kind: "libCall", fn, args: [receiver()], type: VOID, loc },
+ loc,
+ },
+ ],
+ result: receiver(),
+ type,
+ loc,
+ };
+}
+
+function isRecordWrite(s: IrStmt): s is RecordWrite {
+ return s.kind === "recordSet" || s.kind === "recordKeySet" || s.kind === "recordKeyDelete";
+}
+
+/** The key of a record write's source range: strict mode is lexical, so
+ * every write lowered from the same range shares its mode. */
+function writeKey(loc: SrcLoc): string {
+ return `${loc.file}\0${loc.start}\0${loc.end}`;
+}
+
+/** Records the record writes of `stmt`, lowered from `node`, as sloppy-mode
+ * writes when `node` is sloppy-mode code: a write to a frozen record then
+ * leaves it unchanged instead of throwing. `deletes` says `stmt` is the
+ * absent-state write of `delete r.f`, which Node refuses only while the
+ * field is present. */
+export function markSloppyRecordWrites(
+ lowerer: Lowerer,
+ stmt: IrStmt,
+ node: ts.Node,
+ deletes = false,
+): IrStmt {
+ if (deletes) lowerer.recordFieldDeletes.add(writeKey(stmt.loc));
+ if (isStrictCode(node)) return stmt;
+ everyStmtList([stmt], {
+ stmt: (s) => {
+ if (s.kind === "recordSet" || s.kind === "recordKeySet" || s.kind === "recordKeyDelete")
+ lowerer.sloppyRecordWrites.add(writeKey(s.loc));
+ return true;
+ },
+ expr: () => true,
+ });
+ return stmt;
+}
+
+/** Whether a field write's value can be an optional field's absent state,
+ * which only a field write itself can hold. */
+function holdsAbsentState(e: IrExpr): boolean {
+ if (e.kind === "fieldAbsent") return true;
+ return e.kind === "ternary" && (holdsAbsentState(e.then) || holdsAbsentState(e.else_));
+}
+
+/** The record shapes and array types (typeKeys) whose values the program
+ * may freeze. */
+function freezableTypes(
+ lowerer: Lowerer,
+ functions: IrFunction[],
+): { shapes: Set; arrays: Set } {
+ const shapes = new Set();
+ const arrays = new Set();
+ const live: IrType[] = [];
+ let dynFreeze = false;
+ for (const fn of functions)
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ if (e.kind === "libCall" && e.fn === "record.freeze") {
+ const target = e.args[0];
+ if (target && target.type.kind === "record") shapes.add(target.type.shapeId);
+ } else if (e.kind === "libCall" && e.fn === "array.freeze") {
+ const target = e.args[0];
+ if (target && target.type.kind === "array") arrays.add(typeKey(target.type));
+ } else if (e.kind === "libCall" && e.fn === "dyn.freeze") dynFreeze = true;
+ else if (e.kind === "dynFrom") live.push(e.value.type);
+ return true;
+ },
+ stmt: () => true,
+ });
+ if (!dynFreeze) return { shapes, arrays };
+ // An untyped reference to a class instance, or to a record or array
+ // boxed live, reaches the values its fields and elements hold as further
+ // live references. Class instances also cross into untyped code through
+ // compiled callbacks, so every class is a root.
+ const stack = [...live];
+ for (const info of lowerer.classes.values())
+ for (const field of info.def.fields) stack.push(field.type);
+ const seen = new Set();
+ while (stack.length > 0) {
+ const type = stack.pop()!;
+ switch (type.kind) {
+ case "record": {
+ if (seen.has(`record:${type.shapeId}`)) break;
+ seen.add(`record:${type.shapeId}`);
+ shapes.add(type.shapeId);
+ const shape = lowerer.shapes.get(type.shapeId);
+ for (const field of shape?.fields ?? []) stack.push(field.type);
+ if (shape?.indexValue) stack.push(shape.indexValue);
+ break;
+ }
+ case "union":
+ if (seen.has(`union:${type.unionId}`)) break;
+ seen.add(`union:${type.unionId}`);
+ for (const arm of lowerer.unions.get(type.unionId)?.arms ?? []) stack.push(arm);
+ break;
+ case "array": {
+ const key = typeKey(type);
+ if (seen.has(key)) break;
+ seen.add(key);
+ arrays.add(key);
+ stack.push(type.elem);
+ break;
+ }
+ case "set":
+ stack.push(type.elem);
+ break;
+ case "map":
+ stack.push(type.key, type.value);
+ break;
+ case "promise":
+ stack.push(type.inner);
+ break;
+ case "generator":
+ stack.push(type.yieldT, type.retT, type.nextT);
+ break;
+ case "func":
+ for (const param of type.params) stack.push(param);
+ stack.push(type.ret);
+ break;
+ default:
+ break;
+ }
+ }
+ return { shapes, arrays };
+}
+
+/** The guarded form of a write to a freezable record: the receiver and the
+ * operands are evaluated first, in source order, then the frozen bit decides
+ * between the write and `reject`. */
+function guardedWrite(
+ receiver: IrExpr,
+ decls: IrStmt[],
+ reject: IrStmt[],
+ store: IrStmt,
+ loc: SrcLoc,
+): IrStmt {
+ return {
+ kind: "block",
+ body: [
+ ...decls,
+ {
+ kind: "if",
+ cond: { kind: "libCall", fn: "record.isFrozen", args: [receiver], type: BOOL, loc },
+ then: reject,
+ else_: [store],
+ loc,
+ },
+ ],
+ loc,
+ };
+}
+
+/** Checks the frozen bit before every write to a record of a freezable
+ * shape, and lists the freezable array types, whose writes the backend
+ * guards (IrModule.freezableArrays). A write to a frozen record throws
+ * Node's TypeError in strict mode code; in sloppy-mode code it leaves the
+ * record unchanged. Deleting a key the record does not have succeeds either
+ * way. */
+export function guardFrozenWrites(lowerer: Lowerer, functions: IrFunction[]): void {
+ const { shapes: freezable, arrays } = freezableTypes(lowerer, functions);
+ for (const id of freezable) {
+ const shape = lowerer.shapes.get(id);
+ if (shape) shape.freezable = true;
+ }
+ for (const key of arrays) lowerer.freezableArrays.add(key);
+ if (freezable.size === 0) return;
+ const lifted = lowerer.liftedFns.length;
+ for (const fn of functions) {
+ const local = (type: IrType, loc: SrcLoc): Extract => {
+ const id = `%frozenWrite.${fn.locals.length}`;
+ fn.locals.push({ id, name: id, type, mutable: false });
+ return { kind: "varRef", localId: id, type, loc };
+ };
+ const guarded = new Set();
+ fn.body = transformStmtList(fn.body, {
+ stmt: (statement) => {
+ if (!isRecordWrite(statement) || guarded.has(statement)) return statement;
+ if (!freezable.has(statement.shapeId)) return statement;
+ const sloppy = lowerer.sloppyRecordWrites.has(writeKey(statement.loc));
+ const deletes =
+ statement.kind === "recordSet" && lowerer.recordFieldDeletes.has(writeKey(statement.loc));
+ const loc = statement.loc;
+ const shapeId = statement.shapeId;
+ const receiver = local(statement.obj.type, loc);
+ const decls: IrStmt[] = [
+ { kind: "varDecl", localId: receiver.localId, init: statement.obj, loc },
+ ];
+ const owner = strLit(
+ lowerer.shapes.get(shapeId)?.tuple ? "[object Array]" : "#",
+ loc,
+ );
+ const thrown = (key: IrExpr, operation: number): IrStmt => ({
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "record.throwFrozen",
+ args: [key, owner, numLit(operation, loc)],
+ type: VOID,
+ loc,
+ },
+ loc,
+ });
+ if (statement.kind === "recordSet") {
+ let value = statement.value;
+ if (!holdsAbsentState(value)) {
+ const slot = local(value.type, loc);
+ decls.push({ kind: "varDecl", localId: slot.localId, init: value, loc });
+ value = slot;
+ }
+ const store: IrStmt = {
+ kind: "recordSet",
+ obj: receiver,
+ shapeId,
+ field: statement.field,
+ value,
+ loc,
+ };
+ guarded.add(store);
+ const field = strLit(statement.field, loc);
+ // `delete r.f` of an optional field writes its absent state: Node
+ // only refuses it when the field is present.
+ const reject: IrStmt[] = sloppy
+ ? []
+ : deletes
+ ? [
+ {
+ kind: "if",
+ cond: lowerer.recordFieldPresent(receiver, shapeId, statement.field, loc),
+ then: [thrown(field, 2)],
+ else_: null,
+ loc,
+ },
+ ]
+ : [thrown(field, 0)];
+ return guardedWrite(receiver, decls, reject, store, loc);
+ }
+ const key = local(statement.key.type, loc);
+ decls.push({ kind: "varDecl", localId: key.localId, init: statement.key, loc });
+ const named = key.type.kind === "string";
+ const present: IrExpr = named
+ ? {
+ kind: "call",
+ callee: recordHasOwnHelper(lowerer, shapeId, loc),
+ args: [receiver, key],
+ type: BOOL,
+ loc,
+ }
+ : { kind: "boolLit", value: true, type: BOOL, loc };
+ const text: IrExpr = named ? key : strLit("[key]", loc);
+ if (statement.kind === "recordKeySet") {
+ const value = local(statement.value.type, loc);
+ decls.push({ kind: "varDecl", localId: value.localId, init: statement.value, loc });
+ const store: IrStmt = {
+ kind: "recordKeySet",
+ obj: receiver,
+ shapeId,
+ key,
+ value,
+ ...(statement.overflowOnly ? { overflowOnly: true as const } : {}),
+ loc,
+ };
+ guarded.add(store);
+ const reject: IrStmt[] = sloppy
+ ? []
+ : [
+ {
+ kind: "if",
+ cond: present,
+ then: [thrown(text, 0)],
+ else_: [thrown(text, 1)],
+ loc,
+ },
+ ];
+ return guardedWrite(receiver, decls, reject, store, loc);
+ }
+ const store: IrStmt = { kind: "recordKeyDelete", obj: receiver, shapeId, key, loc };
+ guarded.add(store);
+ const reject: IrStmt[] = sloppy
+ ? []
+ : [{ kind: "if", cond: present, then: [thrown(text, 2)], else_: null, loc }];
+ return guardedWrite(receiver, decls, reject, store, loc);
+ },
+ expr: (expression) => expression,
+ });
+ }
+ // The key-presence helpers the guards call.
+ for (const helper of lowerer.liftedFns.slice(lifted)) functions.push(helper);
+}
+
+/** Whether a statically typed Object.freeze can freeze a value of `type`:
+ * a record whose shape has no accessor properties. */
+export function freezableRecordType(lowerer: Lowerer, type: IrType): type is RecordType {
+ if (type.kind !== "record") return false;
+ const shape = lowerer.shapes.get(type.shapeId);
+ return shape !== undefined && !shapeHasAccessorSlots(shape);
+}
diff --git a/packages/compiler/src/frontend/lowering/iterator-adapters.ts b/packages/compiler/src/frontend/lowering/iterator-adapters.ts
index e50652182..9f2b1e31f 100644
--- a/packages/compiler/src/frontend/lowering/iterator-adapters.ts
+++ b/packages/compiler/src/frontend/lowering/iterator-adapters.ts
@@ -28,11 +28,206 @@ export function iteratorSlotAdapter(
expr: IrExpr,
expected: IrType & { kind: "generator" },
): IrExpr | null {
- if (expr.type.kind === "dyn") return dynIteratorAdapter(lowerer, expr, expected);
+ if (expr.type.kind === "dyn")
+ return (
+ collectionIteratorSlotValue(lowerer, expr, expected) ??
+ dynIteratorAdapter(lowerer, expr, expected)
+ );
if (expr.type.kind === "generator") return generatorRetypeAdapter(lowerer, expr, expected);
return null;
}
+/** `m.values()` (or keys/entries, or a Set's) on a typed Map or Set, which
+ * lowers to a dynamic invocation on the boxed collection, flowing into an
+ * iterator-typed slot (`values(): IterableIterator { return
+ * this.rows.values(); }`): an interned generator over the collection that
+ * reads each entry typed, instead of the checked adapter over the boxed
+ * native iterator (dynIteratorAdapter), which boxes every value and checks
+ * it back. It keeps that adapter's protocol: the walk is lazy and live, and
+ * since the native iterator has no return(), an early exit from a
+ * consumer's loop parks it on the current entry instead of completing it,
+ * so a later loop continues with the next entry. Null for any other source
+ * or a slot whose yield type is not the collection's entry type. */
+function collectionIteratorSlotValue(
+ lowerer: Lowerer,
+ expr: IrExpr,
+ expected: IrType & { kind: "generator" },
+): IrExpr | null {
+ if (expr.kind !== "dynInvoke" || expr.args.length > 0 || expr.calleeNameValue) return null;
+ const method = expr.method;
+ if (method !== "values" && method !== "keys" && method !== "entries") return null;
+ if (expr.recv.kind !== "dynFrom") return null;
+ const receiver = expr.recv.value;
+ const collT = receiver.type;
+ if (collT.kind !== "map" && collT.kind !== "set") return null;
+ if (expected.async || expected.yieldT.kind === "void") return null;
+ if (expected.retT.kind !== "void" && expected.retT.kind !== "dyn") return null;
+ if (expected.nextT.kind !== "undefinedT" && expected.nextT.kind !== "dyn") return null;
+ const resultType = genResultRecord(
+ expected.yieldT,
+ expected.retT,
+ lowerer.shapes,
+ lowerer.unions,
+ );
+ if (!resultType) return null;
+ const loc = expr.loc;
+ const key = `collIterSlot:${method}:${typeKey(collT)}:${typeKey(expected)}`;
+ let name = lowerer.valueHelpers.get(key);
+ if (!name) {
+ const coll = varRef("coll.0", collT, loc);
+ const index = varRef("index.0", F64, loc);
+ const native = varRef("native.0", DYN, loc);
+ const resumedFlag = varRef("resumed.0", BOOL, loc);
+ const keyT = collT.kind === "map" ? collT.key : collT.elem;
+ const valueT = collT.kind === "map" ? collT.value : collT.elem;
+ const read = (m: "iterKey" | "iterValue", t: IrType): IrExpr =>
+ collT.kind === "map"
+ ? { kind: "mapIntrinsic", method: m, receiver: coll, args: [index], type: t, loc }
+ : { kind: "setIntrinsic", method: "iterKey", receiver: coll, args: [index], type: t, loc };
+ // entries() answers [key, value] pairs: only a slot whose yield type is
+ // that pair as a record of the two fields takes the typed walk; a tuple
+ // or other pair type keeps the checked adapter.
+ if (method === "entries") {
+ const pair =
+ expected.yieldT.kind === "record" ? lowerer.shapes.get(expected.yieldT.shapeId) : undefined;
+ const field = (name: string): IrType | undefined =>
+ pair?.fields.find((f) => f.name === name)?.type;
+ const first = field("0");
+ const second = field("1");
+ if (
+ pair === undefined ||
+ pair.fields.length !== 2 ||
+ first === undefined ||
+ second === undefined ||
+ !typeEquals(first, keyT) ||
+ !typeEquals(second, valueT)
+ )
+ return null;
+ }
+ const entry = (): IrExpr =>
+ method === "keys" || (collT.kind === "set" && method === "values")
+ ? read("iterKey", keyT)
+ : method === "values"
+ ? read("iterValue", valueT)
+ : {
+ kind: "recordLit",
+ fields: [
+ { name: "0", value: read("iterKey", keyT) },
+ { name: "1", value: read("iterValue", valueT) },
+ ],
+ type: expected.yieldT,
+ loc,
+ };
+ if (!typeEquals(entry().type, expected.yieldT)) return null;
+ // The entry is read once per step and kept: the parking yield of an
+ // early exit answers it again rather than rereading a slot the loop may
+ // have deleted.
+ const current = varRef("current.0", expected.yieldT, loc);
+ const suspend = (): IrStmt => ({
+ kind: "exprStmt",
+ expr: { kind: "yieldExpr", value: current, type: resumeType(expected.nextT), loc },
+ loc,
+ });
+ const finish: IrStmt = {
+ kind: "return",
+ value: expected.retT.kind === "dyn" ? dynUndefinedExpr(loc) : null,
+ loc,
+ };
+ name = keyedHelperName(lowerer, "%coll.iterSlot", key);
+ lowerer.valueHelpers.set(key, name);
+ lowerer.liftedFns.push({
+ name,
+ params: [{ localId: "coll.0", name: "coll", type: collT }],
+ returnType: expected.retT,
+ locals: [
+ { id: "coll.0", name: "coll", type: collT, mutable: false },
+ { id: "native.0", name: "native", type: DYN, mutable: false },
+ { id: "index.0", name: "index", type: F64, mutable: true },
+ { id: "resumed.0", name: "resumed", type: BOOL, mutable: true },
+ { id: "current.0", name: "current", type: expected.yieldT, mutable: false },
+ ],
+ body: [
+ {
+ kind: "varDecl",
+ localId: "native.0",
+ init: {
+ kind: "dynInvoke",
+ recv: lowerer.coerceToExpected(coll, DYN),
+ method: "values",
+ calleeName: collT.kind === "map" ? "map.values" : "set.values",
+ args: [],
+ type: DYN,
+ loc,
+ },
+ loc,
+ },
+ {
+ kind: "varDecl",
+ localId: "resumed.0",
+ init: { kind: "boolLit", value: false, type: BOOL, loc },
+ loc,
+ },
+ {
+ kind: "while",
+ cond: { kind: "boolLit", value: true, type: BOOL, loc },
+ body: [
+ {
+ kind: "assign",
+ localId: "index.0",
+ value: {
+ kind: "libCall",
+ fn: "dyn.collectionIteratorIndex",
+ args: [native],
+ type: F64,
+ loc,
+ },
+ loc,
+ },
+ {
+ kind: "if",
+ cond: {
+ kind: "bin",
+ op: "<",
+ left: index,
+ right: { kind: "numLit", value: 0, type: F64, loc },
+ type: BOOL,
+ loc,
+ },
+ then: [finish],
+ else_: null,
+ loc,
+ },
+ setResumed(false, loc),
+ { kind: "varDecl", localId: "current.0", init: entry(), loc },
+ {
+ kind: "tryCatch",
+ tryBody: [suspend(), setResumed(true, loc)],
+ catchBody: null,
+ catchLocalId: null,
+ // An early exit parks on the current entry; the next step
+ // resumes the walk after it.
+ finallyBody: [
+ {
+ kind: "if",
+ cond: { kind: "unary", op: "!", operand: resumedFlag, type: BOOL, loc },
+ then: [suspend(), { kind: "continue", loc }],
+ else_: null,
+ loc,
+ },
+ ],
+ loc,
+ },
+ ],
+ loc,
+ },
+ ],
+ generator: { yieldT: expected.yieldT, nextT: expected.nextT, resultType, tag: "!" },
+ loc,
+ });
+ }
+ return { kind: "call", callee: name, args: [receiver], type: expected, loc };
+}
+
/** `resumed.0 = value` — the adapters' early-exit marker around a yield. */
function setResumed(value: boolean, loc: SrcLoc): IrStmt {
return {
diff --git a/packages/compiler/src/frontend/lowering/js-class-fields.ts b/packages/compiler/src/frontend/lowering/js-class-fields.ts
index 2f726ce97..28a7ceeda 100644
--- a/packages/compiler/src/frontend/lowering/js-class-fields.ts
+++ b/packages/compiler/src/frontend/lowering/js-class-fields.ts
@@ -2,11 +2,19 @@ import * as ts from "../ts7/adapter.js";
import { isClassOwnEnumerableFieldName } from "../../ir/ir.js";
import type { ClassInfo } from "./lower-classes.js";
-/** Reflection that can remove a property or change its attributes. */
-const PROPERTY_REFLECTION = new Set(["deleteProperty", "freeze", "seal", "preventExtensions"]);
+/* Object.freeze, Object.seal and Object.preventExtensions do not appear
+ * here: they never create or remove a property, and a class instance keeps
+ * its integrity state outside the field layout. A statically typed call
+ * sets the instance's CLASS_ATTRIBUTES word (class-attributes.ts), which
+ * TypeScript classes use with the same fixed layouts; through an untyped
+ * value the runtime refuses the operation on any class instance
+ * (scr_dyn_class_integrity_fence). Lifting that fence must revisit this. */
-/** `Object.freeze({ ... })` and the like apply to a new literal, which is
- * not a class instance. */
+/** Reflection that can remove any property. */
+const PROPERTY_REFLECTION = new Set(["deleteProperty"]);
+
+/** `Reflect.deleteProperty({ ... }, key)` applies to a new literal, which
+ * is not a class instance. */
function freshLiteralArgument(callee: ts.PropertyAccessExpression): boolean {
const call = callee.parent;
if (!ts.isCallExpression(call) || call.expression !== callee) return false;
@@ -141,12 +149,35 @@ export function constructionPrefix(
return { created, sealed };
}
-/** Whether a class declares a getter or setter, which can intercept a
- * base constructor's field assignment. */
-function declaresAccessor(info: ClassInfo): boolean {
- return [...info.methods.keys()].some(
- (name) => name.startsWith("get:") || name.startsWith("set:"),
- );
+/** The names of the getters and setters among a class's own methods. */
+export function accessorNames(methods: Iterable): Set {
+ const names = new Set();
+ for (const name of methods)
+ if (name.startsWith("get:") || name.startsWith("set:")) names.add(name.slice(4));
+ return names;
+}
+
+/** Whether one of `accessors` can intercept an assignment the base
+ * constructions make: a base constructor's `this.name = value` runs a
+ * subclass accessor of that name instead of creating the field. Only field
+ * names can reach the created prefix; any other assignment already ends
+ * it. */
+export function accessorInterceptsBase(accessors: ReadonlySet, base: ClassInfo): boolean {
+ for (const name of accessors) if (base.fields.has(name)) return true;
+ return false;
+}
+
+/** Whether an accessor that instances of `info` inherit or declare shares
+ * a name with one of their fields. Such a field and accessor disagree on
+ * what the instance's property is; any other accessor lives on the
+ * prototype beside the fields and never touches the layout. */
+function accessorShadowsField(info: ClassInfo): boolean {
+ for (let current: ClassInfo | null = info; current; current = current.base) {
+ if (current.def.runtime) break;
+ for (const name of accessorNames(current.methods.keys()))
+ if (info.fields.has(name)) return true;
+ }
+ return false;
}
/** `constructionPrefix` for a collected class and its bases. */
@@ -156,7 +187,8 @@ export function construction(info: ClassInfo): Construction | null {
if (info.base) {
base = construction(info.base);
if (!base) return null;
- if (declaresAccessor(info)) base = { created: base.created, sealed: false };
+ if (accessorInterceptsBase(accessorNames(info.methods.keys()), info.base))
+ base = { created: base.created, sealed: false };
}
return constructionPrefix(info.decl, base, (name) => info.fields.has(name));
}
@@ -169,7 +201,7 @@ export function construction(info: ClassInfo): Construction | null {
export function fixedFieldFamily(root: ClassInfo, deleted: Set | null): boolean {
if (deleted === null) return false;
const fixed = (info: ClassInfo): boolean => {
- if (declaresAccessor(info)) return false;
+ if (accessorShadowsField(info)) return false;
const built = construction(info);
if (!built) return false;
for (const field of info.fieldOrder) {
diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts
index 8058d0066..21fe56815 100644
--- a/packages/compiler/src/frontend/lowering/key-order-witness.test.ts
+++ b/packages/compiler/src/frontend/lowering/key-order-witness.test.ts
@@ -29,7 +29,7 @@ interface Fixture {
notes: KeyOrderNotes;
record(fields: [string, IrType][], order: string[]): IrType & { kind: "record" };
literal(type: IrType, keys: string[], segments?: KeyOrderSegment[]): IrExpr;
- run(body: IrStmt[]): IrFunction;
+ run(body: IrStmt[], name?: string): IrFunction;
order(type: IrType & { kind: "record" }): string[] | undefined;
}
@@ -42,6 +42,7 @@ function fixture(): Fixture {
unions: new Map([["u0", { id: "u0", arms: [F64, { kind: "undefinedT" }] }]]),
classes: new Map(),
methodAccessSites: new Map(),
+ dynConvertible: () => true,
armTag: (unionId: string, arm: IrType): number =>
unionId === "u0" && arm.kind === "undefinedT" ? 1 : -1,
recordFieldPresent: (obj: IrExpr, shapeId: string, field: string): IrExpr => ({
@@ -72,8 +73,8 @@ function fixture(): Fixture {
segments ?? keys.map((name) => ({ kind: "key", name, definite: true })),
);
};
- const run = (body: IrStmt[]): IrFunction => {
- const fn: IrFunction = { name: "main", params: [], returnType: F64, locals: [], body, loc };
+ const run = (body: IrStmt[], name = "main"): IrFunction => {
+ const fn: IrFunction = { name, params: [], returnType: F64, locals: [], body, loc };
applyKeyOrderWitnesses(lowerer, [fn]);
return fn;
};
@@ -705,7 +706,10 @@ test("a spread reads its source's order right after the literal evaluates the so
/** `{ a, b }` with a declared table `["a", "b"]` and a literal `{ b, a }`
* plus `{ ...value, a }` of the same shape, beside `extra`. */
-function selfCopy(extra: (f: Fixture, ab: IrType & { kind: "record" }) => IrStmt[]): string[] {
+function selfCopy(
+ extra: (f: Fixture, ab: IrType & { kind: "record" }) => IrStmt[],
+ fnName = "main",
+): string[] {
const f = fixture();
const ab = f.record(
[
@@ -715,11 +719,14 @@ function selfCopy(extra: (f: Fixture, ab: IrType & { kind: "record" }) => IrStmt
["a", "b"],
);
const self: KeyOrderSegment = { kind: "spread", shapeId: ab.shapeId, maybeAbsent: false };
- f.run([
- decl("v", f.literal(ab, ["b", "a"])),
- observe(f.literal(ab, ["a", "b"], [self, { kind: "key", name: "a", definite: true }])),
- ...extra(f, ab),
- ]);
+ f.run(
+ [
+ decl("v", f.literal(ab, ["b", "a"])),
+ observe(f.literal(ab, ["a", "b"], [self, { kind: "key", name: "a", definite: true }])),
+ ...extra(f, ab),
+ ],
+ fnName,
+ );
return f.order(ab)!;
}
@@ -761,6 +768,31 @@ test("a replaceable method's checked result builds records when a replacement ma
).toEqual(["a", "b"]);
});
+/** A native callback taking the shape, boxed for JavaScript. */
+const boxedCallback = (ab: IrType): IrExpr => ({
+ kind: "dynFrom",
+ value: {
+ kind: "closure",
+ fnName: "callback",
+ captures: [],
+ type: { kind: "func", params: [ab], ret: { kind: "void" } },
+ loc,
+ },
+ type: { kind: "dyn" },
+ loc,
+});
+
+test("a native callback boxed for JavaScript receives records JavaScript builds", () => {
+ expect(selfCopy((_, ab) => [observe(boxedCallback(ab))])).toEqual(["a", "b"]);
+});
+
+test("a class's prototype slots box its methods without building their parameters", () => {
+ expect(selfCopy((_, ab) => [observe(boxedCallback(ab))], "%prototype.data.Service")).toEqual([
+ "b",
+ "a",
+ ]);
+});
+
test("a replaceable method's result keeps its witness through an IR rewrite", () => {
expect(
selfCopy((f, ab) => {
@@ -838,3 +870,127 @@ test("a checked function passes its record parameters out, and its callbacks' ba
"b",
]);
});
+
+test("a generator resume builds only its result record, never the value it carries", () => {
+ const f = fixture();
+ const ab = f.record(
+ [
+ ["a", F64],
+ ["b", F64],
+ ],
+ ["a", "b"],
+ );
+ const result = f.record(
+ [
+ ["done", { kind: "bool" }],
+ ["value", ab],
+ ],
+ ["value", "done"],
+ );
+ const gen: IrType = {
+ kind: "generator",
+ yieldT: ab,
+ retT: { kind: "void" },
+ nextT: { kind: "undefinedT" },
+ };
+ const resume = (mode: "next" | "value", type: IrType): IrStmt =>
+ observe({ kind: "genResume", mode, gen: ref("g", gen), arg: null, type, loc });
+ f.run([
+ observe(f.literal(ab, ["b", "a"])),
+ observe(f.literal(result, ["done", "value"])),
+ resume("value", ab),
+ resume("next", result),
+ ]);
+ expect(f.order(ab)).toEqual(["b", "a"]);
+ // The result record itself is built in its fixed layout: today's table.
+ expect(f.order(result)).toEqual(["value", "done"]);
+});
+
+/** `{ a, b }` built by a checked conversion and by the literal `{ b, a }`,
+ * copied by `{ ...value, a }` (evaluating the value into a binding, as
+ * spread literals do), observed. Answers the table and the function. */
+function checkedSelfCopy(): { order: string[]; fn: IrFunction; copy: IrExpr } {
+ const f = fixture();
+ const ab = f.record(
+ [
+ ["a", F64],
+ ["b", F64],
+ ],
+ ["a", "b"],
+ );
+ const value = ref("value", ab) as IrExpr & { kind: "varRef" };
+ const self: KeyOrderSegment = { kind: "spread", shapeId: ab.shapeId, maybeAbsent: false, value };
+ const literal = f.literal(ab, ["a", "b"], [self, { kind: "key", name: "a", definite: true }]);
+ const copy: IrExpr = {
+ kind: "seqExpr",
+ stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })],
+ result: literal,
+ type: ab,
+ loc,
+ };
+ const fn = f.run([observe(f.literal(ab, ["b", "a"])), observe(copy)]);
+ return { order: f.order(ab)!, fn, copy };
+}
+
+test("a spread of a value a checked conversion built carries the value's order over", () => {
+ const { order, fn, copy } = checkedSelfCopy();
+ // Values the literal builds enumerate in its order; copies of converted
+ // values keep the order their record source holds.
+ expect(order).toEqual(["b", "a"]);
+ expect(copy).toMatchObject({
+ kind: "seqExpr",
+ stmts: [
+ { kind: "varDecl", localId: "value" },
+ { kind: "varDecl", localId: "%orderedSpread.0", init: { kind: "recordLit" } },
+ {
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "record.sourceSpread",
+ args: [
+ { kind: "varRef", localId: "%orderedSpread.0" },
+ { kind: "varRef", localId: "value" },
+ ],
+ },
+ },
+ ],
+ result: { kind: "varRef", localId: "%orderedSpread.0" },
+ });
+ expect(fn.locals.map((local) => local.id)).toEqual(["%orderedSpread.0"]);
+});
+
+test("a spread of a converted value into a wider shape orders the wider shape", () => {
+ const f = fixture();
+ const ab = f.record(
+ [
+ ["a", F64],
+ ["b", F64],
+ ],
+ ["a", "b"],
+ );
+ const abc = f.record(
+ [
+ ["a", F64],
+ ["b", F64],
+ ["c", F64],
+ ],
+ ["c", "a", "b"],
+ );
+ const value = ref("value", ab) as IrExpr & { kind: "varRef" };
+ const spread: KeyOrderSegment = {
+ kind: "spread",
+ shapeId: ab.shapeId,
+ maybeAbsent: false,
+ value,
+ };
+ const wide: IrExpr = {
+ kind: "seqExpr",
+ stmts: [decl("value", { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc })],
+ result: f.literal(abc, ["a", "b", "c"], [spread, { kind: "key", name: "c", definite: true }]),
+ type: abc,
+ loc,
+ };
+ f.run([observe(f.literal(ab, ["b", "a"])), observe(wide)]);
+ expect(f.order(ab)).toEqual(["b", "a"]);
+ expect(f.order(abc)).toEqual(["b", "a", "c"]);
+});
diff --git a/packages/compiler/src/frontend/lowering/key-order-witness.ts b/packages/compiler/src/frontend/lowering/key-order-witness.ts
index 933c9efcd..a5e635e04 100644
--- a/packages/compiler/src/frontend/lowering/key-order-witness.ts
+++ b/packages/compiler/src/frontend/lowering/key-order-witness.ts
@@ -41,6 +41,12 @@
* - a literal that starts by spreading a value of its own shape copies
* that value's order. While no checked conversion builds the shape,
* every value has the one chosen order, so the copy adds no witness;
+ * - a literal that starts by spreading a value of a shape checked
+ * conversions build (an ordered spread) gives the record it builds the
+ * value's record source, when the value has one (record.sourceSpread).
+ * Every value of a shape enumerates in its table or in the order its
+ * record source holds, so the copy has the value's order either way,
+ * and the literal resolves as if the value were in its table's order;
* - a write to an optional key that may be absent appends it in Node. A
* key no construction creates ("late-only") goes after every key a
* construction can create; any other moving write goes after every key.
@@ -61,6 +67,7 @@ import {
shapeHasAccessorSlots,
typeEquals,
UNDEFINED_T,
+ VOID,
} from "../../ir/ir.js";
import {
everyExprChild,
@@ -71,6 +78,7 @@ import {
import type { Lowerer } from "./lowerer.js";
import * as ts from "../ts7/adapter.js";
import { ALWAYS_THROW_LIB_FNS } from "../../ir/builtin-effects.js";
+import { recordMayKeepSource } from "./record-sources.js";
/** The statements a key-order helper runs for record `ref`: `walks[0]`,
* its walk over the shape's table, or, for a shape with orderVariants, a
@@ -218,6 +226,14 @@ interface ShapeFacts {
type Resolved = { order: string[] } | "unknown";
+/** A literal that starts with `...value`: the sequence that evaluates the
+ * value into its binding and then builds the literal, and its function. */
+interface LeadingSpread {
+ wrapper: IrExpr & { kind: "seqExpr" };
+ literal: IrExpr & { kind: "recordLit" };
+ fn: IrFunction;
+}
+
/** `structuredClone(v)` checked back into v's own type: the copy is built
* from v's dyn view, so it creates exactly v's keys in v's order. */
function orderPreservingClone(node: IrExpr & { kind: "dynCheck" }): boolean {
@@ -276,6 +292,17 @@ function replacesWithoutRecords(lowerer: Lowerer, site: ts.Node): boolean {
);
}
+/** A compiler helper that boxes a class's own state for JavaScript's reads:
+ * its prototype data and the property and method dispatch over boxed
+ * instances. */
+function boxesOwnState(fnName: string): boolean {
+ return (
+ fnName.startsWith("%prototype.data.") ||
+ fnName.startsWith("%dyn.class.") ||
+ fnName.startsWith("%class.")
+ );
+}
+
/** A type whose values are primitives (or void): it holds no record. */
function primitiveOnly(type: ts.Type): boolean {
if (type.isUnionType()) return ts.constituentTypes(type).every(primitiveOnly);
@@ -341,8 +368,18 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
* its checked field setters, each a witness of its own. A checked
* function's parameters receive native values: a record parameter is
* passed out, never built, though a callback among them receives values
- * built from JavaScript again. */
- const shapesIn = (type: IrType, out: Set, seen: Set, produced = false): void => {
+ * built from JavaScript again. `built` false starts the walk at a value
+ * passed out (a native value boxed for JavaScript); `writes` false leaves
+ * out what JavaScript could write into the containers passed out, so the
+ * walk counts only what the functions among them receive. */
+ const shapesIn = (
+ type: IrType,
+ out: Set,
+ seen: Set,
+ produced = false,
+ built = true,
+ writes = true,
+ ): void => {
const visit = (t: IrType, built: boolean): boolean => {
const mark = built ? "" : "-";
// A container JavaScript builds converts into fresh native storage, so
@@ -351,7 +388,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
// such as callbacks whose parameters it then builds (children passed
// out). Those children count in both directions.
const child = (type: IrType): boolean => {
- visit(type, true);
+ if (built || writes) visit(type, true);
if (!built) visit(type, false);
return true;
};
@@ -385,7 +422,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
}
return everyTypeChild(t, child);
};
- visit(type, true);
+ visit(type, built);
};
const opaqueShapes = new Set();
const opaqueSeen = new Set();
@@ -396,6 +433,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
* declaration's order, the order their table holds. */
const declaredShapes = new Set();
const declaredSeen = new Set();
+ const boxedSeen = new Set();
/** The checked result of a replaceable method call while every site that
* can replace the method installs functions that return no record. */
const unchangedMethodResult = (node: IrExpr): boolean => {
@@ -412,6 +450,12 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
// ── collection ──────────────────────────────────────────────────────
let pos = 0;
let fnName = "";
+ let currentFn: IrFunction | null = null;
+ /** Literals that start by spreading a binding, by their witness: where
+ * each one is built. A literal's notes are shared by its rebuilt copies,
+ * so `literalSites` counts every literal of each witness. */
+ const leadingSpreads = new Map();
+ const literalSites = new Map();
const loops: number[] = [];
let loopIds = 0;
const loopKey = (): string => loops.join("/");
@@ -463,8 +507,10 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
}
const segments = notes.literalOf(e);
const copy = notes.copyOf(e);
- if (segments !== undefined) f.literals.push(segments);
- else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys });
+ if (segments !== undefined) {
+ f.literals.push(segments);
+ literalSites.set(segments, (literalSites.get(segments) ?? 0) + 1);
+ } else if (copy !== undefined) f.copies.push({ source: copy, keys: presentKeys });
else f.pinned = true;
return;
}
@@ -479,9 +525,31 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
case "assignExpr":
noteInit(e.localId, e.value);
return;
+ case "seqExpr": {
+ // A spread literal evaluates its sources into bindings first.
+ const literal = e.result;
+ if (literal.kind !== "recordLit" || currentFn === null) return;
+ const segments = notes.literalOf(literal);
+ const first = segments?.[0];
+ if (segments === undefined || first?.kind !== "spread" || first.value === undefined) return;
+ const list = leadingSpreads.get(segments) ?? [];
+ list.push({ wrapper: e, literal, fn: currentFn });
+ leadingSpreads.set(segments, list);
+ return;
+ }
case "jsonStringify":
case "dynFrom":
observedRoots.push(e.value.type);
+ // A native value the program hands to JavaScript: the callbacks it
+ // holds (itself, a field, an element) receive records JavaScript
+ // builds. The helpers that box the program's own state for
+ // JavaScript's reads (a class's prototype slots and property
+ // dispatch) hand out the class's methods, whose arguments the
+ // program's own calls pass as native values, as a replaceable
+ // method's result above. Writes through the boxed value are left
+ // out: reading a boxed value creates no record.
+ if (!boxesOwnState(fnName))
+ shapesIn(e.value.type, runtimeShapes, boxedSeen, true, false, false);
return;
case "recordKeyGet":
observedRoots.push(e.obj.type);
@@ -506,12 +574,23 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
case "strIntrinsic":
case "regexIntrinsic":
case "bytesIntrinsic":
- // Generator results ({ done, value }) and Promise.withResolvers
- // records are built by the backend in a fixed layout.
- case "genResume":
+ // Promise.withResolvers records are built by the backend in a fixed
+ // layout.
case "promiseWithResolvers":
producedBy(e.type, true);
return;
+ case "genResume": {
+ // A resume builds at most its IteratorResult record ({ done, value }
+ // in a fixed layout; an async generator's settles a promise of it).
+ // The value it carries is the generator's own yielded or returned
+ // value, which the generator's body constructs. The record-free
+ // drives build nothing: `value` moves that value out, and step,
+ // close and a record-free delegate answer a boolean or nothing.
+ if (e.mode === "step" || e.mode === "value" || e.mode === "close") return;
+ const result = e.type.kind === "promise" ? e.type.inner : e.type;
+ if (result.kind === "record") opaqueShapes.add(result.shapeId);
+ return;
+ }
case "dynCheck":
if (e.adopt === true) shapesIn(e.type, declaredShapes, declaredSeen);
// Conversions that build no record: a boxed primitive, and a
@@ -571,6 +650,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
};
for (const fn of fns) {
fnName = fn.name;
+ currentFn = fn;
for (const st of fn.body) stmt(st);
}
for (const id of opaqueShapes) {
@@ -687,6 +767,61 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
return edges;
};
+ // ── ordered spreads ─────────────────────────────────────────────────
+ // A value a checked conversion builds keeps its source's key order in
+ // its record source when that order is not the table's (record-
+ // sources.ts), so every value of a shape enumerates in the table's order
+ // or in the order its record source holds. A literal that starts with
+ // `...value` creates the value's keys first, in the value's order. When
+ // the value's shape is one checked conversions build, the literal
+ // carries that order over: after it builds the record it gives the
+ // record a source holding the value's order (record.sourceSpread), and
+ // the keys the value lacks follow in the table's order, which every
+ // literal sequence of the shape agrees with. Such a literal resolves as
+ // if its source were in the source's table order, and its own shape
+ // counts as built at run time too, since its values may now carry a
+ // source. It qualifies when every value of the source has all of the
+ // source's keys, both shapes can keep a source, and every later spread
+ // whose values may carry a source only overwrites the source's keys
+ // (its own order cannot move them).
+ const orderedSpreads = new Set();
+ const keepsSource = (shapeId: string): boolean =>
+ recordMayKeepSource(lowerer, { kind: "record", shapeId });
+ const userKeys = (shapeId: string): string[] =>
+ (shapes.get(shapeId)?.fields ?? []).map((x) => x.name).filter((k) => !k.startsWith("%"));
+ const ordersSpread = (segments: KeyOrderSegment[], sites: readonly LeadingSpread[]): boolean => {
+ const first = segments[0];
+ if (first?.kind !== "spread" || first.maybeAbsent || first.value === undefined) return false;
+ const sourceId = first.shapeId;
+ if (facts.get(sourceId)?.runtime !== true || sites.length !== literalSites.get(segments))
+ return false;
+ const target = sites[0]!.literal.type;
+ if (target.kind !== "record" || !keepsSource(sourceId) || !keepsSource(target.shapeId))
+ return false;
+ const localId = first.value.localId;
+ const bound = ({ wrapper, literal }: LeadingSpread): boolean =>
+ typeEquals(literal.type, target) &&
+ wrapper.stmts.some(
+ (st) =>
+ st.kind === "varDecl" &&
+ st.localId === localId &&
+ st.init !== null &&
+ st.init.type.kind === "record" &&
+ st.init.type.shapeId === sourceId,
+ );
+ if (!sites.every(bound)) return false;
+ const sourceKeys = new Set(userKeys(sourceId));
+ if ([...sourceKeys].some((k) => maybeAbsent(sourceId, k))) return false;
+ return segments
+ .slice(1)
+ .every(
+ (s) =>
+ s.kind === "key" ||
+ (s.kind === "spread" &&
+ (facts.get(s.shapeId)?.runtime !== true ||
+ userKeys(s.shapeId).every((k) => sourceKeys.has(k)))),
+ );
+ };
// ── witness sequences ───────────────────────────────────────────────
/** The key sequences one literal can create, or null when a spread
* source's order is unknown. A key sits at its first occurrence that
@@ -696,15 +831,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
segments: readonly KeyOrderSegment[],
keySet: ReadonlySet,
resolved: (id: string) => Resolved,
+ raw: (id: string) => Resolved,
): string[][] | null => {
const occurrences: { key: string; definite: boolean }[] = [];
+ const ordered = orderedSpreads.has(segments);
for (const s of segments) {
if (s.kind === "opaque") return null;
if (s.kind === "key") {
if (keySet.has(s.name)) occurrences.push({ key: s.name, definite: s.definite });
continue;
}
- const source = resolved(s.shapeId);
+ // An ordered spread's values without a source enumerate in the
+ // source's table; the others carry their order over.
+ const source = ordered && s === segments[0] ? raw(s.shapeId) : resolved(s.shapeId);
if (source === "unknown") return null;
for (const k of source.order) {
if (keySet.has(k))
@@ -798,13 +937,17 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
first.kind === "spread" &&
first.shapeId === id &&
!first.maybeAbsent &&
- !f.runtime &&
+ (!f.runtime || orderedSpreads.has(segments)) &&
segments.every((s) => s.kind !== "opaque") &&
table.every((k) => !maybeAbsent(id, k))
);
};
/** One shape's order given its sources' current orders. */
- const resolveOne = (id: string, current: (source: string) => Resolved): Resolved => {
+ const resolveOne = (
+ id: string,
+ current: (source: string) => Resolved,
+ raw: (source: string) => Resolved,
+ ): Resolved => {
const shape = shapes.get(id);
if (!inScope(shape)) return "unknown";
const table = tableOf(shape);
@@ -821,7 +964,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
if (f.pinned) chain(table, edges);
for (const segments of f.literals) {
if (copiesItself(id, f, table, segments)) continue;
- const sequences = literalSequences(segments, keySet, current);
+ const sequences = literalSequences(segments, keySet, current, raw);
if (sequences === null) return "unknown";
for (const seq of sequences) chain(seq, edges);
}
@@ -880,32 +1023,57 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
const known = result.get(id);
return known === undefined ? "unknown" : asSource(id, known);
};
- for (const scc of sccs) {
- const members = new Set(scc);
- const guess = new Map();
- for (const id of scc) {
- const shape = shapes.get(id);
- guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown");
- }
- const lookup = (id: string): Resolved =>
- members.has(id) ? asSource(id, guess.get(id)!) : external(id);
- let stable = false;
- for (let round = 0; round < scc.length + 3 && !stable; round++) {
- stable = true;
+ const resolveAll = (): void => {
+ result.clear();
+ late.clear();
+ for (const scc of sccs) {
+ const members = new Set(scc);
+ const guess = new Map();
for (const id of scc) {
- const next = resolveOne(id, lookup);
- const prev = guess.get(id)!;
- const same =
- next === "unknown"
- ? prev === "unknown"
- : prev !== "unknown" && prev.order.join("\0") === next.order.join("\0");
- if (!same) {
- stable = false;
- guess.set(id, next);
+ const shape = shapes.get(id);
+ guess.set(id, inScope(shape) ? { order: tableOf(shape) } : "unknown");
+ }
+ const lookup = (id: string): Resolved =>
+ members.has(id) ? asSource(id, guess.get(id)!) : external(id);
+ const rawLookup = (id: string): Resolved =>
+ members.has(id) ? guess.get(id)! : (result.get(id) ?? "unknown");
+ let stable = false;
+ for (let round = 0; round < scc.length + 3 && !stable; round++) {
+ stable = true;
+ for (const id of scc) {
+ const next = resolveOne(id, lookup, rawLookup);
+ const prev = guess.get(id)!;
+ const same =
+ next === "unknown"
+ ? prev === "unknown"
+ : prev !== "unknown" && prev.order.join("\0") === next.order.join("\0");
+ if (!same) {
+ stable = false;
+ guess.set(id, next);
+ }
}
}
+ for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown");
}
- for (const id of scc) result.set(id, stable ? guess.get(id)! : "unknown");
+ };
+ // Ordered spreads join once their source resolves; each one can let its
+ // own shape resolve, which a later spread of it needs.
+ resolveAll();
+ for (let grew = true; grew;) {
+ grew = false;
+ for (const [segments, sites] of leadingSpreads) {
+ const first = segments[0];
+ if (first?.kind !== "spread" || orderedSpreads.has(segments)) continue;
+ // A self copy resolves with its own shape.
+ const target = sites[0]!.literal.type;
+ const source = result.get(first.shapeId) ?? "unknown";
+ const self = target.kind === "record" && target.shapeId === first.shapeId;
+ if ((source === "unknown" && !self) || !ordersSpread(segments, sites)) continue;
+ orderedSpreads.add(segments);
+ if (target.kind === "record") factsOf(target.shapeId).runtime = true;
+ grew = true;
+ }
+ if (grew) resolveAll();
}
// ── observation ─────────────────────────────────────────────────────
@@ -914,6 +1082,19 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
for (const id of notes.observed)
shapesIn({ kind: "record", shapeId: id }, observed, observedSeen);
for (const root of observedRoots) shapesIn(root, observed, observedSeen);
+ // An ordered spread's copies of values without a source take the source's
+ // order from its table, which must then hold the resolved order.
+ const orderedTarget = (sites: readonly LeadingSpread[]): string | null => {
+ const target = sites[0]!.literal.type;
+ return target.kind === "record" && (result.get(target.shapeId) ?? "unknown") !== "unknown"
+ ? target.shapeId
+ : null;
+ };
+ for (const [segments, sites] of leadingSpreads) {
+ const first = segments[0];
+ if (orderedSpreads.has(segments) && first?.kind === "spread" && orderedTarget(sites) !== null)
+ observed.add(first.shapeId);
+ }
// ── apply ───────────────────────────────────────────────────────────
for (const [id, resolved] of result) {
@@ -926,6 +1107,46 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
notes.changed.add(id);
}
+ // Ordered spreads carry their source's order over (see above).
+ let orderedResults = 0;
+ const rewritten = new Set();
+ for (const [segments, sites] of leadingSpreads) {
+ const first = segments[0];
+ if (!orderedSpreads.has(segments) || first?.kind !== "spread" || first.value === undefined)
+ continue;
+ // A shape that did not resolve keeps today's table for every value.
+ if (orderedTarget(sites) === null) continue;
+ for (const { wrapper, literal, fn } of sites) {
+ if (rewritten.has(wrapper)) continue;
+ rewritten.add(wrapper);
+ const loc = literal.loc;
+ const localId = `%orderedSpread.${orderedResults++}`;
+ fn.locals.push({ id: localId, name: "%orderedSpread", type: literal.type, mutable: false });
+ const built: IrExpr = { kind: "varRef", localId, type: literal.type, loc };
+ const source: IrExpr = {
+ kind: "varRef",
+ localId: first.value.localId,
+ type: { kind: "record", shapeId: first.shapeId },
+ loc,
+ };
+ wrapper.stmts.push(
+ { kind: "varDecl", localId, init: literal, loc },
+ {
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "record.sourceSpread",
+ args: [built, source],
+ type: VOID,
+ loc,
+ },
+ loc,
+ },
+ );
+ wrapper.result = { kind: "varRef", localId, type: literal.type, loc };
+ }
+ }
+
// ── per-instance orders ─────────────────────────────────────────────
// A shape whose literals write its keys in more than one order has no
// one table. Each value then records which of the orders it was written
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index 672801e9e..77865e066 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -119,7 +119,7 @@ import {
} from "./containers/indexed-objects.js";
import { lowerTupleReadMethodCall } from "./containers/array-methods.js";
import { tryFuseIndexRead } from "./array-values.js";
-import { lowerBytesStaticCall } from "./containers/bytes.js";
+import { lowerBytesPrototypeSetCall, lowerBytesStaticCall } from "./containers/bytes.js";
import {
lowerRegexMethodCall,
lowerStringIndexCall,
@@ -245,6 +245,7 @@ import {
import { fenceSymbolFieldCopy } from "./symbol-fields.js";
import { lowerClassDataDescriptor, lowerClassDescriptorRead } from "./class-descriptors.js";
import { lowerClassFieldDefine, lowerClassIntegrity } from "./class-attributes.js";
+import { freezableRecordType, lowerNativeFreeze } from "./frozen-records.js";
import { classStaticDataFor } from "./class-static-data.js";
import { lowerUrlFactory } from "./lower-url.js";
import { nullishValueUnitOf } from "./binding-analysis.js";
@@ -1578,6 +1579,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
lowerer.lowerTestMethodCall(expr, expr.expression) ??
lowerer.lowerTimeoutMethodCall(expr, expr.expression) ??
lowerArrayPrototypeBorrowCall(lowerer, expr, expr.expression) ??
+ lowerBytesPrototypeSetCall(lowerer, expr, expr.expression) ??
lowerObjectOwnPrototypeCall(lowerer, expr, expr.expression) ??
lowerObjectPrototypeCall(lowerer, expr, expr.expression) ??
lowerStringPrototypeCall(lowerer, expr, expr.expression) ??
@@ -3755,6 +3757,14 @@ function lowerDynReceiverMethodCall(
// 'unknown' slot does.
if (checkerUntyped && DYN_HANDLE_KINDS.has(recv.type.kind))
recv = lowerer.coerceToExpected(recv, DYN);
+ // A typed array whose checker type collapsed to `any` (a JS binding
+ // referenced in its own initializer's inference — hono's etag digest
+ // `const nextChunk = new Uint8Array(new ArrayBuffer(f(chunk)))` beside
+ // `chunk = nextChunk`) still lowered to its native bytes. The typed
+ // lowerings key on the checker type and decline it, so it boxes by
+ // reference (the box shares the native storage, so writes through the
+ // call stay visible) and the call dispatches on the runtime kind.
+ if (checkerUntyped && recv.type.kind === "bytes") recv = lowerer.coerceToExpected(recv, DYN);
// The last-resort retry (boxTypedTemporary — every other lowering
// already declined): a checker-`any` receiver whose value is a FRESH
// typed result of an earlier step in the chain (`out.split("\n")`
@@ -7271,7 +7281,7 @@ export function recordKeysArrayCall(
* (a key is own exactly when Object.keys would list it — the two share
* the test), everything else true. Unmatched keys probe the overflow
* map when present, so explicit undefined values remain own keys. */
-function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): string {
+export function recordHasOwnHelper(lowerer: Lowerer, shapeId: string, loc: SrcLoc): string {
const key = `obj.hasOwn:${shapeId}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
@@ -8486,6 +8496,22 @@ function lowerObjectStaticCall(
locOf(call),
);
if (fnIntegrity) return fnIntegrity;
+ // A Map or Set has no own properties: freeze, seal and preventExtensions
+ // all only make it not extensible.
+ if (value.type.kind === "map" || value.type.kind === "set") {
+ if (member === "preventExtensions" || member === "seal")
+ return lowerNativeFreeze(lowerer, "collection.preventExtensions", value, locOf(call));
+ const extensible: IrExpr = {
+ kind: "libCall",
+ fn: "collection.isExtensible",
+ args: [value],
+ type: BOOL,
+ loc: locOf(call),
+ };
+ return member === "isExtensible"
+ ? extensible
+ : { kind: "unary", op: "!", operand: extensible, type: BOOL, loc: locOf(call) };
+ }
if (member === "preventExtensions" || member === "seal") {
const integrity = lowerClassIntegrity(lowerer, call, member, value);
if (integrity) return integrity;
@@ -8520,10 +8546,44 @@ function lowerObjectStaticCall(
// the argument — no alias exists, so the frozen bit is unobservable
// (writes through the Readonly result are compile errors, and no
// other reference can write). Primitives pass through per ES2015.
- // Aliased objects keep a fence: a later write through the original
- // reference would need the runtime frozen bit (strict mode throws).
+ // Aliased records get the frozen bit of their rc word (frozen-records.ts);
+ // other aliased objects keep a fence: a later write through the original
+ // reference would need a runtime frozen bit (strict mode throws).
if (member === "isFrozen" && call.arguments.nodes.length === 1) {
- const value = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, DYN);
+ const argNode = call.arguments.nodes[0]!;
+ const probedKind = tryLowerExpression(lowerer, argNode)?.type.kind;
+ if (
+ probedKind === "record" ||
+ probedKind === "array" ||
+ probedKind === "map" ||
+ probedKind === "set"
+ ) {
+ const native = lowerer.lowerExpr(argNode);
+ const loc = locOf(call);
+ if (native.type.kind === "record" || native.type.kind === "array")
+ return {
+ kind: "libCall",
+ fn: native.type.kind === "record" ? "record.isFrozen" : "array.isFrozen",
+ args: [native],
+ type: BOOL,
+ loc,
+ };
+ if (native.type.kind === "map" || native.type.kind === "set")
+ return {
+ kind: "unary",
+ op: "!",
+ operand: {
+ kind: "libCall",
+ fn: "collection.isExtensible",
+ args: [native],
+ type: BOOL,
+ loc,
+ },
+ type: BOOL,
+ loc,
+ };
+ }
+ const value = lowerer.lowerExprExpecting(argNode, DYN);
return { kind: "libCall", fn: "dyn.isFrozen", args: [value], type: BOOL, loc: locOf(call) };
}
if (member === "freeze") {
@@ -8573,6 +8633,12 @@ function lowerObjectStaticCall(
if (fnIntegrity) return fnIntegrity;
const integrity = lowerClassIntegrity(lowerer, call, "freeze", value);
if (integrity) return integrity;
+ if (freezableRecordType(lowerer, value.type))
+ return lowerNativeFreeze(lowerer, "record.freeze", value, locOf(call));
+ if (value.type.kind === "array")
+ return lowerNativeFreeze(lowerer, "array.freeze", value, locOf(call));
+ if (value.type.kind === "map" || value.type.kind === "set")
+ return lowerNativeFreeze(lowerer, "collection.preventExtensions", value, locOf(call));
lowerer.noLowering(
"Object.freeze of a possibly-aliased value",
call,
@@ -8712,11 +8778,13 @@ function lowerObjectStaticCall(
const moduleSymbol = source ? lowerer.checker.getSymbolAtLocation(source) : undefined;
const names: string[] = [];
moduleSymbol?.getExports().forEach((symbol, key) => {
+ // Internal names (`__export`) start with `__`; an exported name that
+ // does is keyed with an extra `_` (escapedSymbolName).
const name = String(key);
- if (name.startsWith("__") || name === "export=") return;
+ if ((name.startsWith("__") && !name.startsWith("___")) || name === "export=") return;
const target =
symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol;
- if (target.flags & ts.SymbolFlags.Value) names.push(name);
+ if (target.flags & ts.SymbolFlags.Value) names.push(ts.symbolName(symbol));
});
names.sort();
const loc = locOf(call);
diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts
index fe0873675..9b4aac191 100644
--- a/packages/compiler/src/frontend/lowering/lower-classes.ts
+++ b/packages/compiler/src/frontend/lowering/lower-classes.ts
@@ -1,3 +1,4 @@
+import { lowerAsyncLocalStorageOptions } from "./builtins/async-context.js";
import {
DATE_TIME_FIELD,
dateRootOf,
@@ -177,7 +178,12 @@ import { hasLexicalPrivateReference, hasStaticThis, rejectStaticThis } from "./s
import { lowerUrlNew } from "./lower-url.js";
import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js";
import { classStaticDataFor } from "./class-static-data.js";
-import { construction, constructionPrefix } from "./js-class-fields.js";
+import {
+ accessorInterceptsBase,
+ accessorNames,
+ construction,
+ constructionPrefix,
+} from "./js-class-fields.js";
import { lowerObjectFactoryNew } from "./object-factory-new.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerInstanceConstructorNew } from "./class-instance-constructor.js";
@@ -1420,7 +1426,7 @@ function undefArmedFieldType(lowerer: Lowerer, p: ts.Symbol): IrType | null {
const arms =
mapped.kind === "union" ? (lowerer.unions.get(mapped.unionId)?.arms ?? []) : [mapped];
for (const a of arms) {
- if (a.kind === "map" || a.kind === "regex" || a.kind === "date" || a.kind === "generator") {
+ if (a.kind === "map" || a.kind === "regex" || a.kind === "generator") {
return null;
}
byKey.set(typeKey(a), a);
@@ -3977,7 +3983,6 @@ export function collectClassShapeInner(
declared.kind !== "union" &&
declared.kind !== "dyn" &&
declared.kind !== "map" &&
- declared.kind !== "date" &&
declared.kind !== "generator" &&
declared.kind !== "void" &&
declared.kind !== "caught";
@@ -4114,7 +4119,7 @@ export function collectClassShapeInner(
// A subclass field created before anything can observe the instance
// keeps its type: super() cannot run code that reads it earlier.
const baseConstruction =
- base && ![...methods.keys()].some((m) => m.startsWith("get:") || m.startsWith("set:"))
+ base && !accessorInterceptsBase(accessorNames(methods.keys()), base)
? construction(base)
: null;
const sealedPrefix = baseConstruction
@@ -10418,8 +10423,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
if (arg.type.kind === "string") {
return { kind: "libCall", fn: "date.newString", args: [arg], type: DATE_T, loc };
}
+ // new Date(date): a new Date object with the same time value.
if (arg.type.kind === "date") {
- return arg;
+ const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [arg], type: F64, loc };
+ return { kind: "libCall", fn: "date.newMs", args: [time], type: DATE_T, loc };
}
lowerer.noLowering(
`new Date of '${lowerer.fmt(arg.type)}' values`,
@@ -10622,7 +10629,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
expr,
`Map values of type '${lowerer.checker.typeToString(targs[1])}' ` +
`(Map values must be number, string, boolean, records, class instances, functions, ` +
- `typed arrays, arrays, promises, Maps, Sets, or supported unions and checked values)`,
+ `typed arrays, arrays, promises, Dates, Maps, Sets, or supported unions and checked values)`,
);
}
lowerer.badType(expr, tsType);
@@ -10766,21 +10773,23 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
lowerer.badType(expr, tsType);
}
// `new AsyncLocalStorage()` (node:async_hooks): a fresh store id —
- // an f64 handle (type-mapper.ts), the Channel story. Construction options
- // ({ defaultValue, name }) have no lowering yet.
+ // an f64 handle (type-mapper.ts), the Channel story. The options form
+ // ({ defaultValue, name }) passes the options object as a dyn value.
if (
symbol !== null &&
ts.symbolName(symbol) === "AsyncLocalStorage" &&
lowerer.isStdlibSymbol(symbol)
) {
- if ((expr.arguments?.nodes.length ?? 0) > 0) {
- lowerer.noLowering(
- "new AsyncLocalStorage(options)",
- expr,
- "the zero-argument constructor is the supported form (defaultValue/name options have no lowering yet)",
- );
- }
- return { kind: "libCall", fn: "als.new", args: [], type: F64, loc };
+ const options = expr.arguments?.nodes[0];
+ if (options === undefined)
+ return { kind: "libCall", fn: "als.new", args: [], type: F64, loc };
+ return {
+ kind: "libCall",
+ fn: "als.newOptions",
+ args: [lowerAsyncLocalStorageOptions(lowerer, options)],
+ type: F64,
+ loc,
+ };
}
// `new Promise((resolve) => ...)`: the ambient Promise constructor.
if (symbol !== null && ts.symbolName(symbol) === "Promise" && lowerer.isStdlibSymbol(symbol)) {
diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts
index 2bde5f2aa..c1c5ef8c8 100644
--- a/packages/compiler/src/frontend/lowering/lower-exprs.ts
+++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts
@@ -1087,6 +1087,10 @@ export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr {
lowerer.isStdlibGlobal(e.expression, "Object")
)
return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) };
+ // `if (runtimeBuffer)` over an alias of the Buffer global (a
+ // capability probe): Node always defines Buffer.
+ if (lowerer.isStdlibGlobal(e, "Buffer"))
+ return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) };
if (ts.isPropertyAccessExpression(e)) {
const presence = lowerPromiseThenPresence(lowerer, e, {
kind: "boolLit",
diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts
index 80463fa6c..f837cc0af 100644
--- a/packages/compiler/src/frontend/lowering/lower-generators.ts
+++ b/packages/compiler/src/frontend/lowering/lower-generators.ts
@@ -333,7 +333,7 @@ export function stepDrivable(genT: GenType): boolean {
/** Whether a yield channel's values have an OUT-slot form `value` reads. */
function hasOutSlot(yt: IrType): boolean {
- return yt.kind === "f64" || yt.kind === "date" || yt.kind === "bool" || isRefCounted(yt);
+ return yt.kind === "f64" || yt.kind === "bool" || isRefCounted(yt);
}
/** `step` and `value` resumes of a generator-typed borrowed read. */
diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts
index e671a0a08..dc9d3def8 100644
--- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts
@@ -1945,6 +1945,19 @@ export function lowerStaticReadableStreamNew(
};
}
+/** `new TransformStream()`: the identity transform, a native handle whose
+ * readable side is a native ReadableStream and whose writable side is the
+ * runtime's WritableStream. A transformer or queuing strategies have no
+ * native lowering yet and keep the SC2020 refusal. */
+export function lowerStaticTransformStreamNew(
+ lowerer: Lowerer,
+ expr: ts.NewExpression,
+): IrExpr | null {
+ if (stdlibGlobalNameOf(lowerer, expr.expression) !== "TransformStream") return null;
+ if ((expr.arguments?.nodes.length ?? 0) > 0) return null;
+ return { kind: "libCall", fn: "fetch.transformNew", args: [], type: DYN, loc: locOf(expr) };
+}
+
/** Native Request and Headers values share the fetch runtime's handles. */
export function lowerFetchWebNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
const name = stdlibGlobalNameOf(lowerer, expr.expression);
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index 66ecea602..82c0dbb3e 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -29,6 +29,7 @@ import { lowerOptionalIndex } from "./undefined-index.js";
import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js";
import { requireInitFlagStmt } from "./require-initialization.js";
import { isJsonRequireDecl } from "./json-require.js";
+import { isTimeValueDateDeclaration } from "./date-values.js";
import { bindingSource } from "../binding-source.js";
import { lowerConstructorReturn } from "./constructor-returns.js";
import type { Lowerer } from "./lowerer.js";
@@ -64,6 +65,7 @@ import {
BOOL,
BYTES_U8,
CAUGHT,
+ DATE_T,
DYN,
F64,
type IrExpr,
@@ -105,6 +107,7 @@ import {
requireSpecOf,
} from "../program.js";
import { isStrictCode } from "./strict-mode.js";
+import { markSloppyRecordWrites } from "./frozen-records.js";
import {
COMPOUND_ASSIGN_OPS,
type CompoundOp,
@@ -5414,6 +5417,16 @@ export function lowerVarDecl(
if (alias) return alias;
}
+ // A Date local whose identity no use observes stores its time value.
+ if (isTimeValueDateDeclaration(lowerer, decl)) {
+ const loc = locOf(decl);
+ const date = lowerer.lowerExprExpecting(decl.initializer!, DATE_T);
+ const local = lowerer.declareLocal(decl.name, decl.name.text, F64, isLet);
+ lowerer.timeValueDateLocals.add(local);
+ const init: IrExpr = { kind: "libCall", fn: "date.getTime", args: [date], type: F64, loc };
+ return { kind: "varDecl", localId: local.id, init, loc };
+ }
+
// A NULLISH generic binding (`const i: I = null as any` — the
// declared type has no mapping, the value is provably null/undefined
// forever): no storage exists; reads know the value (member reads and
@@ -6884,7 +6897,7 @@ function lowerDynSwitch(
loc: locOf(expression),
};
}
- if (test.type.kind === "date" || !lowerer.dynConvertible(test.type)) {
+ if (!lowerer.dynConvertible(test.type)) {
lowerer.unsupported(
"SC1100",
expression,
@@ -7160,7 +7173,11 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
if (obj.type.kind === "record") {
const shape = lowerer.shapes.get(obj.type.shapeId);
if (shape?.indexValue && shape.fields.length === 0 && !shape.tuple) {
- return { kind: "recordKeyDelete", obj, shapeId: obj.type.shapeId, key: lowerKey(), loc };
+ return markSloppyRecordWrites(
+ lowerer,
+ { kind: "recordKeyDelete", obj, shapeId: obj.type.shapeId, key: lowerKey(), loc },
+ expr,
+ );
}
// `delete r.f` of a declared OPTIONAL field (undefined-armed slot)
// writes the field's ABSENT state: a monomorphic shape cannot remove
@@ -7181,14 +7198,19 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt
if (field) {
const absent = lowerer.absentFieldValue(field.type, loc);
if (absent) {
- return {
- kind: "recordSet",
- obj,
- shapeId: obj.type.shapeId,
- field: field.name,
- value: absent,
- loc,
- };
+ return markSloppyRecordWrites(
+ lowerer,
+ {
+ kind: "recordSet",
+ obj,
+ shapeId: obj.type.shapeId,
+ field: field.name,
+ value: absent,
+ loc,
+ },
+ expr,
+ true,
+ );
}
}
if (shape?.indexValue) {
@@ -8739,13 +8761,20 @@ function destrSpellingOf(rhs: ts.Expression): string | null {
return base === null ? null : `${base}.${e.name.text}`;
}
// `table[key]` and `list[0]`; V8 prints a numeric key in its canonical
- // number form (`o[1.50]` reads "o[1.5]").
+ // number form (`o[1.50]` reads "o[1.5]"), and a string key as a property
+ // name with its text decoded (`o["a.b"]` and `o[\`a.b\`]` read "o.a.b").
+ // The message is a C string, so a key holding NUL keeps the refusal.
if (ts.isElementAccessExpression(e) && !e.questionDotToken) {
const base = destrSpellingOf(e.expression);
const key = e.argumentExpression;
if (base === null) return null;
if (ts.isIdentifier(key)) return `${base}[${key.text}]`;
if (ts.isNumericLiteral(key)) return `${base}[${String(Number(key.text))}]`;
+ if (
+ (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) &&
+ !key.text.includes("\0")
+ )
+ return `${base}.${key.text}`;
return null;
}
if (ts.isCallExpression(e) && !e.questionDotToken) {
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index b59710605..672a995fc 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -31,6 +31,7 @@ import {
refuseUncheckedCycleReads,
type CycleInitFlag,
} from "./cycle-initialization.js";
+import { bindCompletionCallbacks } from "./completion-callbacks.js";
import { isJsonRequireDecl, type JsonRequireModule } from "./json-require.js";
import {
checkRequireBindingAccesses,
@@ -150,6 +151,7 @@ import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "./optio
import { type ProtocolDispatch } from "./class-protocol-dispatch.js";
import { ClassDynamicDispatch, trackClassFieldCreation } from "./class-dynamic-dispatch.js";
import { guardClassAttributeWrites } from "./class-attributes.js";
+import { guardFrozenWrites, markSloppyRecordWrites } from "./frozen-records.js";
import { applyHandleProperties } from "./handle-properties.js";
import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
@@ -558,6 +560,7 @@ import {
lowerStaticReadableStreamCancelCall,
lowerStaticReadableStreamControllerCall,
lowerStaticReadableStreamNew,
+ lowerStaticTransformStreamNew,
lowerStaticReadableStreamReaderCall,
lowerStaticResponseCall,
lowerStdlibMethodCall,
@@ -2337,6 +2340,10 @@ export class Lowerer {
readonly runtimeOptionalFields = new Set();
/** Bindings initialized by a Date's `toJSON()` (analyzeDateJsonResults). */
readonly dateJsonBindings = new Set();
+ /** isTimeValueDateDeclaration's answers, and the locals that hold a
+ * Date's time value instead of a Date object (date-values.ts). */
+ readonly timeValueDateDecisions = new Map();
+ readonly timeValueDateLocals = new Set();
/** Record bindings whose flow class reaches storage outside the class:
* they keep the declared layout, so an initializer from unchecked reads
* converts once instead of the binding adopting the widened variant. */
@@ -2935,6 +2942,10 @@ export class Lowerer {
* checks resolve through this map (stdlibGlobalNameOf), declarations
* emit nothing. */
readonly stdlibGlobalAliases = new Map();
+ /** The stdlibGlobalAliases bound through a cast of the global object
+ * (`(globalThis as { Buffer?: B }).Buffer`). Their static type is the
+ * cast's, so their value reads are refused (identifiers.ts). */
+ readonly castGlobalAliases = new Set();
/** CJS export-table ACCESSORS (`module.exports = { get path() {...} }`),
* lifted lazily as module-level functions and interned per accessor
* declaration: member reads call the getter (lower-exprs). */
@@ -3258,6 +3269,12 @@ export class Lowerer {
// Stack of function contexts (bottom = the function being declared at
// top level, top = the innermost nested function currently lowering).
fnStack: FnCtx[] = [];
+ /** Source ranges of record writes in sloppy-mode code, and of the
+ * absent-state writes of `delete r.f` (markSloppyRecordWrites). */
+ readonly sloppyRecordWrites = new Set();
+ readonly recordFieldDeletes = new Set();
+ /** typeKeys of the array types the program may freeze (IrModule.freezableArrays). */
+ readonly freezableArrays = new Set();
readonly liftedFns: IrFunction[] = [];
/** The first lifted function of each name (liftedFnNamed), for the
* liftedFns indexed so far. liftedFns only grows. */
@@ -4228,7 +4245,7 @@ export class Lowerer {
// exact mappings, while star exports resolve to the mapped declaration
// owner through externalTypeSpecifiersByFile.
const moduleSymbol = this.checker.getSymbolAtLocation(sf);
- const exported = moduleSymbol?.getExports().get(exportName as ts.__String);
+ const exported = moduleSymbol?.getExports().get(ts.escapedSymbolName(exportName));
return this.externalTypeSpecifierOfSymbol(exported, seenSymbols, seenExports);
}
@@ -4371,7 +4388,7 @@ export class Lowerer {
cjsModuleExportSymbol(sf: ts.SourceFile, name: string): ts.Symbol | undefined {
const moduleSym = this.checker.getSymbolAtLocation(sf);
const exports = moduleSym?.getExports();
- const direct = exports?.get(name as ts.__String);
+ const direct = exports?.get(ts.escapedSymbolName(name));
if (direct) return direct;
// tsgo represents `module.exports = { ... }` as one `export=` symbol
// and does not duplicate the object literal's properties into the
@@ -7715,6 +7732,7 @@ export class Lowerer {
finalizePrivateBrands(this, functions);
trackClassFieldCreation(this, functions);
guardClassAttributeWrites(this, functions);
+ guardFrozenWrites(this, functions);
applyHandleProperties(this, functions);
// Globals typed by a class that never REGISTERED (a JS class whose
@@ -7823,11 +7841,15 @@ export class Lowerer {
...(this.threadSlotKeys.size > 0
? { threadSlotKeys: [...this.threadSlotKeys].sort() }
: {}),
+ ...(this.freezableArrays.size > 0
+ ? { freezableArrays: [...this.freezableArrays].sort() }
+ : {}),
...(isNodeEsmFile(this.entry, this.program) ? {} : { commonJsEntry: true }),
...(this.ffiImports.length > 0 ? { ffiImports: [...this.ffiImports] } : {}),
};
if (module) sanitizeUnregisteredClassTypes(module, (name) => this.classes.has(name));
if (module) checkCycleBindingAccesses(this, module);
+ if (module) bindCompletionCallbacks(module);
// Execution tiers from the final bodies (ir/tiers.ts). Assigned with or
// without a module: coverage reports the dynamic tier of programs that
// still have blockers.
@@ -13004,7 +13026,7 @@ export class Lowerer {
}
lowerElementWrite(expr: ts.BinaryExpression): IrStmt {
- return lowerElementWrite(this, expr);
+ return markSloppyRecordWrites(this, lowerElementWrite(this, expr), expr);
}
lowerLiveWebValue(node: ts.Expression): IrExpr {
@@ -13401,7 +13423,7 @@ export class Lowerer {
}
fieldSetStmt(target: FieldTarget, value: IrExpr, loc: SrcLoc, blame: ts.Node): IrStmt {
- return fieldSetStmt(this, target, value, loc, blame);
+ return markSloppyRecordWrites(this, fieldSetStmt(this, target, value, loc, blame), blame);
}
lowerFieldCompound(
@@ -13441,6 +13463,7 @@ export class Lowerer {
lowerBlobNew(this, expr) ??
lowerNetAddressNew(this, expr) ??
lowerStaticReadableStreamNew(this, expr) ??
+ lowerStaticTransformStreamNew(this, expr) ??
lowerConsoleNew(this, expr);
return (
builtin ?? this.withRuntimeOptionalClassValue(expr.expression, () => lowerNew(this, expr))
diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts
index 44564f2f1..3cce40abd 100644
--- a/packages/compiler/src/frontend/lowering/surfaces.ts
+++ b/packages/compiler/src/frontend/lowering/surfaces.ts
@@ -6,6 +6,20 @@ function unparenthesized(e: ts.Expression): ts.Expression {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
}
+
+/** `e` without parentheses and type-only wrappers (as, satisfies, type
+ * assertions, non-null assertions), which change no runtime value. */
+function withoutTypeCasts(e: ts.Expression): ts.Expression {
+ while (
+ ts.isParenthesizedExpression(e) ||
+ ts.isAsExpression(e) ||
+ ts.isSatisfiesExpression(e) ||
+ ts.isTypeAssertion(e) ||
+ ts.isNonNullExpression(e)
+ )
+ e = e.expression;
+ return e;
+}
import { UNSUPPORTED } from "../../diagnostics/diagnostic.js";
import {
BOOL,
@@ -1563,6 +1577,7 @@ export const BUILTIN_MODULE_FNS: Record<
generateKeyPair: { fn: "crypto.generateKeyPairCb", params: [], result: VOID },
sign: { fn: "crypto.signOneshot", params: [], result: DYN },
verify: { fn: "crypto.verifyOneshot", params: [], result: DYN },
+ diffieHellman: { fn: "crypto.diffieHellman", params: [], result: DYN },
pbkdf2Sync: {
fn: "crypto.pbkdf2",
params: [BYTES_U8, BYTES_U8, F64, F64, STRING],
@@ -3101,9 +3116,6 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record<
"createSign, createVerify, createECDH, and createDiffieHellman",
]),
),
- diffieHellman:
- "crypto.diffieHellman takes DH, EC or X25519 KeyObjects and has no lowering; " +
- "the objects of createDiffieHellman, getDiffieHellman and createECDH compute shared secrets",
setFips:
"a compiled binary has no FIPS provider to enable, and Node itself throws on setFips(true) " +
"in a non-FIPS build — getFips() answers 0 here",
@@ -3440,6 +3452,17 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin
return ts.symbolName(resolved) === "global" ? "globalThis" : ts.symbolName(resolved);
}
if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) {
+ // A cast of the global object (`(globalThis as { Buffer?: B }).Buffer`,
+ // `(globalThis as any).Buffer`) changes only its static type: the
+ // member is the global object's own.
+ const uncast = withoutTypeCasts(expr.expression);
+ if (uncast !== unparenthesized(expr.expression)) {
+ if (!ts.isIdentifier(uncast) || stdlibGlobalNameOf(lowerer, uncast) !== "globalThis")
+ return null;
+ const member = lowerer.checker.getPropertyOfType(lowerer.typeOf(uncast), expr.name.text);
+ if (!member || !lowerer.isStdlibSymbol(member)) return null;
+ return ts.symbolName(member) === "global" ? "globalThis" : ts.symbolName(member);
+ }
if (stdlibGlobalNameOf(lowerer, expr.expression) !== "globalThis") return null;
// Through a detection chain the checker types the receiver as the
// union of every arm; the selected arm (the global object) owns the
@@ -3572,9 +3595,28 @@ export function stdlibGlobalAliasDecl(
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (!symbol) return false;
lowerer.stdlibGlobalAliases.set(symbol, name);
+ if (init !== undefined && castGlobalAliasSource(lowerer, init))
+ lowerer.castGlobalAliases.add(symbol);
return true;
}
+/** True when an alias initializer reads its global through a cast of the
+ * global object (`(globalThis as { Buffer?: B }).Buffer`), directly or as
+ * the left operand of `||` (stdlibGlobalAliasNameOf's guard), or copies an
+ * alias made that way. Such an alias carries the cast's static type. */
+function castGlobalAliasSource(lowerer: Lowerer, init: ts.Expression): boolean {
+ let source = unparenthesized(init);
+ if (ts.isBinaryExpression(source) && source.operatorToken.kind === ts.SyntaxKind.BarBarToken)
+ source = unparenthesized(source.left);
+ if (ts.isPropertyAccessExpression(source))
+ return withoutTypeCasts(source.expression) !== unparenthesized(source.expression);
+ if (ts.isIdentifier(source)) {
+ const symbol = lowerer.checker.getSymbolAtLocation(source);
+ return symbol !== undefined && lowerer.castGlobalAliases.has(symbol);
+ }
+ return false;
+}
+
/** True iff the symbol is declared ONLY by the adopted @types/node
* surface (no es-lib or shipped-ambient declaration merges in) — chooses
* the SC2020 fence's wording: "typed by @types/node", not "standard
diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts
index 6d8ffe4c0..e92251554 100644
--- a/packages/compiler/src/frontend/program.ts
+++ b/packages/compiler/src/frontend/program.ts
@@ -6437,7 +6437,7 @@ function analyzeEsmNamedImportLinks(
const nextSeen = new Set(seenModules);
nextSeen.add(dep);
const module = checker.getSymbolAtLocation(dep);
- const exported = module?.getExports().get(name as ts.__String);
+ const exported = module?.getExports().get(ts.escapedSymbolName(name));
if (exported !== undefined) {
let resolved = exported;
const seenSymbols = new Set();
diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts
new file mode 100644
index 000000000..f041a6cb5
--- /dev/null
+++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts
@@ -0,0 +1,221 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, afterEach, expect, test } from "vitest";
+import type { SourceFile } from "./ast-types.js";
+import {
+ reusedLibDiagnostics,
+ setLibDiagnosticsStore,
+ type LibDiagnosticsStore,
+} from "./lib-diagnostics.js";
+import type { Ts7DiagnosticData } from "./session-schema.generated.js";
+import type { Ts7CheckedFileDiagnosticData, Ts7SessionProgram } from "./session.js";
+import { closeSourceParser, parseSourceFile } from "./source-parser-node.js";
+
+const dir = mkdtempSync(join(tmpdir(), "scriptc-lib-diagnostics-"));
+const lib = join(dir, "lib.es5.d.ts");
+const fallback = join(dir, "scriptc-node-fallback.d.ts");
+const libText = "declare var NaN: number;\ninterface Array { length: number }\n";
+const fallbackText = "export {};\ndeclare global {\n var process: { argv: string[] };\n}\n";
+writeFileSync(lib, libText);
+writeFileSync(fallback, fallbackText);
+const main = join(dir, "main.ts");
+const util = join(dir, "\u{1F600}.ts");
+const other = join(dir, "�.ts");
+
+afterEach(() => setLibDiagnosticsStore(null));
+afterAll(() => {
+ closeSourceParser();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+function memoryStore(): LibDiagnosticsStore & { entries: Map } {
+ const entries = new Map();
+ return {
+ entries,
+ read: (key) => entries.get(key) ?? null,
+ write: (key, text) => void entries.set(key, text),
+ };
+}
+
+function diagnostic(fileName: string | undefined, pos: number, code = 2322): Ts7DiagnosticData {
+ return { fileName, pos, end: pos + 1, code, category: 1, text: `at ${pos}` };
+}
+
+/** A session program whose full-check and checked-file answers are given; it
+ * records which requests it was asked. */
+function sessionProgram(answers: {
+ semantic: Map;
+ global?: Ts7DiagnosticData[];
+ checked?: boolean;
+}): Ts7SessionProgram & { asked: string[] } {
+ const asked: string[] = [];
+ const all = [...answers.semantic.values()].flat();
+ return {
+ asked,
+ getCompilerOptions: () => ({ strict: true }),
+ diagnosticData: (method: string, file?: string) => {
+ asked.push(file === undefined ? method : `${method} ${file}`);
+ if (method === "getGlobalDiagnostics") return answers.global ?? [];
+ return file === undefined ? all : (answers.semantic.get(file) ?? []);
+ },
+ checkedFileDiagnosticData: (files: readonly string[]): Ts7CheckedFileDiagnosticData | null => {
+ asked.push(`checked ${files.join(",")}`);
+ if (answers.checked === false) return null;
+ return { files: files.map((file) => answers.semantic.get(file) ?? []), global: [] };
+ },
+ } as unknown as Ts7SessionProgram & { asked: string[] };
+}
+
+/** The program's files, parsed: the supplied two and the given others. */
+function files(texts: Record): (name: string) => SourceFile | undefined {
+ const parsed = new Map([
+ [lib, parseSourceFile(lib, libText, "ts")],
+ [fallback, parseSourceFile(fallback, fallbackText, "ts")],
+ ]);
+ for (const [name, text] of Object.entries(texts))
+ parsed.set(name, parseSourceFile(name, text, "ts"));
+ return (name) => parsed.get(name);
+}
+
+const names = [lib, fallback, main];
+const supplied = (name: string): boolean => name === lib || name === fallback;
+const moduleFile = files({ [main]: "export const n: number = 'x';\n" });
+
+test("without a store the full check answers", () => {
+ const program = sessionProgram({ semantic: new Map() });
+ expect(reusedLibDiagnostics(program, names, supplied, moduleFile)).toBeNull();
+ expect(program.asked).toEqual([]);
+});
+
+test("a stored answer stands in for checking the supplied files, merged by file name", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const semantic = new Map([
+ [lib, [diagnostic(lib, 1)]],
+ [fallback, []],
+ [main, [diagnostic(main, 5), diagnostic(main, 9)]],
+ ]);
+ const first = sessionProgram({ semantic });
+ const full = reusedLibDiagnostics(first, names, supplied, moduleFile);
+ // The first build runs the full check and stores the supplied files' answers.
+ expect(first.asked).toEqual([
+ "getGlobalDiagnostics",
+ "getSemanticDiagnostics",
+ "checked ",
+ `getSemanticDiagnostics ${lib}`,
+ `getSemanticDiagnostics ${fallback}`,
+ ]);
+ expect(store.entries.size).toBe(1);
+ const second = sessionProgram({ semantic });
+ const reused = reusedLibDiagnostics(second, names, supplied, moduleFile);
+ expect(second.asked).toEqual([`checked ${main}`]);
+ // The files' diagnostics in the order of their names (lib.es5.d.ts, main.ts), as the full answer.
+ expect(reused).toEqual({
+ semantic: [diagnostic(lib, 1), diagnostic(main, 5), diagnostic(main, 9)],
+ global: [],
+ });
+ expect(full?.semantic).toEqual(reused?.semantic);
+});
+
+test("files merge in code point order, which differs from UTF-16 order", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const all = [lib, fallback, util, other];
+ const sources = files({ [util]: "export {};\n", [other]: "export {};\n" });
+ const semantic = new Map([
+ [util, [diagnostic(util, 1)]],
+ [other, [diagnostic(other, 2)]],
+ ]);
+ reusedLibDiagnostics(sessionProgram({ semantic }), all, supplied, sources);
+ const reused = reusedLibDiagnostics(sessionProgram({ semantic }), all, supplied, sources);
+ // U+FFFD sorts before U+1F600 by code point (its surrogates sort after it by UTF-16 unit).
+ expect(reused?.semantic).toEqual([diagnostic(other, 2), diagnostic(util, 1)]);
+});
+
+test("a full check with global diagnostics is not stored", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const program = sessionProgram({
+ semantic: new Map([[main, [diagnostic(main, 1)]]]),
+ global: [diagnostic(undefined, 0, 2318)],
+ });
+ expect(reusedLibDiagnostics(program, names, supplied, moduleFile)?.global).toEqual([
+ diagnostic(undefined, 0, 2318),
+ ]);
+ expect(store.entries.size).toBe(0);
+});
+
+test("scripts reuse the answer unless they declare a global the supplied files declare", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const semantic = new Map([[main, []]]);
+ // A script declaring only new names: it stores the answer, and a later one reuses it.
+ const fresh = files({
+ [main]: "const total = 1;\nfunction run() {}\nif (total) { var later = 2; }\n",
+ });
+ reusedLibDiagnostics(sessionProgram({ semantic }), names, supplied, fresh);
+ expect(store.entries.size).toBe(1);
+ const again = sessionProgram({ semantic });
+ expect(reusedLibDiagnostics(again, names, supplied, fresh)).not.toBeNull();
+ expect(again.asked).toEqual([`checked ${main}`]);
+ // Scripts declaring a supplied global, at the top level, in a block, in a
+ // `declare global` of a supplied module, or an ambient module: the full check.
+ for (const text of [
+ "interface Array { extra: T }\n",
+ "if (true) { var NaN = 1; }\n",
+ "declare var process: unknown;\n",
+ 'declare module "fs" {}\n',
+ ]) {
+ const program = sessionProgram({ semantic });
+ const result = reusedLibDiagnostics(program, names, supplied, files({ [main]: text }));
+ expect(program.asked[0], text).not.toBe(`checked ${main}`);
+ if (result !== null) expect(program.asked[0]).toBe("getGlobalDiagnostics");
+ }
+ expect(store.entries.size).toBe(1);
+});
+
+test("a program whose script declares a supplied global does not store its answer", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const program = sessionProgram({ semantic: new Map([[main, []]]) });
+ reusedLibDiagnostics(program, names, supplied, files({ [main]: "declare var NaN: number;\n" }));
+ expect(store.entries.size).toBe(0);
+});
+
+test("modules that can declare something global, and servers without the method, ask as before", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const semantic = new Map([[main, []]]);
+ reusedLibDiagnostics(sessionProgram({ semantic }), names, supplied, moduleFile);
+ expect(store.entries.size).toBe(1);
+ for (const text of [
+ "export {};\ndeclare global { var extra: number }\n",
+ 'export {};\ndeclare module "./other" { const extra: number }\n',
+ "export as namespace Lib;\nexport const n = 1;\n",
+ ]) {
+ const program = sessionProgram({ semantic });
+ expect(reusedLibDiagnostics(program, names, supplied, files({ [main]: text }))).toBeNull();
+ expect(program.asked).toEqual([]);
+ }
+ const old = sessionProgram({ semantic, checked: false });
+ expect(reusedLibDiagnostics(old, names, supplied, moduleFile)).toBeNull();
+ // Nor does a server without the method store an answer it could not use.
+ const fresh = memoryStore();
+ setLibDiagnosticsStore(fresh, "checker");
+ reusedLibDiagnostics(sessionProgram({ semantic, checked: false }), names, supplied, moduleFile);
+ expect(fresh.entries.size).toBe(0);
+});
+
+test("a supplied file that changes is a different entry", () => {
+ const store = memoryStore();
+ setLibDiagnosticsStore(store, "checker");
+ const semantic = new Map([[main, []]]);
+ reusedLibDiagnostics(sessionProgram({ semantic }), names, supplied, moduleFile);
+ writeFileSync(fallback, fallbackText + "// edited\n");
+ const program = sessionProgram({ semantic });
+ reusedLibDiagnostics(program, names, supplied, moduleFile);
+ expect(program.asked[0]).toBe("getGlobalDiagnostics");
+ expect(store.entries.size).toBe(2);
+ writeFileSync(fallback, fallbackText);
+});
diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts
new file mode 100644
index 000000000..827b337bd
--- /dev/null
+++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts
@@ -0,0 +1,324 @@
+/* The semantic diagnostics of the declaration files scriptc supplies (the
+ * TypeScript libs and scriptc's own declaration files), reused across
+ * builds. Checking them is most of the full check of a small program. When
+ * no other file of a program can change what checking those files reports
+ * (every one is a module without global or module augmentations, or a
+ * script declaring no global name the supplied files declare), the answer
+ * depends only on the checker, the compiler options and the files
+ * themselves, so a stored answer for the same three stands in for checking
+ * them again: the server checks only the program's other files
+ * (getDiagnosticsOfCheckedFiles), and the semantic diagnostics merge in the
+ * order the full check sorts them, by file name. An answer is stored only
+ * when the full check reports no global diagnostics: the global diagnostics
+ * of checking the other files are then the full check's. */
+import { createHash } from "node:crypto";
+import { statSync } from "node:fs";
+import type { Node, SourceFile } from "./ast-types.js";
+import type { Ts7DiagnosticData } from "./session-schema.generated.js";
+import type { Ts7SessionProgram } from "./session.js";
+import { NodeFlags, SyntaxKind } from "./enums.js";
+import { walkPreorder } from "./ast.js";
+
+/** Key-value storage for reused diagnostics (best effort: failures miss). */
+export interface LibDiagnosticsStore {
+ read(key: string): string | null;
+ write(key: string, text: string): void;
+}
+
+const SCHEMA = "scriptc.lib-diagnostics.v1";
+
+let configured: { store: LibDiagnosticsStore; checker: string } | null = null;
+
+/** Has later builds of this process reuse the diagnostics of the supplied
+ * declaration files through `store`, for the checker identified by
+ * `checker` (its executable's identity), or stop reusing them (null). */
+export function setLibDiagnosticsStore(store: LibDiagnosticsStore | null, checker = ""): void {
+ configured = store === null ? null : { store, checker };
+}
+
+/** A checker executable's identity for the stored diagnostics' key: its
+ * path and file identity, or the path alone when it cannot be read. */
+export function checkerExecutableIdentity(path: string): string {
+ try {
+ const info = statSync(path);
+ return JSON.stringify([path, info.size, info.mtimeMs, info.ino]);
+ } catch {
+ return JSON.stringify([path]);
+ }
+}
+
+/** The program's semantic and global diagnostics as the full check reports
+ * them, using stored diagnostics for the supplied declaration files; null
+ * when they cannot be reused for this program (then the caller asks for
+ * them as before). `supplied` tells whether a program file is one of the
+ * supplied declaration files. */
+export function reusedLibDiagnostics(
+ program: Ts7SessionProgram,
+ fileNames: readonly string[],
+ supplied: (fileName: string) => boolean,
+ sourceFile: (fileName: string) => SourceFile | undefined,
+): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null {
+ if (configured === null || process.env["SCRIPTC_LIB_DIAGNOSTICS"] === "0") return null;
+ const { store, checker } = configured;
+ const libs: string[] = [];
+ const own: string[] = [];
+ for (const name of fileNames) (supplied(name) ? libs : own).push(name);
+ if (libs.length === 0) return null;
+ // The global names each of the program's scripts declares; a module that
+ // declares nothing global contributes none.
+ const scripts: Set[] = [];
+ for (const name of own) {
+ const file = sourceFile(name);
+ if (file === undefined) return null;
+ if (file.externalModuleIndicator !== undefined) {
+ if (!declaresNothingGlobal(file)) return null;
+ continue;
+ }
+ const names = isJsFileName(name) ? null : scriptGlobalNames(file);
+ if (names === null) return null;
+ scripts.push(names);
+ }
+ // A script's globals cannot change what the supplied files report unless
+ // one of them is also a global the supplied files declare.
+ const collides = (globals: ReadonlySet): boolean =>
+ scripts.some((names) => [...names].some((name) => globals.has(name)));
+ // The options without the configuration file's path: it names where the
+ // project is, which checking the supplied files does not read (the files a
+ // project's location brings in are part of the key).
+ const options = { ...program.getCompilerOptions(), configFilePath: undefined };
+ const key = entryKey(checker, options, libs);
+ if (key === null) return null;
+ const stored = readEntry(store, key, libs);
+ if (stored !== null && !collides(stored.globals)) {
+ const checked = program.checkedFileDiagnosticData(own);
+ if (checked === null) return null;
+ const byFile = new Map(stored.files);
+ own.forEach((name, index) => byFile.set(name, checked.files[index] ?? []));
+ return { semantic: inFileOrder(byFile), global: checked.global };
+ }
+ // The full check, as before. Its answers for the supplied files are stored
+ // when it reports no global diagnostics and the program's scripts declare
+ // none of the supplied files' globals (so nothing of the program reached
+ // what those files report).
+ const global = program.diagnosticData("getGlobalDiagnostics");
+ const semantic = program.diagnosticData("getSemanticDiagnostics");
+ // A server that cannot check only some files could never use the answer.
+ if (global.length === 0 && stored === null && program.checkedFileDiagnosticData([]) !== null) {
+ const globals = suppliedGlobalNames(libs, sourceFile);
+ if (globals !== null && !collides(globals)) {
+ const files: Record = {};
+ for (const name of libs) files[name] = program.diagnosticData("getSemanticDiagnostics", name);
+ try {
+ store.write(key, JSON.stringify({ schema: SCHEMA, globals: [...globals], files }));
+ } catch {
+ // Best effort: a failed write is a later miss.
+ }
+ }
+ }
+ return { semantic, global };
+}
+
+/** Whether a file cannot add to or change the global declarations the
+ * supplied files declare: a module with no `declare global`, no module
+ * augmentation and no UMD `export as namespace` declaration. */
+function declaresNothingGlobal(file: SourceFile): boolean {
+ if (file.externalModuleIndicator === undefined) return false;
+ if (file.moduleAugmentations.length > 0) return false;
+ for (const statement of file.statements.nodes) {
+ if (statement.kind === SyntaxKind.NamespaceExportDeclaration) return false;
+ // `declare global` (a module declaration named by the identifier `global`).
+ if (
+ statement.kind === SyntaxKind.ModuleDeclaration &&
+ statement.name?.kind === SyntaxKind.Identifier &&
+ statement.name.text === "global"
+ )
+ return false;
+ }
+ return true;
+}
+
+function isJsFileName(name: string): boolean {
+ return /\.[cm]?jsx?$/.test(name);
+}
+
+/** The names a script file declares in the global scope: its top-level
+ * declarations, and the `var` and function declarations of its top-level
+ * blocks; null when it declares an ambient module (`declare module "x"`),
+ * which can merge with one the supplied files declare. Over-approximates:
+ * a name more only makes the stored answer apply less often. */
+function scriptGlobalNames(file: SourceFile): Set | null {
+ const names = new Set();
+ let ambientModule = false;
+ walkPreorder(file, (node) => {
+ if (node === file) return undefined;
+ if (node.kind === SyntaxKind.ModuleDeclaration && node.name?.kind === SyntaxKind.StringLiteral)
+ ambientModule = true;
+ const topLevel = node.parent === file;
+ if (topLevel || node.kind === SyntaxKind.FunctionDeclaration) addDeclaredNames(node, names);
+ else if (
+ node.kind === SyntaxKind.VariableDeclarationList &&
+ (node.flags & NodeFlags.BlockScoped) === 0
+ )
+ for (const declaration of node.declarations?.nodes ?? [])
+ addBindingNames(declaration.name, names);
+ // Functions, classes and namespaces have scopes of their own.
+ return GLOBAL_SCOPE_BOUNDARIES.has(node.kind) ? "skip" : undefined;
+ });
+ return ambientModule ? null : names;
+}
+
+/** The global names the supplied files declare: the declarations of their
+ * scripts (as scriptGlobalNames finds them, with ambient modules as their
+ * quoted names) and those of the `declare global` blocks of their modules;
+ * null when one of them is not available. */
+function suppliedGlobalNames(
+ libs: readonly string[],
+ sourceFile: (fileName: string) => SourceFile | undefined,
+): Set | null {
+ const names = new Set();
+ for (const name of libs) {
+ const file = sourceFile(name);
+ if (file === undefined) return null;
+ if (file.externalModuleIndicator === undefined) {
+ walkPreorder(file, (node) => {
+ if (node === file) return undefined;
+ if (
+ node.kind === SyntaxKind.ModuleDeclaration &&
+ node.name?.kind === SyntaxKind.StringLiteral
+ )
+ names.add(JSON.stringify(node.name.text ?? ""));
+ if (node.parent === file || node.kind === SyntaxKind.FunctionDeclaration)
+ addDeclaredNames(node, names);
+ else if (
+ node.kind === SyntaxKind.VariableDeclarationList &&
+ (node.flags & NodeFlags.BlockScoped) === 0
+ )
+ for (const declaration of node.declarations?.nodes ?? [])
+ addBindingNames(declaration.name, names);
+ return GLOBAL_SCOPE_BOUNDARIES.has(node.kind) ? "skip" : undefined;
+ });
+ continue;
+ }
+ for (const statement of file.statements.nodes) {
+ if (
+ statement.kind !== SyntaxKind.ModuleDeclaration ||
+ statement.name?.kind !== SyntaxKind.Identifier ||
+ statement.name.text !== "global"
+ )
+ continue;
+ for (const inner of statement.body?.statements?.nodes ?? []) addDeclaredNames(inner, names);
+ }
+ }
+ return names;
+}
+
+const GLOBAL_SCOPE_BOUNDARIES = new Set([
+ SyntaxKind.FunctionDeclaration,
+ SyntaxKind.FunctionExpression,
+ SyntaxKind.ArrowFunction,
+ SyntaxKind.MethodDeclaration,
+ SyntaxKind.Constructor,
+ SyntaxKind.GetAccessor,
+ SyntaxKind.SetAccessor,
+ SyntaxKind.ClassDeclaration,
+ SyntaxKind.ClassExpression,
+ SyntaxKind.ModuleDeclaration,
+]);
+
+/** The names a statement declares in the scope it is in. */
+function addDeclaredNames(node: Node, names: Set): void {
+ switch (node.kind) {
+ case SyntaxKind.VariableStatement:
+ for (const declaration of node.declarationList?.declarations?.nodes ?? [])
+ addBindingNames(declaration.name, names);
+ return;
+ case SyntaxKind.FunctionDeclaration:
+ case SyntaxKind.ClassDeclaration:
+ case SyntaxKind.InterfaceDeclaration:
+ case SyntaxKind.TypeAliasDeclaration:
+ case SyntaxKind.EnumDeclaration:
+ case SyntaxKind.ModuleDeclaration:
+ case SyntaxKind.ImportEqualsDeclaration:
+ if (node.name?.kind === SyntaxKind.Identifier) names.add(node.name.text ?? "");
+ return;
+ }
+}
+
+function addBindingNames(name: Node | undefined, names: Set): void {
+ if (name === undefined) return;
+ if (name.kind === SyntaxKind.Identifier) {
+ names.add(name.text ?? "");
+ return;
+ }
+ // Object and array binding patterns.
+ for (const element of name.elements?.nodes ?? []) addBindingNames(element.name, names);
+}
+
+/** The stored diagnostics' key: the checker, the compiler options and the
+ * supplied files in program order with each one's file identity; null when
+ * one of them cannot be read. */
+function entryKey(checker: string, options: unknown, libs: readonly string[]): string | null {
+ const files: unknown[] = [];
+ for (const name of libs) {
+ try {
+ const info = statSync(name);
+ files.push([name, info.size, info.mtimeMs, info.ino]);
+ } catch {
+ return null;
+ }
+ }
+ return createHash("sha256")
+ .update(JSON.stringify({ schema: SCHEMA, checker, options, files }))
+ .digest("hex");
+}
+
+function readEntry(
+ store: LibDiagnosticsStore,
+ key: string,
+ libs: readonly string[],
+): { files: Map; globals: Set } | null {
+ let text: string | null;
+ try {
+ text = store.read(key);
+ } catch {
+ return null;
+ }
+ if (text === null) return null;
+ let entry: { schema?: unknown; globals?: unknown; files?: Record };
+ try {
+ entry = JSON.parse(text) as typeof entry;
+ } catch {
+ return null;
+ }
+ if (entry.schema !== SCHEMA || entry.files === undefined || !Array.isArray(entry.globals))
+ return null;
+ const files = new Map();
+ for (const name of libs) {
+ const list = entry.files[name];
+ if (!Array.isArray(list)) return null;
+ files.set(name, list);
+ }
+ return { files, globals: new Set(entry.globals.filter((name) => typeof name === "string")) };
+}
+
+/** Each file's diagnostics (already in the checker's order) in the order the
+ * checker sorts files: by file name, compared by code point. */
+function inFileOrder(byFile: ReadonlyMap): Ts7DiagnosticData[] {
+ const names = [...byFile.keys()].sort(compareCodePoints);
+ const out: Ts7DiagnosticData[] = [];
+ for (const name of names) for (const d of byFile.get(name)!) out.push(d);
+ return out;
+}
+
+function compareCodePoints(a: string, b: string): number {
+ let i = 0;
+ let j = 0;
+ while (i < a.length && j < b.length) {
+ const x = a.codePointAt(i)!;
+ const y = b.codePointAt(j)!;
+ if (x !== y) return x < y ? -1 : 1;
+ i += x > 0xffff ? 2 : 1;
+ j += y > 0xffff ? 2 : 1;
+ }
+ return i < a.length ? 1 : j < b.length ? -1 : 0;
+}
diff --git a/packages/compiler/src/frontend/ts7/program-host.ts b/packages/compiler/src/frontend/ts7/program-host.ts
index 639086e94..ec143e401 100644
--- a/packages/compiler/src/frontend/ts7/program-host.ts
+++ b/packages/compiler/src/frontend/ts7/program-host.ts
@@ -46,6 +46,10 @@ import {
trackedRealpath,
} from "../input-tracker.js";
import { packageShimDeclarationText } from "../package-shims.js";
+import { ambientDtsPath, fallbackDtsPath, overridesDtsPath } from "../dts-paths.js";
+import { reusedLibDiagnostics } from "./lib-diagnostics.js";
+import { ts7Diagnostic } from "./diagnostic.js";
+import type { Ts7DiagnosticData } from "./session-schema.generated.js";
/** The compiler options our createProgram accepts: TS7's CompilerOptions
* shape (numeric enums for target/module/moduleResolution — the enums module
@@ -393,6 +397,22 @@ export class Ts7Program {
return this.project.program.getSourceFileMetadata(resolve(fileName));
}
+ /** The semantic and global diagnostics with the stored diagnostics of the
+ * declaration files scriptc supplies (lib-diagnostics.ts), or null. */
+ reusedLibDiagnostics(): { semantic: Ts7DiagnosticData[]; global: Ts7DiagnosticData[] } | null {
+ this.snapshot.ensureActive();
+ const program = this.project.program;
+ const declarations = new Set([ambientDtsPath(), overridesDtsPath(), fallbackDtsPath()]);
+ return reusedLibDiagnostics(
+ program,
+ this.getSourceFileNames(),
+ (name) =>
+ declarations.has(tsgoPath(name)) ||
+ program.getSourceFileMetadataByPath(name)?.isDefaultLibrary === true,
+ (name) => this.getSourceFile(name),
+ );
+ }
+
isSourceFileDefaultLibrary(file: SourceFile): boolean {
return this.project.program.isSourceFileDefaultLibrary(file);
}
@@ -423,9 +443,20 @@ export class Ts7Program {
* package dropped the aggregate). Order mirrors 5.9.3: config, options/
* program, global, syntactic, semantic. */
export function getPreEmitDiagnostics(program: Ts7Program): readonly Diagnostic[] {
+ const config = program.getConfigFileParsingDiagnostics();
+ const options = program.getProgramDiagnostics();
+ const reused = program.reusedLibDiagnostics();
+ if (reused !== null)
+ return [
+ ...config,
+ ...options,
+ ...reused.global.map(ts7Diagnostic),
+ ...program.getSyntacticDiagnostics(),
+ ...reused.semantic.map(ts7Diagnostic),
+ ];
return [
- ...program.getConfigFileParsingDiagnostics(),
- ...program.getProgramDiagnostics(),
+ ...config,
+ ...options,
...program.getGlobalDiagnostics(),
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
diff --git a/packages/compiler/src/frontend/ts7/semantic-model.test.ts b/packages/compiler/src/frontend/ts7/semantic-model.test.ts
index 9d0f61a4e..bbcd53d8b 100644
--- a/packages/compiler/src/frontend/ts7/semantic-model.test.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-model.test.ts
@@ -28,6 +28,7 @@ import {
SemanticSnapshot,
SemanticType,
symbolNameOf,
+ escapedSymbolName,
} from "./semantic-model.js";
import type { __String } from "./ast-types.js";
import {
@@ -313,6 +314,11 @@ test("symbol tables keep escaped keys, display names, canonical context and cach
const exports = parent.getExports();
expect(symbolNameOf(members.get("___x" as __String))).toBe("__x");
expect(symbolNameOf(exports.get("__x" as __String))).toBe("__x");
+ // A name that starts with `__` is keyed with an extra `_`.
+ expect(symbolNameOf(members.get(escapedSymbolName("__x")))).toBe("__x");
+ expect(escapedSymbolName("x")).toBe("x");
+ expect(escapedSymbolName("_x")).toBe("_x");
+ expect(escapedSymbolName("___y")).toBe("____y");
expect(parent.getMembers()).toBe(members);
expect(parent.getExports()).toBe(exports);
expect(parent.getParent()).toBe(first.symbol(symbol(2)));
diff --git a/packages/compiler/src/frontend/ts7/semantic-model.ts b/packages/compiler/src/frontend/ts7/semantic-model.ts
index d3d5fe61a..405f4775b 100644
--- a/packages/compiler/src/frontend/ts7/semantic-model.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-model.ts
@@ -454,6 +454,12 @@ export function symbolName(symbol: SemanticSymbol): string {
return name.startsWith("___") ? name.slice(1) : name;
}
+/** The key of the name `name` in a symbol table (`getExports()`, `getMembers()`): the name, with
+ * an extra `_` when it starts with `__`. The inverse of `symbolName`. */
+export function escapedSymbolName(name: string): __String {
+ return (name.startsWith("__") ? `_${name}` : name) as __String;
+}
+
/** `symbolName(symbol)`, or undefined without a symbol. */
export function symbolNameOf(symbol: SemanticSymbol | null | undefined): string | undefined {
return symbol === undefined || symbol === null ? undefined : symbolName(symbol);
diff --git a/packages/compiler/src/frontend/ts7/semantic-types.ts b/packages/compiler/src/frontend/ts7/semantic-types.ts
index ea43e3885..5a597ae4e 100644
--- a/packages/compiler/src/frontend/ts7/semantic-types.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-types.ts
@@ -30,7 +30,7 @@ export type {
SemanticSignature as Signature,
SemanticSymbol as Symbol,
} from "./semantic-model.js";
-export { symbolName, symbolNameOf } from "./semantic-model.js";
+export { escapedSymbolName, symbolName, symbolNameOf } from "./semantic-model.js";
export type {
SemanticIndexInfo as IndexInfo,
SemanticTypePredicate as TypePredicate,
diff --git a/packages/compiler/src/frontend/ts7/session.ts b/packages/compiler/src/frontend/ts7/session.ts
index d3f2ae9fe..082bb79a6 100644
--- a/packages/compiler/src/frontend/ts7/session.ts
+++ b/packages/compiler/src/frontend/ts7/session.ts
@@ -55,6 +55,13 @@ interface ProgramQuery {
snapshot?: number;
project?: string;
file?: SemanticDocument;
+ files?: string[];
+}
+
+/** The answer of getDiagnosticsOfCheckedFiles, as the server sends it. */
+export interface Ts7CheckedFileDiagnosticData {
+ files: Ts7DiagnosticData[][];
+ global: Ts7DiagnosticData[];
}
/** Platform-independent session over an already connected transport. The
@@ -344,6 +351,29 @@ export class Ts7SessionProgram {
result.push(ts7Diagnostic(data));
return result;
}
+ /** The diagnostics of `method` for `file`, or for the whole program, as
+ * the server sends them. */
+ diagnosticData(method: string, file?: SemanticDocument): Ts7DiagnosticData[] {
+ return this.session.request(method, this.query(file)) ?? [];
+ }
+ /** The semantic diagnostics of each of `files` and the global diagnostics,
+ * after the server checks only those files (getDiagnosticsOfCheckedFiles),
+ * or null when the server does not answer that method. */
+ checkedFileDiagnosticData(files: readonly string[]): Ts7CheckedFileDiagnosticData | null {
+ try {
+ return this.session.request("getDiagnosticsOfCheckedFiles", {
+ ...this.query(),
+ files: [...files],
+ });
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes('unknown API method "getDiagnosticsOfCheckedFiles"')
+ )
+ return null;
+ throw error;
+ }
+ }
getSyntacticDiagnostics(file?: SemanticDocument): Ts7Diagnostic[] {
return this.diagnostics("getSyntacticDiagnostics", file);
}
diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts
index f1e86067e..fc159116f 100644
--- a/packages/compiler/src/frontend/type-mapper.ts
+++ b/packages/compiler/src/frontend/type-mapper.ts
@@ -183,6 +183,7 @@ export const CRYPTO_KEY_OPTION_DYN_TYPES: ReadonlySet = new Set([
"PrivateKeyInput",
"PublicKeyInput",
"RSAKeyPairKeyObjectOptions",
+ "X25519KeyPairOptions",
"RSAKeyPairOptions",
"SignKeyObjectInput",
"SignPrivateKeyInput",
@@ -213,6 +214,9 @@ export const FETCH_AMBIENT_TYPES = [
"ReadableStreamReadResult",
"ReadableStreamReadValueResult",
"ReadableStreamReadDoneResult",
+ "TransformStream",
+ "WritableStream",
+ "WritableStreamDefaultWriter",
"Blob",
"File",
"BlobPropertyBag",
@@ -1390,9 +1394,17 @@ function genChannels(
if (!nextT || nextT.kind === "void" || nextT.kind === "nullT") return null;
const ownChannel = (t: IrType): boolean =>
t.kind === "f64" || t.kind === "bool" || t.kind === "string";
+ // A record yield channel keeps its type beside a dyn return (an
+ // `IterableIterator`). A record return channel beside a dyn yield
+ // does not: the value a delegating `yield*` answers (Effect.gen's
+ // generators) is often an object whose methods a record would drop.
const dynMix =
(yieldT.kind === "dyn" && retT.kind !== "dyn" && retT.kind !== "void" && !ownChannel(retT)) ||
- (retT.kind === "dyn" && yieldT.kind !== "dyn" && yieldT.kind !== "void" && !ownChannel(yieldT));
+ (retT.kind === "dyn" &&
+ yieldT.kind !== "dyn" &&
+ yieldT.kind !== "void" &&
+ !ownChannel(yieldT) &&
+ yieldT.kind !== "record");
if (dynMix) return { yieldT: DYN, retT: DYN, nextT };
return { yieldT, retT, nextT };
}
@@ -3109,7 +3121,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
psym &&
(FETCH_AMBIENT_TYPES as readonly string[]).includes(ts.symbolName(psym)) &&
!(
- ts.symbolName(psym) === "ReadableStream" &&
+ (ts.symbolName(psym) === "ReadableStream" || ts.symbolName(psym) === "WritableStream") &&
psym.declarations.some(
(d) => ts.isInterfaceDeclaration(d) && isDeclaredInAmbientNamespace(d, "NodeJS"),
)
@@ -3219,10 +3231,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
}
const collectionView = mapCollectionView(widened, ctx);
if (collectionView) return collectionView;
- // Date: a TimeClip'd epoch-millisecond scalar in the static runtime.
- // This supports stored/passed values and the read-only getter slice;
- // identity and mutation stay fenced because the scalar deliberately
- // carries no object identity.
+ // Date: a refcounted object holding its TimeClip'd time value (ScrDate),
+ // shared with the checked-dynamic Date handle, so it keeps its identity
+ // through every slot and through `unknown`. Setters stay fenced.
if (isStdlibInterface("Date")) return DATE_T;
// RegExp: a reference to the lib RegExp interface — provenance, not the
// name (a user's own `interface RegExp` maps as a record). Regex values
@@ -3955,8 +3966,10 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// Mixed dyn/concrete value channels share checked storage, so either
// branch of the result record has the same representation. A number,
// boolean or string channel keeps its own type beside a dyn one (the
- // `Generator` defaults): its values ride OUT typed and box only
- // when the result record is built.
+ // `Generator` default), and so does a record yield channel beside
+ // a dyn return (`IterableIterator`): its values ride OUT typed and box
+ // only when the result record is built, so for-of and spread read them
+ // back unchecked.
if (
isStdlibInterface("Generator") ||
isStdlibInterface("AsyncGenerator") ||
@@ -4755,6 +4768,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (hybrid) return hybrid;
}
const callSigs = checker.getCallSignatures(widened);
+ // AsyncLocalStorage.snapshot()'s generic runner is a dyn function value
+ // (als.snapshot), called through the dyn call path.
+ if (callSigs.length === 1 && isAsyncLocalStorageSnapshot(callSigs[0]!, ctx)) return DYN;
if (callSigs.length === 1) return mapCallSignature(callSigs[0]!, ctx);
// Records: object types whose members are all data properties (shorthand
// methods in type position count — they're func-typed fields) with
@@ -5051,7 +5067,6 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
(a) =>
a.kind === "void" ||
a.kind === "union" ||
- a.kind === "date" ||
a.kind === "dyn" ||
// A generator arm only comes from an Iterable (an optional
// one keeps its unit arms beside the iterable union's).
@@ -5087,6 +5102,26 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
/** One non-overloaded call signature's func type (mapTypeInner's
* single-signature rule; erasedOverloadMemberType maps each overload of a
* member through it). */
+/** Whether `sig` is the function type AsyncLocalStorage.snapshot() returns:
+ * declared as that static method's return type in the async_hooks module
+ * of the standard library declarations. */
+function isAsyncLocalStorageSnapshot(sig: ts.Signature, ctx: TypeMapperCtx): boolean {
+ const decl = sig.declaration;
+ if (decl === undefined || !ts.isFunctionTypeNode(decl)) return false;
+ const method = decl.parent;
+ return (
+ method !== undefined &&
+ ts.isMethodDeclaration(method) &&
+ ts.isIdentifier(method.name) &&
+ method.name.text === "snapshot" &&
+ method.parent !== undefined &&
+ ts.isClassDeclaration(method.parent) &&
+ method.parent.name?.text === "AsyncLocalStorage" &&
+ ctx.isStdlibFile(ts.getSourceFileOfNode(decl)) &&
+ isDeclaredInAmbientModule(decl, "async_hooks")
+ );
+}
+
function mapCallSignature(sig: ts.Signature, ctx: TypeMapperCtx): IrType | null {
const { checker } = ctx;
if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null;
@@ -6584,7 +6619,7 @@ export function genResultRecord(
for (const a of unions.get(t.unionId)?.arms ?? []) byKey.set(typeKey(a), a);
return true;
}
- if (t.kind === "regex" || t.kind === "date" || t.kind === "generator") {
+ if (t.kind === "regex" || t.kind === "generator") {
return false; // no legal union arm exists for these kinds
}
byKey.set(typeKey(t), t);
@@ -6673,15 +6708,15 @@ export function withUnitArm(
if (t.kind === "union") {
const def = unions.get(t.unionId);
if (!def) return null;
- if (def.arms.some((a) => a.kind === "date")) return null;
if (def.arms.some((a) => a.kind === kind)) return t;
const arms = [...def.arms, unit];
+ // Date and Promise arms beside other data arms have no union home.
+ if (!unionContainerArmsOk(arms)) return null;
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
return { kind: "union", unionId: unions.transform(def, arms) };
}
if (
t.kind === "void" ||
- t.kind === "date" ||
t.kind === "dyn" ||
// A bare unit field type cannot occur (units live only inside unions),
// but guard against constructing a single-arm union from one.
@@ -8271,7 +8306,6 @@ function armHasUnionHome(arm: IrType): boolean {
case "void":
case "union":
case "regex":
- case "date":
case "generator":
case "dyn":
return false;
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 4f7b93cfd..58dc39c7b 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -9,6 +9,7 @@ import {
} from "./library/prepare.js";
import { analyzeWithFrontend } from "./frontend/analysis.js";
import { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js";
+import { setLibDiagnosticsStore } from "./frontend/ts7/lib-diagnostics.js";
import { analyzeWithVerdictCache, fileVerdictStore } from "./coverage/verdict-cache.js";
import { coverageCompilerIdentity } from "./coverage/verdict-startup.js";
import { commentOnlyReplay } from "./coverage/verdict-semantic.js";
@@ -450,6 +451,7 @@ export async function compile(
);
return withCompileWarnings(result, warnings);
} finally {
+ setLibDiagnosticsStore(null);
frontendProcess?.release();
}
}
@@ -623,6 +625,7 @@ async function compileExecutableNative(
dgram: features.dgram,
dnsPromises: features.dnsPromises,
dgramDyn: features.dgramDyn,
+ textCodecDyn: features.textCodecDyn,
watch: features.watch,
foreignFfi: features.foreignFfi,
...(features.workers ? { workers: true } : {}),
@@ -1161,10 +1164,36 @@ async function prepareExecutableInputInChild(
* `requestPath` as `compileTracked` would in-process, and writes what it prepared, without the LLVM
* text, to `resultPath`.
*/
+/** Has the build reuse the diagnostics of the declaration files scriptc
+ * supplies (lib-diagnostics.ts) through the build cache at `root`, when the
+ * build uses one (executables; source outputs create no cache) and the
+ * checker is tsc-ts. */
+function configureLibDiagnostics(root: string | null): void {
+ let checker: string | undefined;
+ try {
+ checker = root === null ? undefined : checkerCacheIdentity();
+ } catch {
+ // A missing checker fails where the build starts it.
+ checker = undefined;
+ }
+ setLibDiagnosticsStore(
+ root === null || checker === undefined
+ ? null
+ : fileVerdictStore(join(root, "lib-diagnostics-v1")),
+ checker ?? "",
+ );
+}
+
export async function runFrontendProcess(requestPath: string, resultPath: string): Promise {
const request = JSON.parse(await readFile(requestPath, "utf8")) as FrontendProcessRequest;
clearCompileSessionCaches();
setProvenanceSources(request.provenance);
+ // The compile that started this process prepared the cache root.
+ configureLibDiagnostics(
+ (request.opts.outputKind ?? "exe") === "exe" && provenanceSources() === null
+ ? await prepareBuildCacheRoot(buildCacheRoot())
+ : null,
+ );
const frontendInputs = new FrontendInputTracker();
const warnings: CompileWarningSink = { warnings: [], sourceTexts: null };
let result: FrontendProcessResult = { kind: "failed" };
@@ -1212,6 +1241,8 @@ export async function runFrontendProcess(requestPath: string, resultPath: string
}
} catch {
result = { kind: "failed" };
+ } finally {
+ setLibDiagnosticsStore(null);
}
await writeFile(resultPath, JSON.stringify(result), { mode: 0o600 });
}
@@ -1449,6 +1480,7 @@ async function compileTracked(
outputKind === "exe" && provenanceSources() === null
? await prepareBuildCacheRoot(buildCacheRoot())
: null;
+ configureLibDiagnostics(cacheRoot);
let earlyCacheOptions: EarlyExecutableCacheOptions | null = null;
if (outputKind === "exe") {
const helperObjectRoute =
diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts
index 70ec381a1..ee1cee6be 100644
--- a/packages/compiler/src/ir/analysis.ts
+++ b/packages/compiler/src/ir/analysis.ts
@@ -4,6 +4,7 @@ import {
BYTES_ELEMENT_NAME,
DYN_HANDLE_KINDS,
RUNTIME_STREAM_CLASSES,
+ recordTextCodecClass,
typeEquals,
type IrExpr,
type IrLibFn,
@@ -141,8 +142,10 @@ export function dynDesc(
return "string";
case "bool":
return "boolean";
- case "record":
- return recordsById.get(t.shapeId)?.tuple ? "array" : "object";
+ case "record": {
+ const shape = recordsById.get(t.shapeId);
+ return shape?.tuple ? "array" : ((shape && recordTextCodecClass(shape)) ?? "object");
+ }
case "array":
return "array";
case "nullT":
diff --git a/packages/compiler/src/ir/array-length-stability.test.ts b/packages/compiler/src/ir/array-length-stability.test.ts
index f9b3cc9c2..97270e059 100644
--- a/packages/compiler/src/ir/array-length-stability.test.ts
+++ b/packages/compiler/src/ir/array-length-stability.test.ts
@@ -193,3 +193,34 @@ test("number and boolean writes to class fields run no user code", () => {
// A reference stored into a field releases the old one.
expect(preserves([fieldSet(arr("b"))], "i", new Set(), scope)).toBe(false);
});
+
+test("optional element reads, their assertion errors and present-undefined stores at an in-range index keep the length", () => {
+ const optional = (receiver: string, index: IrExpr): IrExpr => ({
+ kind: "call",
+ callee: "%arr.idxOr.f64",
+ args: [arr(receiver), index],
+ type: F64,
+ loc,
+ });
+ const undefinedAt = (receiver: string, index: IrExpr): IrStmt => ({
+ kind: "arraySetUndefined",
+ arr: arr(receiver),
+ index,
+ loc,
+ });
+ const thrown: IrExpr = { kind: "libCall", fn: "error.nodeThrow", args: [num(1)], type: F64, loc };
+ expect(preserves([stmt(optional("a", ref("i"))), stmt(thrown)])).toBe(true);
+ expect(preserves([undefinedAt("a", ref("i"))])).toBe(true);
+ // Past the counter, a present undefined extends the array.
+ expect(preserves([undefinedAt("a", plus(ref("i"), num(1)))])).toBe(false);
+ // The high counter of a two-counter loop is in range as well.
+ const paired = (body: IrStmt[]): boolean =>
+ preservesArrayLength(
+ [cond, ...body],
+ { receiver: "a", inRange: "i", inRangeHigh: "j" },
+ locals,
+ new Set(),
+ );
+ expect(paired([undefinedAt("a", ref("j")), set("a", ref("j"), num(0))])).toBe(true);
+ expect(preserves([set("a", ref("j"), num(0))])).toBe(false);
+});
diff --git a/packages/compiler/src/ir/array-length-stability.ts b/packages/compiler/src/ir/array-length-stability.ts
index ed57972b6..7715ea1e1 100644
--- a/packages/compiler/src/ir/array-length-stability.ts
+++ b/packages/compiler/src/ir/array-length-stability.ts
@@ -162,6 +162,10 @@ export interface ArrayLengthLoop {
* long as the length itself has not changed). An element write at this
* index replaces an existing element or sets a non-index property. */
inRange: string | null;
+ /** A second such binding: the high counter of a two-counter loop
+ * (`for (let i = lo, j = hi; i < j; i++, j--)`), whose values stay
+ * between the low counter's start and its own. */
+ inRangeHigh?: string | null;
}
/** Whether running `nodes` (a loop's condition, body and update) can never
@@ -191,7 +195,10 @@ export function preservesArrayLength(
// escaping.
const receiver = (e: IrExpr): boolean =>
(e.kind === "varRef" && e.localId === loop.receiver) || expr(e);
- const written = (id: string): boolean => id === loop.receiver || id === loop.inRange;
+ const inRange = (id: string): boolean =>
+ (loop.inRange !== null && id === loop.inRange) ||
+ (loop.inRangeHigh != null && id === loop.inRangeHigh);
+ const written = (id: string): boolean => id === loop.receiver || inRange(id);
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "varRef":
@@ -224,12 +231,21 @@ export function preservesArrayLength(
case "call":
// A checked union extraction (`x!`, coercions/unions.ts): the
// asserted arm's payload or a TypeError, and no user code.
- return e.callee.startsWith("%union.narrow.") && e.args.every(expr);
+ if (e.callee.startsWith("%union.narrow.")) return e.args.every(expr);
+ // An element read as `T | undefined` (int32-arrays.ts isElementReader).
+ return (
+ e.callee.startsWith("%arr.idxOr.") &&
+ e.args.length === 2 &&
+ receiver(e.args[0]!) &&
+ expr(e.args[1]!)
+ );
case "mapIntrinsic":
return READING_MAP_METHODS.has(e.method) && expr(e.receiver) && e.args.every(expr);
case "setIntrinsic":
return READING_SET_METHODS.has(e.method) && expr(e.receiver) && e.args.every(expr);
case "libCall":
+ // The TypeError of an assertion past a union arm leaves the loop.
+ if (e.fn === "error.nodeThrow") return e.args.every(expr);
return (
isBorrowSafeMath(e.fn) &&
e.args.every((arg) => arg.type.kind === "f64") &&
@@ -254,14 +270,18 @@ export function preservesArrayLength(
break;
case "arraySet":
return (
- (separate(s.arr) ||
- (s.index.kind === "varRef" &&
- loop.inRange !== null &&
- s.index.localId === loop.inRange)) &&
+ (separate(s.arr) || (s.index.kind === "varRef" && inRange(s.index.localId))) &&
receiver(s.arr) &&
expr(s.index) &&
expr(s.value)
);
+ case "arraySetUndefined":
+ // A present undefined replacing an element below the length.
+ return (
+ (separate(s.arr) || (s.index.kind === "varRef" && inRange(s.index.localId))) &&
+ receiver(s.arr) &&
+ expr(s.index)
+ );
case "return":
return s.value === null || expr(s.value);
case "fieldSet":
diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts
index 53f4c3eda..5dd437c7a 100644
--- a/packages/compiler/src/ir/builtin-effects.ts
+++ b/packages/compiler/src/ir/builtin-effects.ts
@@ -207,6 +207,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([
"async.awaitDyn",
"als.run",
"als.exitRun",
+ "als.newOptions",
+ "als.bindCallbackDyn",
"dc.chanBindStore",
"dc.chanRunStores",
"http.resWriteHeadDyn",
@@ -694,6 +696,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([
"class.defineField",
"class.throwReadOnly",
"class.throwNotExtensible",
+ "record.throwFrozen",
"dyn.toString",
"dyn.valueOf",
"string.fromCodePoint",
@@ -1028,6 +1031,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([
"crypto.generateKeyPairCb",
"crypto.signOneshot",
"crypto.verifyOneshot",
+ "crypto.diffieHellman",
"crypto.createSign",
"crypto.createVerify",
]);
diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts
index 1be0f95cd..61b1361b0 100644
--- a/packages/compiler/src/ir/builtin-signatures.ts
+++ b/packages/compiler/src/ir/builtin-signatures.ts
@@ -163,6 +163,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"fetch.abortNow": { argTypes: [DYN], result: DYN },
"fetch.abortAny": { argTypes: [DYN], result: DYN },
"fetch.streamNew": { argTypes: [DYN], result: DYN },
+ "fetch.transformNew": { argTypes: [], result: DYN },
// Program-dependent iterable: typed arrays/bytes/string stay intact so
// the native stream can pull lazily; checked-dynamic values are the
// fallback. The libCall validator in validate.ts checks the closed set.
@@ -272,6 +273,28 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
/** Throws Node's TypeError for a class field initializer on a
* non-extensible instance: the field name. */
"class.throwNotExtensible": { argTypes: [STRING], result: VOID },
+ /** Object.freeze of a record whose shape is freezable (frozen-records.ts):
+ * sets the frozen bit in its rc word (scr_record_freeze). The receiver is
+ * any record type. */
+ "record.freeze": { argTypes: [null], result: VOID },
+ /** Whether a record is frozen: its rc word's frozen bit, set by
+ * record.freeze and dyn.freeze of a capsule. Emitted inline. The receiver
+ * is any record type. */
+ "record.isFrozen": { argTypes: [null], result: BOOL },
+ /** Throws Node's TypeError for a write to a frozen record: the key, the
+ * object's text in the message ("#") and the operation (0 assign,
+ * 1 add, 2 delete). */
+ "record.throwFrozen": { argTypes: [STRING, STRING, F64], result: VOID },
+ /** Object.freeze of an array (scr_arr_freeze): sets its frozen bit, which
+ * the write guards of IrModule.freezableArrays test, and isFrozen reads.
+ * The receiver is any array type. */
+ "array.freeze": { argTypes: [null], result: VOID },
+ "array.isFrozen": { argTypes: [null], result: BOOL },
+ /** Object.freeze, seal or preventExtensions of a Map or Set
+ * (scr_map_prevent_extensions), which only makes it not extensible: its
+ * entries stay writable. The receiver is any map or set type. */
+ "collection.preventExtensions": { argTypes: [null], result: VOID },
+ "collection.isExtensible": { argTypes: [null], result: BOOL },
"dyn.nativeSetNew": { argTypes: [DYN], result: DYN },
"dyn.nativeSetIs": { argTypes: [DYN], result: BOOL },
"dyn.nativeMapIs": { argTypes: [DYN], result: BOOL },
@@ -4435,6 +4458,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* (inspect, Object.keys/values/entries) then answer through its live
* checked view. The receiver is any such record type. Never throws. */
"record.sourceHas": { argTypes: [null], result: BOOL },
+ /** `record.sourceSpread(built, value)`: the record a literal starting
+ * with `...value` just built keeps the order of value's kept source
+ * (key-order-witness.ts ordered spreads). Both are record types. Never
+ * throws. */
+ "record.sourceSpread": { argTypes: [null, null], result: VOID },
"insp.key": { argTypes: [STRING], result: STRING },
"insp.moreItems": { argTypes: [F64], result: STRING },
"insp.end": { argTypes: [STRING, STRING, STRING, F64, BOOL, BOOL], result: STRING },
@@ -4725,6 +4753,20 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"als.exitRun": { argTypes: [F64, DYN, DYN], result: DYN },
"als.enterWith": { argTypes: [F64, DYN], result: VOID },
"als.disable": { argTypes: [F64], result: VOID },
+ /** new AsyncLocalStorage(options) (Node's object check throws; MAY
+ * THROW) and the store's name option (dyn; "" without one). */
+ "als.newOptions": { argTypes: [DYN], result: F64 },
+ "als.name": { argTypes: [F64], result: DYN },
+ /** A function bound to the active context: the completion callbacks of
+ * callback-style APIs (always false: an empty context leaves the
+ * callback unbound) and AsyncLocalStorage.bind (always true). The typed
+ * form answers its closure argument's type; the dyn form throws Node's
+ * ERR_INVALID_ARG_TYPE for a non-function when always (MAY THROW). */
+ "als.bindCallback": { argTypes: [null, BOOL], result: VOID },
+ "als.bindCallbackDyn": { argTypes: [DYN, BOOL], result: DYN },
+ /** AsyncLocalStorage.snapshot(): a dyn function (cb, ...args) that runs
+ * cb in the context active at the snapshot. */
+ "als.snapshot": { argTypes: [], result: DYN },
/** Channel.bindStore/unbindStore/runStores (scr_dc.c): the
* AsyncLocalStorage integration — set-semantics bindings per store,
* runStores entering every bound store with transform(data) around the
@@ -4780,6 +4822,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"date.getMilliseconds": { argTypes: [DATE_T], result: F64 },
"date.getUTCMilliseconds": { argTypes: [DATE_T], result: F64 },
"date.getTimezoneOffset": { argTypes: [DATE_T], result: F64 },
+ /** A Date object for a time value a local holds (date-values.ts): a
+ * lib-call argument passes the time value itself, and any other use
+ * creates the Date. */
+ "date.fromTime": { argTypes: [F64], result: DATE_T },
/** Date.prototype.toString / toDateString / toTimeString and String(date):
* V8's local-time formats with the host zone's long name, "Invalid Date"
* for an invalid date. Owned (+1) string; never throws. */
@@ -4832,6 +4878,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"crypto.generateKeyPairSync": { argTypes: [DYN, DYN], result: DYN },
"crypto.generateKeyPairCb": { argTypes: [DYN, DYN, DYN], result: VOID },
"crypto.signOneshot": { argTypes: [DYN, DYN, DYN], result: DYN },
+ "crypto.diffieHellman": { argTypes: [DYN, DYN], result: DYN },
"crypto.verifyOneshot": { argTypes: [DYN, DYN, DYN, DYN], result: DYN },
"crypto.createSign": { argTypes: [DYN], result: DYN },
"crypto.createVerify": { argTypes: [DYN], result: DYN },
@@ -4852,6 +4899,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"text.decoderEncoding": { argTypes: [DYN], result: F64 },
"text.decoderName": { argTypes: [F64], result: STRING },
"text.encodeInto": { argTypes: [DYN, DYN], result: DYN },
+ /** `value instanceof TextDecoder` (true) or `TextEncoder` (false) on a
+ * checked-dynamic value: a codec record's handle box (scr_text_codec_dyn.c).
+ * Borrowed; never throws. */
+ "text.codecIs": { argTypes: [DYN, BOOL], result: BOOL },
"buffer.isAscii": { argTypes: [DYN], result: BOOL },
"buffer.isUtf8": { argTypes: [DYN], result: BOOL },
"buffer.transcode": { argTypes: [DYN, DYN, DYN], result: BYTES_U8 },
diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts
index 26539b17a..91e1d5f05 100644
--- a/packages/compiler/src/ir/function-references.test.ts
+++ b/packages/compiler/src/ir/function-references.test.ts
@@ -219,6 +219,7 @@ const MODULE_FIELDS: Record>, Role> = {
"wasmUntranslated[].sha256": "other",
"wasmUntranslated[].reason": "other",
"threadSlotKeys[]": "other",
+ "freezableArrays[]": "other",
};
const loc = { file: "references.ts", start: 0, end: 0 };
diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts
index 02de7268f..353755c05 100644
--- a/packages/compiler/src/ir/int32-arrays.ts
+++ b/packages/compiler/src/ir/int32-arrays.ts
@@ -1,3 +1,4 @@
+import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js";
import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
type SwitchCase = Extract["cases"][number];
@@ -6,22 +7,29 @@ import {
everyExprChild,
everyStmtChild,
everyStmtList,
+ mapExprChildren,
+ mapStmtChildren,
transformExpr,
transformStmt,
transformStmtList,
type IrTransform,
} from "./traverse.js";
-/** Local number arrays the backend may store as int32 slots.
+/** Number arrays the backend may store as int32 slots.
*
- * A candidate is a local `number[]` of a synchronous function that no
- * other binding can hold and only compiled element operations reach:
- * every write of the local stores a fresh array (an array literal, or
- * `new Array(n).fill(v)`), and every read of it is the receiver of an
- * element read, write, state test, `length`, `push` or `pop` (a discarded
- * `pop` included), or the array of an element-reader helper call. The
- * array never reaches a runtime function that reads its slots, a library
- * call, a closure, a return or untyped code.
+ * A candidate is a `number[]` binding that only compiled element
+ * operations reach: a local of a synchronous function, a module global, or
+ * a parameter. Every write of a local or a global stores a fresh array (an
+ * array literal, or `new Array(n).fill(v)`); a parameter is never written.
+ * Every read of a candidate is the receiver of an element read, write,
+ * state test, `length`, `push` or `pop` (a discarded `pop` included), the
+ * array of an element-reader helper call, or the argument of a direct call
+ * whose parameter at that position is a candidate itself. A parameter is a
+ * candidate only when its function is reached by direct calls alone and
+ * every call passes a candidate there, so every binding that can hold one
+ * of these arrays is a candidate: the array never reaches a runtime
+ * function that reads its slots, a library call, a closure, a return or
+ * untyped code.
*
* Candidates are only proposed here. The int32 slot analysis
* (int32-slots.ts) keeps the ones whose every stored element is a proven
@@ -33,6 +41,18 @@ import {
* calls are inlined first (rewriteCandidates), so no shared helper ever
* receives an int32 array. */
+/** The candidate key of the binding `localId` as `fnName` reads it:
+ * `function\0local` for a local or parameter, `\0global` for a module
+ * global (`%g.` ids), which every function reads alike. */
+export function int32ArrayKey(fnName: string, localId: string): string {
+ return localId.startsWith("%g.") ? `\0${localId}` : `${fnName}\0${localId}`;
+}
+
+/** The int32 element slot (int32-slots.ts) of a candidate key. */
+export function int32ArraySlot(key: string): string {
+ return key.startsWith("\0") ? `g:${key.slice(1)}` : `l:${key}`;
+}
+
/** Array methods a candidate may be the receiver of: the runtime paths
* behind them read and write int32 slots through the double-valued slot
* accessors. */
@@ -202,10 +222,70 @@ function writesLocal(e: IrExpr, id: string): boolean {
return !expr(e);
}
-/** The candidate locals of `fn` (see the module comment). */
-export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet): Set {
+/** Expression kinds an index can be built from without running any code. */
+const PURE_INDEX_KINDS: ReadonlySet = new Set([
+ "varRef",
+ "numLit",
+ "boolLit",
+ "bin",
+ "unary",
+ "ternary",
+ "logical",
+ "arrayGet",
+ "arrayState",
+]);
+
+/** Whether evaluating an index runs no code (no call, accessor or
+ * conversion): plain reads, literals and their operators only. */
+function pureIndex(e: IrExpr): boolean {
+ return PURE_INDEX_KINDS.has(e.kind) && everyExprChild(e, pureIndex, () => false);
+}
+
+/** The parameters and globals a module's functions share as candidates,
+ * still assumed to qualify. */
+export interface SharedCandidates {
+ /** Candidate parameters, as `function\0local` keys. */
+ params: ReadonlySet;
+ /** Candidate globals, as `%g.` ids. */
+ globals: ReadonlySet;
+ /** Whether the parameter at `index` of `callee` is a candidate. */
+ paramAt: (callee: string, index: number) => boolean;
+}
+
+const NO_SHARED: SharedCandidates = {
+ params: new Set(),
+ globals: new Set(),
+ paramAt: () => false,
+};
+
+/** The candidates of `fn` (see the module comment): its own locals, its
+ * parameters in `shared.params`, and the globals of `shared.globals` that
+ * it reads only in candidate positions. `written` receives the globals it
+ * assigns fresh arrays. */
+export function int32ArrayCandidates(
+ fn: IrFunction,
+ readers: ReadonlySet,
+ shared: SharedCandidates = NO_SHARED,
+ written: Set = new Set(),
+): Set {
const result = new Set();
- if (fn.async || fn.generator) return result;
+ if (fn.async || fn.generator) {
+ // Globals read here leave the candidates.
+ for (const id of shared.globals) result.add(id);
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ if ((e.kind === "varRef" || e.kind === "assignExpr") && result.has(e.localId))
+ result.delete(e.localId);
+ return true;
+ },
+ stmt: (s) => {
+ if ((s.kind === "varDecl" || s.kind === "assign") && result.has(s.localId))
+ result.delete(s.localId);
+ return true;
+ },
+ });
+ return result;
+ }
const captured = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
@@ -217,11 +297,12 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet();
+ const allocated = new Set();
// A receiver position: the candidate is read there without escaping.
const receiver = (e: IrExpr): void => {
if (e.kind !== "varRef") expr(e);
@@ -255,13 +336,28 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet {
+ if (shared.paramAt(e.callee, index)) receiver(arg);
+ else expr(arg);
+ });
return true;
}
}
@@ -272,7 +368,8 @@ export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet | undefined, live: ReadonlySet): Set {
+ const result = new Set();
+ if (ids) for (const id of ids) if (live.has(id)) result.add(id);
+ return result;
+}
+
+/** The candidates of every function of `mod` that has any, only those
+ * whose keys are in `keep` when given. Parameters and globals are shared:
+ * starting from every one that may qualify, each pass drops the ones a
+ * function reads outside candidate positions, the parameters some call
+ * passes anything but a candidate, and the globals never assigned a fresh
+ * array, until a pass drops nothing. Functions in `redirected` keep their
+ * parameters: some of their calls reach another function. */
+export function moduleInt32ArrayCandidates(
+ mod: IrModule,
+ readers: ReadonlySet,
+ keep: ReadonlySet | null = null,
+ redirected: ReadonlySet = new Set(),
+): Map> {
+ const kept = (key: string): boolean => keep === null || keep.has(key);
+ const arrayParams = new Map();
+ for (const fn of mod.functions) {
+ // Class methods, constructors and runtime helpers (`%` names) keep
+ // their parameters.
+ if (fn.name.startsWith("%") || fn.async || fn.generator || redirected.has(fn.name)) continue;
+ const indexes: number[] = [];
+ fn.params.forEach((p, i) => {
+ if (p.type.kind === "array" && p.type.elem.kind === "f64") indexes.push(i);
+ });
+ if (indexes.length > 0) arrayParams.set(fn.name, indexes);
+ }
+ let globals = new Set();
+ for (const g of mod.globals ?? [])
+ if (
+ g.type.kind === "array" &&
+ g.type.elem.kind === "f64" &&
+ g.initFlag === undefined &&
+ kept(`\0${g.id}`)
+ )
+ globals.add(g.id);
+ const result = new Map>();
+ if (arrayParams.size === 0 && globals.size === 0) {
+ for (const fn of mod.functions) {
+ const candidates = int32ArrayCandidates(fn, readers);
+ for (const id of candidates) if (!kept(`${fn.name}\0${id}`)) candidates.delete(id);
+ if (candidates.size > 0) result.set(fn.name, candidates);
+ }
+ return result;
+ }
+ // One walk: the functions reached other than by direct calls, the direct
+ // calls of functions with number[] parameters, the globals each function
+ // reads or writes, and whether a module namespace object exists (its
+ // member reads can reach any global by name).
+ const reached = new Set(moduleFunctionReferences(mod));
+ const calls: { caller: string; call: IrExpr & { kind: "call" } }[] = [];
+ const globalsIn = new Map>();
+ let namespaces = false;
+ for (const fn of mod.functions) {
+ const used = new Set();
+ const note = (id: string): void => {
+ if (globals.has(id)) used.add(id);
+ };
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ const name = exprFunctionReference(e);
+ if (name !== undefined) reached.add(name);
+ if (e.kind === "moduleNsRef") namespaces = true;
+ if (e.kind === "call" && arrayParams.has(e.callee))
+ calls.push({ caller: fn.name, call: e });
+ if (e.kind === "varRef" || e.kind === "assignExpr") note(e.localId);
+ return true;
+ },
+ stmt: (s) => {
+ if (s.kind === "varDecl" || s.kind === "assign") note(s.localId);
+ return true;
+ },
+ });
+ if (used.size > 0) globalsIn.set(fn.name, used);
+ }
+ if (namespaces) {
+ globals = new Set();
+ globalsIn.clear();
+ }
+ const paramIds = new Map();
+ let params = new Set();
+ for (const fn of mod.functions) {
+ const indexes = arrayParams.get(fn.name);
+ if (!indexes || reached.has(fn.name)) continue;
+ paramIds.set(
+ fn.name,
+ fn.params.map((p) => p.localId),
+ );
+ for (const i of indexes) {
+ const key = `${fn.name}\0${fn.params[i]!.localId}`;
+ if (kept(key)) params.add(key);
+ }
+ }
+ for (;;) {
+ const current = params;
+ const written = new Set();
+ const liveGlobals = new Set(globals);
+ result.clear();
+ for (const fn of mod.functions) {
+ const shared: SharedCandidates = {
+ params: current,
+ globals: liveIn(globalsIn.get(fn.name), globals),
+ paramAt: (callee, index) => {
+ const id = paramIds.get(callee)?.[index];
+ return id !== undefined && current.has(`${callee}\0${id}`);
+ },
+ };
+ const candidates = int32ArrayCandidates(fn, readers, shared, written);
+ for (const id of shared.globals) if (!candidates.has(id)) liveGlobals.delete(id);
+ for (const id of candidates)
+ if (!shared.globals.has(id) && !kept(`${fn.name}\0${id}`)) candidates.delete(id);
+ if (candidates.size > 0) result.set(fn.name, candidates);
+ }
+ for (const id of liveGlobals) if (!written.has(id)) liveGlobals.delete(id);
+ const live = new Set(
+ [...current].filter((key) => {
+ const cut = key.indexOf("\0");
+ return result.get(key.slice(0, cut))?.has(key.slice(cut + 1)) === true;
+ }),
+ );
+ // A parameter needs a candidate at that position of every call; a call
+ // with fewer arguments passes undefined.
+ for (const { caller, call } of calls) {
+ const ids = paramIds.get(call.callee);
+ if (!ids) continue;
+ ids.forEach((id, i) => {
+ const key = `${call.callee}\0${id}`;
+ if (!live.has(key)) return;
+ const arg = call.args[i];
+ const ok =
+ arg?.kind === "varRef" &&
+ (arg.localId.startsWith("%g.")
+ ? liveGlobals.has(arg.localId)
+ : result.get(caller)?.has(arg.localId) === true);
+ if (!ok) live.delete(key);
+ });
+ }
+ if (live.size === current.size && liveGlobals.size === globals.size) break;
+ params = live;
+ globals = liveGlobals;
+ }
+ return result;
+}
+
+/** The candidates one array can reach together: a direct call's argument
+ * and the parameter it binds hold the same array, so their element facts
+ * stand or fall together. `group` maps each of `keys` to its group's keys
+ * (itself alone for most), and `globalReaders` each candidate global's
+ * key to the functions that read or write it. */
+export function int32ArrayGroups(
+ mod: IrModule,
+ keys: ReadonlySet,
+): { group: Map; globalReaders: Map> } {
+ const parent = new Map();
+ const root = (key: string): string => {
+ let r = key;
+ while (parent.has(r)) r = parent.get(r)!;
+ if (r !== key) parent.set(key, r);
+ return r;
+ };
+ const globalReaders = new Map>();
+ const params = new Map(mod.functions.map((fn) => [fn.name, fn.params]));
+ const paramKeys = new Set(
+ mod.functions.flatMap((fn) => fn.params.map((p) => `${fn.name}\0${p.localId}`)),
+ );
+ if ([...keys].some((key) => key.startsWith("\0") || paramKeys.has(key))) {
+ for (const fn of mod.functions) {
+ const touch = (id: string): void => {
+ const key = int32ArrayKey(fn.name, id);
+ if (!key.startsWith("\0") || !keys.has(key)) return;
+ const set = globalReaders.get(key) ?? new Set();
+ set.add(fn.name);
+ globalReaders.set(key, set);
+ };
+ everyStmtList(fn.body, {
+ expr: (e) => {
+ if (e.kind === "varRef" || e.kind === "assignExpr") touch(e.localId);
+ if (e.kind === "call")
+ e.args.forEach((arg, i) => {
+ const param = params.get(e.callee)?.[i];
+ if (arg.kind !== "varRef" || !param) return;
+ const a = int32ArrayKey(fn.name, arg.localId),
+ p = `${e.callee}\0${param.localId}`;
+ if (!keys.has(a) || !keys.has(p)) return;
+ const ra = root(a),
+ rp = root(p);
+ if (ra !== rp) parent.set(ra, rp);
+ });
+ return true;
+ },
+ stmt: (s) => {
+ if (s.kind === "varDecl" || s.kind === "assign") touch(s.localId);
+ return true;
+ },
+ });
+ }
+ }
+ const members = new Map();
+ for (const key of keys) {
+ const r = root(key);
+ const list = members.get(r);
+ if (list) list.push(key);
+ else members.set(r, [key]);
+ }
+ const group = new Map();
+ for (const key of keys) group.set(key, members.get(root(key))!);
+ return { group, globalReaders };
+}
+
/** The element values stored into each candidate: element writes, pushed
* values, literal elements and fill values. */
export function int32ArrayStores(
@@ -341,16 +656,34 @@ export function int32ArrayStores(
return stores;
}
+const REPEATED_INDEX_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]);
+
+/** An index the inlined read may evaluate at each of its uses: a plain read,
+ * a literal, or integer arithmetic over them. Repeating it keeps the forms
+ * the backend derives from its operands at every use, such as the exact i64
+ * of an int32 product, where a bound local holds only the double. */
+function repeatableIndex(e: IrExpr): boolean {
+ if (e.kind === "varRef" || e.kind === "numLit") return true;
+ if (e.kind === "unary") return e.op === "-" && repeatableIndex(e.operand);
+ return (
+ e.kind === "bin" &&
+ REPEATED_INDEX_OPS.has(e.op) &&
+ repeatableIndex(e.left) &&
+ repeatableIndex(e.right)
+ );
+}
+
/** Rewrite a function's candidates for the backend. A discarded pop uses
* the candidate in place of its hidden local. An element-reader helper call
* whose array is a candidate is inlined: the helper's returned ternary, with
* the candidate for its array and the index for its index, so the read means
- * the same. An index that is not a plain read or literal is bound to a fresh
- * local first: in its own declaration just before the statement when the
- * call is the whole value of a declaration or assignment (the form lowering
- * gives `a[i]!`, and the one the backend reads without a box), otherwise in a
- * sequence around the ternary. Selection keeps out indexes that write the
- * array, so evaluating the index before the array read is unobservable. */
+ * the same. An index that is not repeatable (repeatableIndex) is bound to a
+ * fresh local first: in its own declaration just before the statement when
+ * the call is the whole value of a declaration or assignment (the form
+ * lowering gives `a[i]!`, and the one the backend reads without a box),
+ * otherwise in a sequence around the ternary. Selection keeps out indexes
+ * that write the array, so evaluating the index before the array read is
+ * unobservable. */
function rewriteCandidates(
fn: IrFunction,
candidates: ReadonlySet,
@@ -374,14 +707,13 @@ function rewriteCandidates(
candidates.has(e.args[0]!.localId)
? e
: null;
- const simple = (e: IrExpr): boolean => e.kind === "varRef" || e.kind === "numLit";
// The statements of a list, with complex read indexes declared first.
const hoist = (list: IrStmt[]): IrStmt[] =>
list.flatMap((s): IrStmt[] => {
if (s.kind !== "varDecl" && s.kind !== "assign") return [s];
const value = s.kind === "varDecl" ? s.init : s.value;
const call = readerCall(value);
- if (!call || simple(call.args[1]!)) return [s];
+ if (!call || repeatableIndex(call.args[1]!)) return [s];
const { decl, ref } = bind(call.args[1]!);
const read: IrExpr = { ...call, args: [call.args[0]!, ref] };
if (s.kind === "varDecl") return [decl, { ...s, init: read }];
@@ -444,21 +776,23 @@ function rewriteCandidates(
if (ret.kind !== "return" || !ret.value) return e;
let index = call.args[1]!;
const stmts: IrStmt[] = [];
- if (!simple(index)) {
+ if (!repeatableIndex(index)) {
const { decl, ref } = bind(index);
stmts.push(decl);
index = ref;
}
const operand = index;
- const body = transformExpr(reparseExpr(ret.value), {
- expr: (x) => {
- if (x.kind !== "varRef") return x;
- if (x.localId === arrayParam!.localId) return reparseExpr(array);
- if (x.localId === indexParam!.localId) return reparseExpr(operand);
- return x;
- },
- stmt: (x) => x,
- });
+ // The arguments' copies are not searched again: an index may read a
+ // local that has the id of one of the helper's parameters.
+ const substituteExpr = (x: IrExpr): IrExpr => {
+ if (x.kind !== "varRef") return mapExprChildren(x, substituteExpr, substituteStmt);
+ if (x.localId === arrayParam!.localId) return reparseExpr(array);
+ if (x.localId === indexParam!.localId) return reparseExpr(operand);
+ return x;
+ };
+ const substituteStmt = (x: IrStmt): IrStmt =>
+ mapStmtChildren(x, substituteExpr, substituteStmt);
+ const body = substituteExpr(reparseExpr(ret.value));
// The ternary is a fresh copy (reparseExpr): it takes the call's location.
const typed = body;
typed.loc = e.loc;
@@ -471,40 +805,45 @@ function rewriteCandidates(
return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) };
}
-/** Propose the module's int32 array candidates (`function\0local` keys in
+/** Propose the module's int32 array candidates (int32ArrayKey keys in
* `int32ArrayCandidates`), only those in `keep` when given, and rewrite
* their functions. */
export function markInt32ArrayCandidates(
mod: IrModule,
keep: ReadonlySet | null = null,
+ redirected: ReadonlySet = new Set(),
): IrModule {
if (mod.lib !== undefined) return mod;
const readers = new Map();
for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn);
- const names = new Set(readers.keys());
- const keys: string[] = [];
+ const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected);
+ if (all.size === 0) return mod;
+ const keys = new Set();
const functions = mod.functions.map((fn) => {
- const candidates = int32ArrayCandidates(fn, names);
- for (const id of candidates)
- if (keep === null || keep.has(`${fn.name}\0${id}`)) keys.push(`${fn.name}\0${id}`);
- else candidates.delete(id);
- return candidates.size === 0 ? fn : rewriteCandidates(fn, candidates, readers);
+ const candidates = all.get(fn.name);
+ if (!candidates) return fn;
+ for (const id of candidates) keys.add(int32ArrayKey(fn.name, id));
+ return rewriteCandidates(fn, candidates, readers);
});
- return keys.length === 0 ? mod : { ...mod, functions, int32ArrayCandidates: keys };
+ return { ...mod, functions, int32ArrayCandidates: [...keys] };
}
/** The module the backend emits: rewritten for exactly the candidates the
* int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a
* module without int32 arrays is `mod` itself. A dropped candidate's
* rewrite is undone by marking again without it, until every candidate
- * marked is kept. */
+ * marked is kept. `redirected` names the functions some of whose calls the
+ * backend emits as calls of another function (a result-slot entry): their
+ * parameters keep doubles. */
export function selectInt32Arrays(
mod: IrModule,
prove: (marked: IrModule) => ReadonlySet,
+ redirected: (mod: IrModule) => ReadonlySet = () => new Set(),
): IrModule {
let keep: ReadonlySet | null = null;
+ const others = redirected(mod);
for (let round = 0; round < 4; round++) {
- const marked = markInt32ArrayCandidates(mod, keep);
+ const marked = markInt32ArrayCandidates(mod, keep, others);
if (marked === mod) return mod;
const kept = prove(marked);
const proposed = marked.int32ArrayCandidates ?? [];
diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts
index 8b992e6a8..85f373c89 100644
--- a/packages/compiler/src/ir/int32-slots.ts
+++ b/packages/compiler/src/ir/int32-slots.ts
@@ -20,7 +20,13 @@ import {
type IrNodeKind,
} from "./node-index.js";
import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js";
-import { checkableInt32Allocations, int32ArrayStores } from "./int32-arrays.js";
+import {
+ checkableInt32Allocations,
+ int32ArrayGroups,
+ int32ArrayKey,
+ int32ArraySlot,
+ int32ArrayStores,
+} from "./int32-arrays.js";
import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js";
/** The storage slot an array-valued expression reads, as an
@@ -257,9 +263,10 @@ export class Int32Slots {
/** Whether the module has int32 array candidates (the narrowedReads
* range fact). */
narrowedReads = false;
- /** Local number arrays (`function\0local`) stored as int32 slots: the
- * module's int32 array candidates whose every stored element the
- * fixpoint proved an int32. */
+ /** Number arrays stored as int32 slots (int32ArrayKey keys: locals and
+ * parameters as `function\0local`, globals as `\0global`): the module's
+ * int32 array candidates whose every stored element, through every
+ * binding that can hold the same array, the fixpoint proved an int32. */
readonly int32Arrays = new Set();
/** Local number arrays that may hold int32 slots with checked stores: the
* candidates whose element stores the fixpoint proved exact integers but
@@ -847,17 +854,29 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key);
for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key);
// Int32 array candidates (ir/int32-arrays.ts): assumed to hold int32
- // elements until a store the fixpoint cannot prove drops one.
+ // elements until a store the fixpoint cannot prove drops one, with every
+ // candidate the same array can reach (its group).
const arrayCandidates = new Map>();
- slots.narrowedReads = (mod.int32ArrayCandidates?.length ?? 0) > 0;
- for (const key of mod.int32ArrayCandidates ?? []) {
+ const globalArrays = new Set();
+ const arrayKeys = new Set(mod.int32ArrayCandidates ?? []);
+ slots.narrowedReads = arrayKeys.size > 0;
+ for (const key of arrayKeys) {
const cut = key.indexOf("\0");
- const fnName = key.slice(0, cut);
- const set = arrayCandidates.get(fnName) ?? new Set();
- set.add(key.slice(cut + 1));
- arrayCandidates.set(fnName, set);
- slots.addInt32Elements(`l:${key}`);
+ if (cut === 0) globalArrays.add(key.slice(1));
+ else {
+ const fnName = key.slice(0, cut);
+ const set = arrayCandidates.get(fnName) ?? new Set();
+ set.add(key.slice(cut + 1));
+ arrayCandidates.set(fnName, set);
+ }
+ slots.addInt32Elements(int32ArraySlot(key));
}
+ const arrayGroups = arrayKeys.size > 0 ? int32ArrayGroups(mod, arrayKeys) : null;
+ // Functions that read or write a candidate global.
+ const globalArrayFunctions = new Set();
+ if (arrayGroups)
+ for (const names of arrayGroups.globalReaders.values())
+ for (const name of names) globalArrayFunctions.add(name);
/* ── candidate fields ── */
const children = new Map();
@@ -1139,6 +1158,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
const candidateWrites = nodes?.scan(CANDIDATE_WRITE_KINDS);
const hasCandidateWrite = (fn: IrFunction): boolean => {
if (arrayCandidates.has(fn.name)) return true;
+ if (globalArrayFunctions.has(fn.name)) return true;
const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId));
const returns = slots.hasReturn(fn.name);
let found = false;
@@ -1209,25 +1229,49 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
// (checkableInt32Allocations), it keeps int32 slots with checked stores
// (checkedInt32Arrays), which widen it to doubles in place at the first
// store that is not an int32.
- const arrays = arrayCandidates.get(fn.name);
- if (arrays) {
- let dropped = false;
+ const locals = arrayCandidates.get(fn.name);
+ let arrays = locals;
+ if (globalArrayFunctions.has(fn.name)) {
+ const both = new Set();
+ if (locals) for (const id of locals) both.add(id);
+ for (const id of globalArrays) both.add(id);
+ arrays = both;
+ }
+ if (arrays && arrayGroups) {
+ const requeue = (name: string): void => {
+ if (queued.has(name)) return;
+ queued.add(name);
+ queue.push(name);
+ };
+ const params = new Set(fn.params.map((param) => param.localId));
for (const [id, values] of int32ArrayStores(fn, arrays)) {
- const key = `l:${fn.name}\0${id}`;
- if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue;
- slots.dropInt32Elements(key);
+ const key = int32ArrayKey(fn.name, id);
+ if (!slots.hasInt32ElementSlot(int32ArraySlot(key)) || values.every(int32)) continue;
+ // The array's group drops together: every function reading a
+ // member is analyzed again without its facts.
+ const group = arrayGroups.group.get(key) ?? [key];
+ for (const member of group) {
+ slots.dropInt32Elements(int32ArraySlot(member));
+ if (member.startsWith("\0")) {
+ const names = arrayGroups.globalReaders.get(member);
+ if (names) for (const name of names) requeue(name);
+ } else requeue(member.slice(0, member.indexOf("\0")));
+ }
+ // Checked stores serve a lone local: its allocations are the only
+ // arrays it holds.
const checkable = (value: IrExpr): boolean => {
const range = ranges.get(value);
return int32(value) || range != null;
};
- if (values.every(checkable) && checkableInt32Allocations(fn, id))
- slots.checkedInt32Arrays.add(`${fn.name}\0${id}`);
- else reason(`array:${fn.name}\0${id}`, "an element store is not int32");
- dropped = true;
- }
- if (dropped && !queued.has(fn.name)) {
- queued.add(fn.name);
- queue.push(fn.name);
+ if (
+ group.length === 1 &&
+ !key.startsWith("\0") &&
+ !params.has(id) &&
+ values.every(checkable) &&
+ checkableInt32Allocations(fn, id)
+ )
+ slots.checkedInt32Arrays.add(key);
+ else for (const member of group) reason(`array:${member}`, "an element store is not int32");
}
}
// An int32 written into a slot still assumed non-negative: widen the
@@ -1298,10 +1342,8 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
}
for (const proven of provenByFunction.values())
for (const value of proven) slots.provenWrites.add(value);
- for (const [fnName, ids] of arrayCandidates)
- for (const id of ids)
- if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`))
- slots.int32Arrays.add(`${fnName}\0${id}`);
+ for (const key of arrayKeys)
+ if (slots.hasInt32ElementSlot(int32ArraySlot(key))) slots.int32Arrays.add(key);
const ranged = rangedParameters(
slots,
functions,
diff --git a/packages/compiler/src/ir/ir.test.ts b/packages/compiler/src/ir/ir.test.ts
index d62c9c83c..30fe6f50e 100644
--- a/packages/compiler/src/ir/ir.test.ts
+++ b/packages/compiler/src/ir/ir.test.ts
@@ -57,7 +57,8 @@ describe("IR kind sets", () => {
test("distinguishes pointer values from object-like scalars", () => {
expect(POINTER_KINDS.has("record")).toBe(true);
- expect(POINTER_KINDS.has("date")).toBe(false);
+ expect(POINTER_KINDS.has("date")).toBe(true);
+ expect(POINTER_KINDS.has("procStream")).toBe(false);
});
test("distinguishes typed-rest closure ABIs", () => {
diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts
index 878088d65..550333edd 100644
--- a/packages/compiler/src/ir/ir.ts
+++ b/packages/compiler/src/ir/ir.ts
@@ -440,6 +440,7 @@ const POINTER_KIND_LIST = [
"url",
"searchParams",
"symbol",
+ "date",
...POINTER_HANDLE_KINDS,
"func",
"object",
@@ -463,7 +464,7 @@ export const POINTER_KINDS = irKindSet(POINTER_KIND_LIST);
export const RUNTIME_RC_STEMS: Record = {
f64: "",
bigint: "scr_bigint",
- date: "",
+ date: "scr_date",
string: "scr_str",
bool: "",
array: "scr_arr",
@@ -645,6 +646,7 @@ export function isSupportedArrayElem(t: IrType): boolean {
case "func":
case "promise":
case "regex":
+ case "date":
case "child":
case "netServer":
// fs.Stats snapshots (`await Promise.all(paths.map(stat))`): immutable
@@ -689,7 +691,8 @@ export function isIdentityCollectionKey(t: IrType): boolean {
// functions intern one immortal closure, inner closures allocate once
// per evaluation), so pointer identity is JS function identity.
// Closures are cycle-headered and traced like records.
- t.kind === "func"
+ t.kind === "func" ||
+ t.kind === "date"
);
}
@@ -797,6 +800,9 @@ export function isSupportedMapValue(t: IrType): boolean {
// the pre-settle closures.
case "secureCtx":
return true;
+ // A Date object: immutable, holds no references.
+ case "date":
+ return true;
// A promise (Map> — the SNI callback's
// in-flight dedupe map): refcounted, with trace/cycle support already
// in place (scr_promise_trace_v — the race machinery's refcounting).
@@ -849,8 +855,9 @@ export function funcOf(params: IrType[], ret: IrType): IrType {
* promises nullable-only; declared maybe-promise unions are admitted by
* maybePromiseUnionArmsOk. */
export function unionContainerArmsOk(arms: IrType[]): boolean {
- return arms.every(
- (a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b)),
+ return (
+ arms.every((a, i) => a.kind !== "promise" || arms.every((b, j) => j === i || isUnitType(b))) &&
+ dateUnionArmsOk(arms)
);
}
@@ -861,7 +868,13 @@ export function unionContainerArmsOk(arms: IrType[]): boolean {
* non-thenable path). Data arms must not be thenable; the type mapper
* checks that against the checker types before minting such a union. */
export function maybePromiseUnionArmsOk(arms: IrType[]): boolean {
- return arms.filter((a) => a.kind === "promise").length <= 1;
+ return arms.filter((a) => a.kind === "promise").length <= 1 && dateUnionArmsOk(arms);
+}
+
+/** A Date arm has a union home beside undefined and null arms only: no
+ * narrowing contract tells it from other data arms yet. */
+function dateUnionArmsOk(arms: IrType[]): boolean {
+ return arms.every((a, i) => a.kind !== "date" || arms.every((b, j) => j === i || isUnitType(b)));
}
/** Canonical, injective text form of an IrType — the building block of
@@ -1056,6 +1069,10 @@ export interface IrModule {
* each one's hierarchy carries a slot word (the slots.* intrinsics;
* backend/llvm/thread-slots.ts). */
threadSlotKeys?: string[];
+ /** typeKeys of the array types whose values the program may freeze
+ * (frontend frozen-records.ts): their writes test the array's frozen bit
+ * (SCR_ARR_FROZEN) and throw Node's TypeError when it is set. */
+ freezableArrays?: string[];
/** The entry module is CommonJS under Node's classification. Node runs a
* CommonJS main synchronously and drains process.nextTick callbacks
* before promise jobs at the first checkpoint; an ES-module main's
@@ -1685,6 +1702,10 @@ export interface IrRecordShape {
* dynCheck), and JSON serialization appends overflow entries after the
* declared fields in insertion order. */
indexValue?: IrType;
+ /** Values of this shape may be frozen (frozen-records.ts): their rc word
+ * can carry the frozen bit, which their release ignores, and every write
+ * to them checks it. */
+ freezable?: true;
/** Field names in FIRST-SEEN declaration order (the checker's property
* order of the first ts.Type interned to this shape) — metadata, NOT
* part of the interned identity: a later structurally-equal type with a
@@ -1745,6 +1766,18 @@ export function recordTextCodecClass(shape: IrRecordShape): "TextEncoder" | "Tex
return null;
}
+/** The codec class of a TextEncoder or TextDecoder record type, else null.
+ * Such a record crosses an `unknown` slot as a native handle that holds the
+ * record itself (scr_text_codec_dyn.c), never as a copy or a field view. */
+export function textCodecRecordClass(
+ t: IrType,
+ getRecord: (shapeId: string) => IrRecordShape | undefined,
+): "TextEncoder" | "TextDecoder" | null {
+ if (t.kind !== "record") return null;
+ const shape = getRecord(t.shapeId);
+ return shape ? recordTextCodecClass(shape) : null;
+}
+
/** Object-literal ACCESSOR properties (`{ get x() {...}, set x(v) {...} }`)
* live on the shape as reserved '%'-fields holding closures: `%get:x` a
* `() => T` invoked once per property READ (side effects and all — JS's
@@ -4019,6 +4052,9 @@ function isJsonSafeAt(
case "string":
case "bool":
return true;
+ // JSON.stringify writes a Date's toJSON(); JSON text never parses to one.
+ case "date":
+ return stringify;
case "array":
return isJsonSafeAt(
t.elem,
@@ -4117,7 +4153,6 @@ function isJsonSafeAt(
// Maps are not JSON (JSON.stringify(new Map()) is "{}" in Node — an
// empty-object husk nobody wants; stringify/dynCheck reject instead).
case "map":
- case "date":
// URLSearchParams stringifies as the same "{}" husk — rejected; use
// sp.toString() instead.
case "searchParams":
@@ -4273,6 +4308,7 @@ export function canConvertToDyn(
visiting: Set = new Set(),
): boolean {
if (isJsonSafeType(t, getRecord, getUnion)) return true;
+ if (textCodecRecordClass(t, getRecord) !== null) return true;
// numeric typed arrays and boxable functions are dyn kinds the walker boxes
// ANYWHERE (bytes and functions held by identity), including nested
// in records/arrays/unions. isJsonSafeType rejects them, but dynFrom
@@ -4319,6 +4355,7 @@ export function canConvertToDyn(
// either its result or a promise of it.
canBoxDynComposite(a, getRecord, getUnion, visiting) ||
isDynTypedRefType(a) ||
+ textCodecRecordClass(a, getRecord) !== null ||
a.kind === "classval" ||
DYN_HANDLE_KINDS.has(a.kind) ||
(a.kind === "func" && canBoxFuncIntoDyn(a, getRecord, getUnion, visiting)) ||
@@ -4398,7 +4435,8 @@ function canBoxDynCompositeAt(
case "record": {
const shape = getRecord(t.shapeId);
if (!shape) return false;
- if (recordTextCodecClass(shape) !== null) return false;
+ // A codec boxes as its own handle wherever it sits (toDynHelper).
+ if (recordTextCodecClass(shape) !== null) return true;
// Recursive shapes answer coinductively, like isJsonSafeType.
if (visiting.has(t.shapeId)) return true;
visiting.add(t.shapeId);
@@ -4499,6 +4537,8 @@ function canDynCheckToAt(
visiting.delete(key);
return result;
}
+ // A codec handle unboxes to the record it holds.
+ if (textCodecRecordClass(t, getRecord) !== null) return true;
if (t.kind === "record") {
const key = typeKey(t);
if (visiting.has(key)) return false;
diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts
index 4ad3dcc5c..d5d2626c5 100644
--- a/packages/compiler/src/ir/runtime-features.ts
+++ b/packages/compiler/src/ir/runtime-features.ts
@@ -2,6 +2,7 @@ import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef }
import type { IrLibFn } from "./builtin-signatures.js";
import {
isDirentShape,
+ recordTextCodecClass,
RUNTIME_EMITTER_CLASS,
RUNTIME_EVENT_CLASS,
RUNTIME_EVENT_TARGET_CLASS,
@@ -116,12 +117,16 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([
"perf.measure",
// Blob and File (scr_blob.c, linked with this unit, which boxes their
// reads as dyn promises).
+ "als.bindCallbackDyn",
"als.disable",
"als.enterWith",
"als.exitRun",
"als.get",
+ "als.name",
"als.new",
+ "als.newOptions",
"als.run",
+ "als.snapshot",
"blob.arrayBuffer",
"blob.bytes",
"blob.fileNew",
@@ -415,7 +420,10 @@ interface TypeTables {
unions: Map;
classes: Map;
/** mentionsDgramSocket's carriers, computed on first use. */
- dgramCarriers?: DgramCarriers;
+ dgramCarriers?: TypeCarriers;
+ /** mentionsTextCodec's and mentionsTextDecoder's carriers. */
+ textCodecCarriers?: TypeCarriers;
+ textDecoderCarriers?: TypeCarriers;
}
/** Whether boxing a value of type `t` as dyn boxes a promise, which goes
@@ -479,30 +487,42 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean
}
}
-/** The record shapes, unions and classes whose values carry a dgram socket
- * (directly or through other members), computed once per module. */
-interface DgramCarriers {
+/** The record shapes, unions and classes whose values carry a leaf type
+ * (directly or through other members), computed once per module and leaf. */
+interface TypeCarriers {
records: Set;
unions: Set;
anyClass: boolean;
}
-/** Whether `t` carries a dgram socket, given the carrier sets so far:
- * structural through functions (parameters and result), promises and
- * collections; named records, unions and class instances by the sets. */
-function carriesDgram(t: IrType, carriers: DgramCarriers): boolean {
+/** The leaf a carrier set tracks: a dgram socket, a codec record. */
+type CarrierLeaf = (t: IrType, tables: TypeTables) => boolean;
+
+/** Whether `t` carries a leaf, given the carrier sets so far: structural
+ * through functions (parameters and result), promises and collections;
+ * named records, unions and class instances by the sets. */
+function carriesLeaf(
+ t: IrType,
+ carriers: TypeCarriers,
+ leaf: CarrierLeaf,
+ tables: TypeTables,
+): boolean {
+ if (leaf(t, tables)) return true;
switch (t.kind) {
- case "dgramSocket":
- return true;
case "func":
- return t.params.some((p) => carriesDgram(p, carriers)) || carriesDgram(t.ret, carriers);
+ return (
+ t.params.some((p) => carriesLeaf(p, carriers, leaf, tables)) ||
+ carriesLeaf(t.ret, carriers, leaf, tables)
+ );
case "promise":
- return carriesDgram(t.inner, carriers);
+ return carriesLeaf(t.inner, carriers, leaf, tables);
case "array":
case "set":
- return carriesDgram(t.elem, carriers);
+ return carriesLeaf(t.elem, carriers, leaf, tables);
case "map":
- return carriesDgram(t.key, carriers) || carriesDgram(t.value, carriers);
+ return (
+ carriesLeaf(t.key, carriers, leaf, tables) || carriesLeaf(t.value, carriers, leaf, tables)
+ );
case "record":
return carriers.records.has(t.shapeId);
case "union":
@@ -515,25 +535,29 @@ function carriesDgram(t: IrType, carriers: DgramCarriers): boolean {
}
}
-/** The module's dgram carriers: a fixpoint over its records, unions and
+/** The module's carriers of `leaf`: a fixpoint over its records, unions and
* class fields. */
-function dgramCarriers(tables: TypeTables): DgramCarriers {
- const carriers: DgramCarriers = { records: new Set(), unions: new Set(), anyClass: false };
+function leafCarriers(tables: TypeTables, leaf: CarrierLeaf): TypeCarriers {
+ const carriers: TypeCarriers = { records: new Set(), unions: new Set(), anyClass: false };
+ const carries = (t: IrType): boolean => carriesLeaf(t, carriers, leaf, tables);
for (let changed = true; changed;) {
changed = false;
for (const [id, shape] of tables.records)
- if (!carriers.records.has(id) && shape.fields.some((f) => carriesDgram(f.type, carriers))) {
+ if (
+ !carriers.records.has(id) &&
+ (leaf({ kind: "record", shapeId: id }, tables) || shape.fields.some((f) => carries(f.type)))
+ ) {
carriers.records.add(id);
changed = true;
}
for (const [id, def] of tables.unions)
- if (!carriers.unions.has(id) && def.arms.some((a) => carriesDgram(a, carriers))) {
+ if (!carriers.unions.has(id) && def.arms.some(carries)) {
carriers.unions.add(id);
changed = true;
}
if (
!carriers.anyClass &&
- [...tables.classes.values()].some((c) => c.fields.some((f) => carriesDgram(f.type, carriers)))
+ [...tables.classes.values()].some((c) => c.fields.some((f) => carries(f.type)))
) {
carriers.anyClass = true;
changed = true;
@@ -542,13 +566,42 @@ function dgramCarriers(tables: TypeTables): DgramCarriers {
return carriers;
}
+const dgramLeaf: CarrierLeaf = (t) => t.kind === "dgramSocket";
+
/** Whether converting a value of type `t` to or from dyn converts a dgram
* socket, which goes through scr_dgram_dyn.c: the socket itself, or a
* function, collection, record, union, promise or class instance that
* carries one (boxed functions convert their parameters too). */
function mentionsDgramSocket(t: IrType, tables: TypeTables): boolean {
- tables.dgramCarriers ??= dgramCarriers(tables);
- return carriesDgram(t, tables.dgramCarriers);
+ tables.dgramCarriers ??= leafCarriers(tables, dgramLeaf);
+ return carriesLeaf(t, tables.dgramCarriers, dgramLeaf, tables);
+}
+
+const textCodecLeaf: CarrierLeaf = (t, tables) => {
+ if (t.kind !== "record") return false;
+ const shape = tables.records.get(t.shapeId);
+ return shape !== undefined && recordTextCodecClass(shape) !== null;
+};
+
+const textDecoderLeaf: CarrierLeaf = (t, tables) => {
+ if (t.kind !== "record") return false;
+ const shape = tables.records.get(t.shapeId);
+ return shape !== undefined && recordTextCodecClass(shape) === "TextDecoder";
+};
+
+/** Whether converting a value of type `t` to or from dyn converts a
+ * TextEncoder or TextDecoder record, which boxes it as a handle of
+ * scr_text_codec_dyn.c (the same carrier walk as dgram sockets). */
+function mentionsTextCodec(t: IrType, tables: TypeTables): boolean {
+ tables.textCodecCarriers ??= leafCarriers(tables, textCodecLeaf);
+ return carriesLeaf(t, tables.textCodecCarriers, textCodecLeaf, tables);
+}
+
+/** The TextDecoder half: its boxed decode runs scr_text_decode_stream,
+ * which reaches the legacy decoders. */
+function mentionsTextDecoder(t: IrType, tables: TypeTables): boolean {
+ tables.textDecoderCarriers ??= leafCarriers(tables, textDecoderLeaf);
+ return carriesLeaf(t, tables.textDecoderCarriers, textDecoderLeaf, tables);
}
/** The libCall fns served by the OPTIONAL events unit (scr_events.c):
@@ -679,6 +732,7 @@ export const PKI_LIB_FNS: ReadonlySet = new Set([
"crypto.generateKeyPairSync",
"crypto.signOneshot",
"crypto.verifyOneshot",
+ "crypto.diffieHellman",
]);
/** The WebCrypto algorithms scr_webcrypto_pki.c implements over the pki
@@ -794,6 +848,15 @@ const RUNTIME_FEATURE_RULES = {
(node.kind === "dynFrom" && mentionsDgramSocket(node.value.type, tables())) ||
(node.kind === "dynCheck" && mentionsDgramSocket(node.type, tables())),
}),
+ /** A TextEncoder or TextDecoder record converts to or from an untyped
+ * value (its handle box, or a boxed function, record or collection that
+ * carries one): scr_text_codec_dyn.c. */
+ textCodecDyn: rule({
+ fns: ["text.codecIs"],
+ expr: (node, tables) =>
+ (node.kind === "dynFrom" && mentionsTextCodec(node.value.type, tables())) ||
+ (node.kind === "dynCheck" && mentionsTextCodec(node.type, tables())),
+ }),
/** node:dns/promises: scr_dns_promises.c over the dgram unit. */
dnsPromises: rule({ fns: ["dns.promisesLookup", "dns.promisesLookupService"] }),
dynAsync: rule({
@@ -942,7 +1005,12 @@ const RUNTIME_FEATURE_RULES = {
prefixes: ["insp.", "console.", "test."],
libCall: (node) => node.fn === "rl.terminal" && !isDynUndefined(node.args[3]!),
}),
- legacyTextDecoder: rule({ fns: [...LEGACY_TEXT_DECODER_LIB_FNS] }),
+ legacyTextDecoder: rule({
+ fns: [...LEGACY_TEXT_DECODER_LIB_FNS],
+ expr: (node, tables) =>
+ (node.kind === "dynFrom" && mentionsTextDecoder(node.value.type, tables())) ||
+ (node.kind === "dynCheck" && mentionsTextDecoder(node.type, tables())),
+ }),
/** The program calls mock.timers members: its timer calls go through
* scr_test.c's mock-aware entry points. */
mockTimers: rule({ prefixes: ["test.mockTimers", "test.ctxMockTimers"] }),
@@ -1056,6 +1124,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
dc: false,
dgram: false,
dgramDyn: false,
+ textCodecDyn: false,
dnsPromises: false,
dynAsync: false,
dynBuiltinClass: false,
diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts
index a404c918a..5276448bf 100644
--- a/packages/compiler/src/ir/validate.ts
+++ b/packages/compiler/src/ir/validate.ts
@@ -55,6 +55,7 @@ import {
typeEquals,
typeKey,
maybePromiseUnionArmsOk,
+ unionContainerArmsOk,
VOID,
} from "./ir.js";
import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js";
@@ -803,7 +804,6 @@ export function validateModule(mod: IrModule): IrValidationError[] {
arm.kind === "void" ||
arm.kind === "union" ||
arm.kind === "dyn" ||
- arm.kind === "date" ||
(arm.kind === "generator" && iterableArmsElem(u.arms) === null)
) {
errors.push({ message: `union ${u.id}: arm ${i} is ${arm.kind}`, loc: noLoc });
@@ -814,6 +814,9 @@ export function validateModule(mod: IrModule): IrValidationError[] {
loc: noLoc,
});
}
+ if (arm.kind === "date" && !unionContainerArmsOk(u.arms)) {
+ errors.push({ message: `union ${u.id}: date arm ${i} beside non-unit arms`, loc: noLoc });
+ }
if (arm.kind === "record" && !recordsById.has(arm.shapeId)) {
errors.push({
message: `union ${u.id}: arm ${i} references undeclared shape "${arm.shapeId}"`,
@@ -1507,6 +1510,7 @@ function validateFunction(
e.left.type.kind === "map" ||
e.left.type.kind === "set" ||
e.left.type.kind === "regex" ||
+ e.left.type.kind === "date" ||
e.left.type.kind === "url" ||
e.left.type.kind === "object" ||
e.left.type.kind === "record" ||
@@ -2372,9 +2376,13 @@ function validateFunction(
(e.method === "sortPrimitive" || e.method === "toSortedPrimitive") &&
elem.kind !== "string" &&
elem.kind !== "f64" &&
- elem.kind !== "bool"
+ elem.kind !== "bool" &&
+ elem.kind !== "date"
) {
- err(`arrIntrinsic ${e.method} requires primitive elements, got ${elem.kind}`, e.loc);
+ err(
+ `arrIntrinsic ${e.method} requires primitive or Date elements, got ${elem.kind}`,
+ e.loc,
+ );
}
if (e.method === "sortValues" || e.method === "sortDense") {
const arms = elem.kind === "union" ? unions.get(elem.unionId)?.arms : undefined;
@@ -2456,13 +2464,18 @@ function validateFunction(
elem.kind !== "f64" &&
elem.kind !== "string" &&
elem.kind !== "bool" &&
+ elem.kind !== "date" &&
!(
elem.kind === "union" &&
(unions
.get(elem.unionId)
?.arms.every(
(a) =>
- a.kind === "f64" || a.kind === "string" || a.kind === "bool" || isUnitType(a),
+ a.kind === "f64" ||
+ a.kind === "string" ||
+ a.kind === "bool" ||
+ a.kind === "date" ||
+ isUnitType(a),
) ??
false)
)
@@ -5339,6 +5352,13 @@ function validateFunction(
}
function checkLibSpecialCall(e: IrExpr & { kind: "libCall" }): boolean {
+ if (e.fn === "als.bindCallback") {
+ // The bound function, at its closure argument's own type.
+ const fn = e.args[0]?.type;
+ if (fn?.kind !== "func" || !typeEquals(e.type, fn))
+ err(`libCall ${e.fn} must return its function argument's type`, e.loc);
+ return true;
+ }
if (
e.fn === "crypto.randomFillView" ||
e.fn === "crypto.randomFillViewRest" ||
diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts
index e8fad6dd1..ac5d5e47b 100644
--- a/packages/compiler/src/library/ir-policy.ts
+++ b/packages/compiler/src/library/ir-policy.ts
@@ -63,6 +63,7 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [
["crypto.generateKeyPair", "asymmetric KeyObjects, generateKeyPair, sign and verify"],
["crypto.signOneshot", "asymmetric KeyObjects, generateKeyPair, sign and verify"],
["crypto.verifyOneshot", "asymmetric KeyObjects, generateKeyPair, sign and verify"],
+ ["crypto.diffieHellman", "crypto.diffieHellman"],
["webcrypto.", "WebCrypto (crypto.subtle)"],
["fs.watch", "fs.watch"],
["watcher.", "fs.watch"],
diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts
index 21047b929..aa46944f5 100644
--- a/packages/compiler/src/native/cli.ts
+++ b/packages/compiler/src/native/cli.ts
@@ -16,7 +16,11 @@ import { createNativeTs7Api } from "../frontend/ts7/native-api.js";
import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js";
import { setCompilerReleaseVersion } from "../library/sidecar.js";
import { NativeCompiler } from "./compiler.js";
-import { openNativeCache } from "./cache.js";
+import { NATIVE_STRING_BYTES, openNativeCache } from "./cache.js";
+import {
+ checkerExecutableIdentity,
+ setLibDiagnosticsStore,
+} from "../frontend/ts7/lib-diagnostics.js";
import { loadNativeToolchain } from "./toolchain.js";
import { buildSanitizedRuntime } from "./sanitizer.js";
import { runCompilerTask } from "./task.js";
@@ -53,6 +57,17 @@ async function main(): Promise