Merge main and give split require cycles the const form's admission

This commit is contained in:
Chris Tate
2026-10-23 17:15:33 -05:00
618 changed files with 35430 additions and 1838 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -33,6 +33,7 @@ output/
/packages/runtime-*/runtime-pack.json
/packages/runtime-*/.runtime-pack-*
!tests/fixtures/npm/node_modules/*/dist/
!tests/fixtures/npm/node_modules/@*/*/dist/
!tests/fixtures/npm/workspace/*/dist/
!tests/fixtures/npm/cases/workspace-copied/members/*/dist/
!tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/
+1
View File
@@ -12,6 +12,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` |
| `cyclic-graphs` | cpu | object graphs with back references built and dropped: trees with parent and sibling links, rings of records sharing a long-lived registry, closures capturing their owner |
| `dictionary-walks` | cpu | `Record<string, number>` dictionaries walked with for-in and `Object.keys`/`values`/`entries` while entries are added, updated and deleted |
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
@@ -0,0 +1,6 @@
{
"entry": "workloads/dynamic-methods.mjs",
"args": [
"300000"
]
}
@@ -0,0 +1,36 @@
// Untyped JavaScript calling builtin methods on values it never declares
// types for: string slice/indexOf/toUpperCase/startsWith/padStart, array
// push/pop/at/join/includes/indexOf, Map and Set get/set/has/add, number
// toFixed, and calls of an object's own methods. Argument: the number of
// iterations.
const rounds = Number(process.argv[2] ?? "300000");
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"];
const counts = new Map();
const seen = new Set();
const stack = [];
const recent = [];
const tally = {
total: 0,
add(value) {
this.total += value;
return this.total;
},
};
let checksum = 0;
for (let i = 0; i < rounds; i++) {
const word = words[i % words.length];
const head = word.slice(0, 3).toUpperCase();
const key = head.padStart(4, "-");
if (word.indexOf("e") >= 0 && !word.startsWith("z")) checksum += 1;
counts.set(key, (counts.get(key) ?? 0) + 1);
seen.add(i % 97);
if (seen.has(i % 89)) checksum += 2;
stack.push(i);
if (stack.length > 32) checksum += stack.pop() % 3;
recent.push(word);
if (recent.length > 4) recent.shift();
if (recent.includes("gamma")) checksum += recent.indexOf("gamma");
checksum += tally.add(i % 5) % 11;
if (i % 1000 === 0) checksum += Number((i / 7).toFixed(2)) | 0;
}
console.log(checksum, counts.size, seen.size, stack.at(-1), recent.join(","), tally.total);
@@ -0,0 +1,6 @@
{
"entry": "workloads/index-cursors.ts",
"args": [
"20000"
]
}
@@ -0,0 +1,25 @@
// Arrays of indices walked by loop-carried cursors: each step reads the
// next index from the array and relinks the visited entries, so the
// cursor and every stored value are small integers read back from the
// array itself.
const N = 4096;
function relink(rounds: number): number {
const next: number[] = [];
for (let i = 0; i < N; i++) next.push(i + 1 < N ? i + 1 : 0);
let at = next[0]!;
let total = 0;
for (let r = 0; r < rounds; r++) {
for (let s = 0; s < N; s++) {
const t = next[at]!;
next[at] = next[t]!;
next[t] = at;
at = t;
total = (total + at) | 0;
}
}
return total;
}
const rounds = Number(process.argv[2] ?? "1");
console.log("relink", relink(rounds));
+1 -1
View File
@@ -97,7 +97,7 @@ Coverage and `scriptc build` report the same diagnostic codes. Common ones inclu
Some code compiles but can behave differently than under Node.js. Coverage reports the cases it can detect as `SC6xxx` warnings, and `scriptc build` prints the same warnings after a successful build. They never fail a build, but `--fail-on=divergences` can fail a CI job on them.
- `SC6001`: a record passed to, stored as, asserted as, or stored as an array element of a record type with different properties is copied, and the program then writes through the copy or compares it by identity.
- `SC6002`: values of one record type are built in different key orders, so the type keeps its declaration order, an object literal's key order differs from it, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output.
- `SC6002`: values of one record type are built in key orders scriptc cannot track per value, so the type keeps its declaration order, an object literal's key order differs from it, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output.
- `SC6003`: `localeCompare` compares a string literal outside the built-in Latin-script collation.
- `SC6004`: a call passes a class instance to a function that casts it to an unrelated class.
+8 -1
View File
@@ -53,7 +53,7 @@ Type-only imports follow Node.js's type stripping. `import type` and `export typ
scriptc compiles a package automatically when it:
- ships its own type declarations, or is typed by its DefinitelyTyped `@types/` package, or is a dependency of another package that has no declarations at all, and
- ships readable JavaScript: unminified, or minified by a bundler whose helpers keep their names.
- ships JavaScript that isn't a webpack bundle. Minified JavaScript compiles like unminified JavaScript.
The declarations check your program; the package's JavaScript compiles with the types scriptc infers from it. A program that doesn't typecheck against the inferred types, for example because an `@types/` package describes another release, fails the build at that use.
@@ -92,6 +92,13 @@ scriptc implements a few packages itself and compiles that implementation in pla
- graceful-fs's retry queue, which fs-extra uses under Node.js, isn't part of the implementation, so a program that runs out of file descriptors fails at the first `EMFILE` error instead of retrying.
- An ES module that imports fs-extra can use any of its exports by name. Node.js only exposes the names its CommonJS lexer finds in fs-extra's source, so a name like `readFile` throws a `SyntaxError` there.
`@hono/node-server` 2.x is built in. `serve`, `createAdaptorServer`, `getRequestListener` and `RequestError` serve a fetch handler such as Hono's `app.fetch` over `node:http` with native `Request` and `Response` objects, and write each response the way the package does under Node.js: the same status line, header names, header order and content length, for buffered and streamed bodies, including the package's shortcut for a response that is still exactly what its constructor received. Request bodies stream to the handler as it reads them, and a body the handler leaves unread is drained after the response, up to 64 MiB or 500 ms, as the package does. Known differences:
- Only HTTP/1.1 servers are declared: the `createServer` and `serverOptions` options for `node:https` and `node:http2`, the `websocket` option and `upgradeWebSocket` aren't declared, so using one is a type error at build time. Subpath modules such as `@hono/node-server/serve-static` compile from the package's own files.
- The fetch handler must return a `Response` or a promise of one; the handler's second argument holds `incoming` and `outgoing`.
- The global `Request` and `Response` stay the built-in classes, so `overrideGlobalObjects` has no effect, and a `Response` reads its default text content type as `text/plain;charset=UTF-8` where the package's replacement reads `text/plain; charset=UTF-8`. Written responses carry the package's spelling.
- A streamed response is written without waiting for `drain`, and a failed handler answers 504 only for an error whose `name` is `TimeoutError`.
Small polyfill packages whose module is exactly a Node.js built-in on Node.js 24 compile as that built-in, at the `require()` that loads them, from your program's CommonJS files, from compiled packages, and from esbuild bundles whose module paths name the package and its version (as pnpm installs do). The package's own files aren't compiled.
<table>
@@ -8,7 +8,10 @@
`randomFill`, `randomFillSync` (every view kind and ArrayBuffer), `randomInt`, `randomUUID`, and `randomUUIDv7` follow Node 24.22, including callback timing; a `randomInt` callback whose error parameter is typed `Error | null` receives `null` where Node passes `undefined`.
Scrypt supports its default parameters, numeric options, aliases, and memory limit.
Both KDFs accept strings, Buffers, numeric typed arrays, DataViews, and fixed-length ArrayBuffers.
`createCipheriv` and `createDecipheriv` support aes-128, aes-192, and aes-256 in CBC, CTR, GCM, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede3 in CBC, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede in CBC, ECB, CFB, and OFB modes, chacha20, and chacha20-poly1305, on native targets, through `update()`, `final()`, `setAAD()`, `setAutoPadding()`, `getAuthTag()`, and `setAuthTag()`; other ciphers and wasm32-wasi builds are refused.
`createCipheriv` and `createDecipheriv` support aes-128, aes-192, and aes-256 in CBC, CTR, GCM, CCM, ECB, CFB, CFB8, CFB1, and OFB modes and as key wrap (the wrap and wrap-pad ciphers and their -inv forms), des-ede3 in CBC, ECB, CFB, CFB8, CFB1, and OFB modes, des-ede in CBC, ECB, CFB, and OFB modes, chacha20, and chacha20-poly1305, on native targets, through `update()`, `final()`, `setAAD()`, `setAutoPadding()`, `getAuthTag()`, and `setAuthTag()`; other ciphers and wasm32-wasi builds are refused.
`getCipherInfo` answers Node's info for every cipher OpenSSL names, including the ones without a native implementation.
`getCipherInfo` options whose class defines `keyLength` or `ivLength` throw a coded SC2020 error.
The result is Node's object; the SIV ciphers and the SM4 GCM, CCM, and XTS ciphers have no `nid`, and reading it where the declared `CipherInfo` type gives `number` throws a TypeError.
Cipheriv and Decipheriv values are also Transforms: they can be written, piped into, used as `pipeline()` stages, read, iterated with `for await`, and listened to, and their stream properties read as in Node; written chunks go through `update()`, and the end pushes `final()` or reports its error through `'error'`.
Through `unknown` and `any` slots and untyped JavaScript, in a program that uses streams, their stream properties and their `on`, `once`, `off`, `pipe`, `unpipe`, `read`, `pause`, `resume`, `setEncoding`, `write`, `end`, `destroy`, `cork`, and `uncork` members work; `write` and `end` take no encoding or callback there, and the other stream members throw a coded SC2020 error.
Hash, Hmac, Cipheriv, and Decipheriv streams count a written string's bytes in `writableLength`, where Node counts the string's length.
@@ -29,6 +32,8 @@
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.
`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.
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.
They support `type`, `asymmetricKeyType`, `asymmetricKeyDetails`, `export()`, `equals()`, and `instanceof KeyObject`.
@@ -18,7 +18,7 @@
Stages may take the `{ signal }` argument.
With a function destination, `pipeline()` returns an object-mode PassThrough that receives an async generator destination's values, or an async function destination's value unless it is `null` or `undefined`.
`@types/node` types that result as `NodeJS.WritableStream`, so `for await` over it needs the `PassThrough` type (`pipeline(...) as PassThrough`, or a JSDoc `@type`).
In typed code, the callback of a pipeline with an async function destination receives `null` instead of `undefined` when there is no error.
In typed code, a pipeline callback with an async function or async generator destination receives `null` for no error when its error parameter cannot hold `undefined` (@types/node declares it `NodeJS.ErrnoException | null`), and `undefined` when it can, as in Node.
`Readable.from` accepts `objectMode` and `highWaterMark` options.
The `Readable` operators (`map`, `filter`, `forEach`, `toArray`, `some`, `find`, `every`, `flatMap`, `drop`, `take`, and `reduce`) accept `concurrency`, `highWaterMark`, and `signal` options.
Their callbacks may take the `{ signal }` argument, `map` may return any value, and `flatMap` may return any iterable, async iterable, or stream.
@@ -32,9 +32,10 @@
`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.
Stream constructors accept a `signal` option in an options object literal, and `stream.addAbortSignal` accepts any stream.
The `construct` option and `_construct` method, `decodeStrings`, and `defaultEncoding` work on every stream class, and the `writev` option and `_writev` method on Writable and Duplex; `writev` on a Transform or PassThrough, assigning `_construct` after construction, and a non-literal `decodeStrings` or `defaultEncoding` in an options object literal are unsupported.
The `construct` option and `_construct` method, `decodeStrings`, and `defaultEncoding` work on every stream class, and the `writev` option and `_writev` method on every class with a writable side.
A `decodeStrings` or `defaultEncoding` value known only at run time is unsupported after an option member whose value has effects (a call or an assignment).
In typed code, a `writev` entry's `encoding` declared as a string receives `""` where Node passes `undefined` or `null` (an object-mode write).
`setDefaultEncoding()` takes any encoding Node knows.
A `transform` or `flush` option callback whose completion callback types `data` as `any`, as @types/node's `TransformCallback` does, may pass a Buffer, a string, or nothing; any other value throws a TypeError where the callback is called, instead of Node's `ERR_INVALID_ARG_TYPE` stream error.
`node:readline` and `node:readline/promises` (also reached as `readline.promises`) interfaces, from `createInterface(options)` or `createInterface(input, output, completer, terminal)`, read `process.stdin` or a readable stream: `question`, `prompt`, `write` (with a key object literal of `name`, `ctrl`, `meta`, and `shift`), `pause`, `resume`, `'line'`/`'close'`/`'pause'`/`'resume'`/`'error'`/`'history'`/`'SIGINT'`/`'SIGTSTP'`/`'SIGCONT'` listeners, and `for await` compile.
`on`, `once`, and `addListener` return the interface, so registrations chain.
The `history`, `historySize`, `removeHistoryDuplicates`, `completer`, `tabSize`, and `escapeCodeTimeout` options are validated as in Node.
@@ -42,7 +43,7 @@
The output's `'resize'` event does not redraw the line.
The cursor never stops between the two UTF-16 units of a supplementary character: where Node's Up or Down movement through a multiline line would, the cursor moves to the character's start or end.
In JavaScript, a `'history'` listener receives a copy of the history; its changes are written back when the listener returns, but the copy is not `rl.history` itself.
`rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node, except that `rl.cursor` of an interface that is not a terminal throws instead of answering `undefined`.
`rl.history`, `historySize`, `historyIndex`, `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `cursor`, and `terminal` read as in Node; `rl.cursor` is `undefined` on an interface that is not a terminal.
`rl[Symbol.dispose]()` closes the interface, and `instanceof` with `readline.Interface` or `readlinePromises.Interface` answers whether the interface came from that module, as in Node.
In JavaScript, the input and output can be any expression that evaluates to a stream, such as a function's result or an object's member; a value that is not a stream is unsupported.
In JavaScript, `question()` and `setPrompt()` accept a value that is not a string: when such a prompt is written, an interface that is not a terminal throws `ERR_INVALID_ARG_TYPE` as Node does (the `readline/promises` question rejects with it) and the question stays pending, and a terminal interface writes `String(value)`.
@@ -10,9 +10,10 @@ A type whose values are only read field by field, from fields the class does not
Object spread of a class instance is refused.
Dense array literals created directly in checked storage preserve identity, and supported native handles pass by reference.
`Object.keys`, `Object.values`, `Object.entries`, `for...in`, `JSON.stringify`, and `util.inspect` enumerate a record's keys in one order per record type rather than per object. scriptc chooses that order from the order in which the program's object literals create the keys, follows object spread, rest destructuring, and record conversions back to the values they copy, and places an optional key that the program assigns after construction after the keys the object was built with.
The order matches Node.js when every value of the type is created in the same order.
When values of one record type are created in different orders, including values of two types with the same properties and property types, or when a key is deleted and assigned again, the type keeps the declaration order of its first type instead, and `SC6002` reports object literals whose order differs where the program observes it.
`Object.keys`, `Object.values`, `Object.entries`, `for...in`, `JSON.stringify`, and `util.inspect` enumerate a record's keys in the order the object created them, as in Node.js.
scriptc chooses one order per record type from the order in which the program's object literals create the keys, follows object spread, rest destructuring, and record conversions back to the values they copy, and places an optional key that the program assigns after construction after the keys the object was built with.
When the object literals of one record type create its keys in different orders, including literals of two types with the same properties and property types, each value records the order its literal used: copies made by object spread, rest destructuring, and record conversions keep their source's order, and assigning an optional key the object lacks, including after `delete`, moves the key last.
A type keeps the declaration order of its first type, and `SC6002` reports object literals whose order differs where the program observes it, when one of its values gets its keys from a spread whose source order is not known, from a spread that also sets an optional key the source lacks, or from a copy of a value scriptc cannot trace, or when the type's values can take more than 16 orders.
A record converted from a checked value, such as `const rows: { score: number }[] = JSON.parse(text)`, keeps the properties its type does not declare.
`JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `util.inspect`, and untyped views of the record see them, together with the declared fields, in the source object's key order.
@@ -11660,9 +11660,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"
}
},
{
@@ -19332,6 +19332,10 @@
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/7152-crypto-prime.ts",
"tests/corpus/7153-crypto-prime-errors.cjs",
"tests/corpus/7154-crypto-cipher-info.cjs",
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7157-crypto-cipher-info-typed.ts",
"tests/corpus/node24-crypto-kdf-errors.ts",
"tests/corpus/node24-crypto-kdf-options.ts",
"tests/corpus/node24-crypto-kdf.ts",
@@ -19719,6 +19723,8 @@
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.createCipheriv",
"tests": [
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7150-cipher-legacy-modes.ts",
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/5250-cipher-aes-gcm.ts",
@@ -19760,6 +19766,8 @@
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.createDecipheriv",
"tests": [
"tests/corpus/7155-cipher-aes-ccm.cjs",
"tests/corpus/7156-cipher-key-wrap.cjs",
"tests/corpus/7150-cipher-legacy-modes.ts",
"tests/corpus/7151-cipher-legacy-errors.cjs",
"tests/corpus/5250-cipher-aes-gcm.ts",
@@ -20434,8 +20442,12 @@
"inherited": true
},
"static": {
"status": "refused",
"evidence": "surface-manifest:unsupported:node-builtin.crypto.getCipherInfo"
"status": "partial",
"evidence": "surface-manifest:node-builtin.crypto.getCipherInfo",
"tests": [
"tests/corpus/7154-crypto-cipher-info.cjs",
"tests/corpus/7157-crypto-cipher-info-typed.ts"
]
},
"anchorSource": "exact"
},
+3 -2
View File
@@ -44,8 +44,8 @@
"node-builtin.console": ["tests/corpus/console-imports.cjs", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/console-native-value.js", "tests/harness/npm-static.test.ts"],
"node-builtin.crypto.checkPrime": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.checkPrimeSync": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.createCipheriv": ["tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDecipheriv": ["tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createCipheriv": ["tests/corpus/7155-cipher-aes-ccm.cjs", "tests/corpus/7156-cipher-key-wrap.cjs", "tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDecipheriv": ["tests/corpus/7155-cipher-aes-ccm.cjs", "tests/corpus/7156-cipher-key-wrap.cjs", "tests/corpus/7150-cipher-legacy-modes.ts", "tests/corpus/7151-cipher-legacy-errors.cjs", "tests/corpus/5250-cipher-aes-gcm.ts", "tests/corpus/5251-cipher-aes-cbc.ts", "tests/corpus/5252-cipher-aes-ctr.ts", "tests/corpus/5253-cipher-chacha20-poly1305.ts", "tests/corpus/5254-cipher-errors.js", "tests/corpus/5255-cipher-encodings.cjs", "tests/corpus/5256-cipher-deprecation.cjs", "tests/corpus/5257-cipher-vectors.ts", "tests/corpus/5258-cipher-handles.js", "tests/corpus/5259-cipher-chunked.ts", "tests/corpus/7055-cipher-streams.ts", "tests/corpus/7056-cipher-stream-errors.ts", "tests/corpus/7057-cipher-streams.mjs", "tests/fixtures/node-types/cipher.ts"],
"node-builtin.crypto.createDiffieHellman": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.createDiffieHellmanGroup": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.createECDH": ["tests/corpus/6700-ecdh-shared-secret.ts", "tests/corpus/6701-ecdh-formats.ts", "tests/corpus/6702-ecdh-errors.js", "tests/corpus/6703-ecdh-private-keys.ts", "tests/corpus/6704-ecdh-set-public-key.cjs", "tests/corpus/6705-ecdh-handles.ts", "tests/fixtures/node-types/ecdh.ts"],
@@ -124,6 +124,7 @@
"node-builtin.fs.constants": ["tests/corpus/6207-fs-constants-object.ts", "tests/corpus/6208-fs-constants-require.cjs"],
"node-builtin.crypto.generatePrime": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.generatePrimeSync": ["tests/corpus/7152-crypto-prime.ts", "tests/corpus/7153-crypto-prime-errors.cjs"],
"node-builtin.crypto.getCipherInfo": ["tests/corpus/7154-crypto-cipher-info.cjs", "tests/corpus/7157-crypto-cipher-info-typed.ts"],
"node-builtin.crypto.getDiffieHellman": ["tests/corpus/6706-dh-groups.ts", "tests/corpus/6707-dh-explicit-params.ts", "tests/corpus/6708-dh-errors.js", "tests/corpus/6709-dh-generate.ts", "tests/corpus/6710-dh-encodings.cjs", "tests/corpus/6711-dh-handles.ts", "tests/fixtures/node-types/dh.ts"],
"node-builtin.crypto.getRandomValues": ["tests/corpus/6204-crypto-get-random-values.ts", "tests/corpus/6205-crypto-get-random-values.js"],
"node-builtin.url.parse": ["tests/corpus/5220-url-parse-warnings.ts", "tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5229-url-legacy-errors.cjs", "packages/runtime/test/url-legacy.test.ts", "tests/fixtures/node-types/url-legacy.ts"],
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v33");
expect(error?.stderr).toContain("scr_runtime_abi_v37");
});
});
+194 -9
View File
@@ -3171,13 +3171,17 @@ declare module "crypto" {
callback: (error: Error | null, buffer: Buffer) => void,
): void;
export type BinaryLike = string | ArrayBuffer | ArrayBufferView;
/* Cipheriv/Decipheriv: aes-128/192/256 in cbc, ctr, gcm, ecb, cfb, cfb8,
* cfb1, and ofb modes, des-ede3 and des-ede, chacha20, and
* chacha20-poly1305. Both are Transforms: written chunks go through
* update(), and the end pushes final(). */
/* Cipheriv/Decipheriv: aes-128/192/256 in cbc, ctr, gcm, ccm, ecb, cfb,
* cfb8, cfb1, and ofb modes and their key wraps, des-ede3 and des-ede,
* chacha20, and chacha20-poly1305. Both are Transforms: written chunks go
* through update(), and the end pushes final(). */
export type CipherCCMTypes = "aes-128-ccm" | "aes-192-ccm" | "aes-256-ccm";
export type CipherGCMTypes = "aes-128-gcm" | "aes-192-gcm" | "aes-256-gcm";
export type CipherChaCha20Poly1305Types = "chacha20-poly1305";
export type CipherKey = BinaryLike | KeyObject;
export interface CipherCCMOptions {
authTagLength: number;
}
export interface CipherGCMOptions {
authTagLength?: number;
}
@@ -3197,6 +3201,10 @@ declare module "crypto" {
final(outputEncoding: BufferEncoding): string;
setAutoPadding(autoPadding?: boolean): this;
}
export interface CipherCCM extends Cipheriv {
setAAD(buffer: ArrayBufferView, options: { plaintextLength: number }): this;
getAuthTag(): Buffer;
}
export interface CipherGCM extends Cipheriv {
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
getAuthTag(): Buffer;
@@ -3218,6 +3226,10 @@ declare module "crypto" {
final(outputEncoding: BufferEncoding): string;
setAutoPadding(autoPadding?: boolean): this;
}
export interface DecipherCCM extends Decipheriv {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options: { plaintextLength: number }): this;
}
export interface DecipherGCM extends Decipheriv {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
@@ -3226,6 +3238,12 @@ declare module "crypto" {
setAuthTag(buffer: ArrayBufferView): this;
setAAD(buffer: ArrayBufferView, options?: { plaintextLength: number }): this;
}
export function createCipheriv(
algorithm: CipherCCMTypes,
key: CipherKey,
iv: BinaryLike,
options: CipherCCMOptions,
): CipherCCM;
export function createCipheriv(
algorithm: CipherGCMTypes,
key: CipherKey,
@@ -3244,6 +3262,12 @@ declare module "crypto" {
iv: BinaryLike | null,
options?: { authTagLength?: number },
): Cipheriv;
export function createDecipheriv(
algorithm: CipherCCMTypes,
key: CipherKey,
iv: BinaryLike,
options: CipherCCMOptions,
): DecipherCCM;
export function createDecipheriv(
algorithm: CipherGCMTypes,
key: CipherKey,
@@ -3468,6 +3492,34 @@ declare module "crypto" {
* per member like http2.constants. */
export function getFips(): 1 | 0;
export function getCiphers(): string[];
export type CipherMode =
| "cbc"
| "ccm"
| "cfb"
| "ctr"
| "ecb"
| "gcm"
| "ocb"
| "ofb"
| "stream"
| "wrap"
| "xts";
export interface CipherInfoOptions {
keyLength?: number | undefined;
ivLength?: number | undefined;
}
export interface CipherInfo {
name: string;
nid: number;
blockSize?: number | undefined;
ivLength?: number | undefined;
keyLength: number;
mode: CipherMode;
}
export function getCipherInfo(
nameOrNid: string | number,
options?: CipherInfoOptions,
): CipherInfo | undefined;
export function getHashes(): string[];
export function getCurves(): string[];
/* Node v24 crypto.constants, literal-typed (the Http2Constants
@@ -5561,6 +5613,7 @@ declare module "http" {
readonly trailersDistinct: { [name: string]: string[] | undefined };
readonly rawTrailers: string[];
resume(): void;
pause(): void;
destroy(): void;
setTimeout(msecs: number, callback?: () => void): this;
/* setEncoding('utf8'): 'data' delivers strings (other real encodings
@@ -5986,6 +6039,7 @@ declare module "http2" {
export interface Http2Stream {
on(event: "error" | "close", listener: (err: Error) => void): void;
once(event: "error" | "close", listener: (err: Error) => void): void;
addListener(event: "error" | "close", listener: (err: Error) => void): void;
destroy(): void;
}
export interface Http2ServerRequest {
@@ -6086,11 +6140,6 @@ declare module "http2" {
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
on(event: "session", listener: (session: ServerHttp2Session) => void): void;
once(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
once(event: "session", listener: (session: ServerHttp2Session) => void): void;
/* HTTP CONNECT (and, in Node, the h2 extended CONNECT — which the
* allowHTTP1 lowering never sees): the second argument is the raw
* socket on HTTP/1.1 connections; narrow with instanceof. */
@@ -6108,6 +6157,42 @@ declare module "http2" {
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "close", listener: () => void): void;
once(event: "sessionError", listener: (err: Error, session: ServerHttp2Session) => void): void;
once(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
once(event: "session", listener: (session: ServerHttp2Session) => void): void;
once(
event: "connect",
listener: (
req: Http2ServerRequest | import("http").IncomingMessage,
resOrSocket: Http2ServerResponse | Socket,
) => void,
): void;
addListener(event: "connection", listener: (socket: Socket) => void): void;
addListener(
event: "request",
listener: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(event: "close", listener: () => void): void;
addListener(
event: "sessionError",
listener: (err: Error, session: ServerHttp2Session) => void,
): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "session", listener: (session: ServerHttp2Session) => void): void;
addListener(
event: "connect",
listener: (
req: Http2ServerRequest | import("http").IncomingMessage,
resOrSocket: Http2ServerResponse | Socket,
) => void,
): void;
}
export interface SecureServerOptions {
allowHTTP1?: boolean;
@@ -6449,6 +6534,28 @@ declare module "http2" {
listener: (errorCode: number, lastStreamID: number, opaqueData?: Uint8Array) => void,
): void;
once(event: "localSettings" | "remoteSettings", listener: (settings: any) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "ping", listener: (payload: Uint8Array) => void): void;
addListener(event: "close" | "timeout", listener: () => void): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "connect",
listener: (session: Http2Session, socket: import("net").Socket) => void,
): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "goaway",
listener: (errorCode: number, lastStreamID: number, opaqueData?: Uint8Array) => void,
): void;
addListener(event: "localSettings" | "remoteSettings", listener: (settings: any) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(event: "ping", listener: (payload: Uint8Array) => void): void;
}
export interface ClientHttp2Session extends Http2Session {
@@ -6526,7 +6633,30 @@ declare module "http2" {
listener: () => void,
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
addListener(event: "data", listener: (chunk: any) => void): void;
addListener(
event:
| "end"
| "close"
| "aborted"
| "ready"
| "timeout"
| "drain"
| "finish"
| "wantTrailers",
listener: () => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(
event: "trailers",
listener: (trailers: IncomingHttpHeaders, flags: number) => void,
): void;
respond(
headers?: OutgoingHttpHeaders,
options?: { endStream?: boolean; waitForTrailers?: boolean },
@@ -6576,6 +6706,7 @@ declare module "http2" {
on(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "response", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "push", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "headers", listener: (headers: IncomingHttpHeaders, flags: number) => void): void;
once(event: "data", listener: (chunk: any) => void): void;
once(
event:
@@ -6590,6 +6721,42 @@ declare module "http2" {
listener: () => void,
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "frameError", listener: (type: number, code: number, id: number) => void): void;
once(event: "trailers", listener: (trailers: IncomingHttpHeaders, flags: number) => void): void;
addListener(
event: "response",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "push",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(
event: "headers",
listener: (headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "data", listener: (chunk: any) => void): void;
addListener(
event:
| "end"
| "close"
| "aborted"
| "ready"
| "timeout"
| "drain"
| "finish"
| "wantTrailers",
listener: () => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(
event: "frameError",
listener: (type: number, code: number, id: number) => void,
): void;
addListener(
event: "trailers",
listener: (trailers: IncomingHttpHeaders, flags: number) => void,
): void;
}
export interface Http2Server {
@@ -6624,6 +6791,24 @@ declare module "http2" {
): void;
once(event: "error", listener: (err: Error) => void): void;
once(event: "close" | "listening" | "timeout", listener: () => void): void;
once(event: "connection", listener: (socket: import("net").Socket) => void): void;
once(event: "sessionError", listener: (err: Error, session: ServerHttp2Session) => void): void;
addListener(
event: "stream",
listener: (stream: ServerHttp2Stream, headers: IncomingHttpHeaders, flags: number) => void,
): void;
addListener(event: "session", listener: (session: ServerHttp2Session) => void): void;
addListener(
event: "request",
listener: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
): void;
addListener(event: "error", listener: (err: Error) => void): void;
addListener(event: "close" | "listening" | "timeout", listener: () => void): void;
addListener(event: "connection", listener: (socket: import("net").Socket) => void): void;
addListener(
event: "sessionError",
listener: (err: Error, session: ServerHttp2Session) => void,
): void;
}
export interface ServerOptions {
Binary file not shown.
+4
View File
@@ -1,6 +1,10 @@
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
/* flock and LOCK_EX/LOCK_UN, which Darwin hides under _POSIX_C_SOURCE. */
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE 1
#endif
#include <stdint.h>
#include <limits.h>
#include <stddef.h>
@@ -0,0 +1,645 @@
// scriptc's built-in implementation of `@hono/node-server` 2.x: the Node.js
// adapter that serves a fetch-style handler (Hono's `app.fetch`) over
// node:http. The published package swaps the global Request and Response
// for lightweight objects and reparents prototypes, which only pays off
// under V8; this port builds native Request objects from the incoming
// message, with a body streamed from it as the app reads it, and writes
// the handler's Response the way the package's listener does (header
// record, content length for bodies that complete within the first reads,
// streamed bodies, error responses), so a client sees the same status
// lines, headers and bodies. A body the app leaves unread is drained after
// the response, within the package's limits.
//
// HTTP/1.1 only: the http2/https server options and WebSocket upgrades are
// not declared. A Response that is still exactly its constructor arguments
// takes the package's cached-response path (its lightweight Response), read
// from the runtime through scriptcResponseLight; every other Response takes
// the general path.
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
export type HttpBindings = {
incoming: IncomingMessage;
outgoing: ServerResponse;
};
export type ServerType = Server;
export type FetchCallback = (request: Request, env: HttpBindings) => Response | Promise<Response>;
export type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>;
export type Options = {
fetch: FetchCallback;
overrideGlobalObjects?: boolean;
autoCleanupIncoming?: boolean;
port?: number;
hostname?: string;
};
export interface RequestListenerOptions {
hostname?: string;
errorHandler?: CustomErrorHandler;
overrideGlobalObjects?: boolean;
autoCleanupIncoming?: boolean;
}
export class RequestError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "RequestError";
}
}
const DEFAULT_CONTENT_TYPE = "text/plain; charset=UTF-8";
const X_ALREADY_SENT = "x-hono-already-sent";
const reValidRequestUrl = /^\/[!#$&-;=?-[\]_a-z~]*$/;
const reDotSegment = /\/\.\.?(?:[/?#]|$)/;
const reValidHost = /^[a-z0-9._-]+(?::(?:[1-5]\d{3,4}|[6-9]\d{3}))?$/;
function buildUrl(scheme: string, host: string, incomingUrl: string): string {
const url = `${scheme}://${host}${incomingUrl}`;
if (!reValidHost.test(host)) {
const urlObj = new URL(url);
if (
urlObj.hostname.length !== host.length &&
urlObj.hostname !== (host.includes(":") ? host.replace(/:\d+$/, "") : host).toLowerCase()
)
throw new RequestError("Invalid host header");
return urlObj.href;
}
if (incomingUrl.length === 0) return url + "/";
if (incomingUrl.charCodeAt(0) !== 47) throw new RequestError("Invalid URL");
if (!reValidRequestUrl.test(incomingUrl) || reDotSegment.test(incomingUrl))
return new URL(url).href;
return url;
}
function normalizeIncomingMethod(method: string | undefined): string {
if (method === undefined || method.length === 0) return "GET";
switch (method) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "PATCH":
case "POST":
case "PUT":
case "QUERY":
return method;
}
const upper = method.toUpperCase();
switch (upper) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "POST":
case "PUT":
return upper;
default:
return method;
}
}
/** The request URL the package builds: an absolute request target as is,
* otherwise the Host header (or the configured hostname) over http. */
function requestUrl(incoming: IncomingMessage, defaultHostname: string | undefined): string {
const incomingUrl = incoming.url ?? "";
if (
incomingUrl[0] !== "/" &&
(incomingUrl.startsWith("http://") || incomingUrl.startsWith("https://"))
) {
try {
return new URL(incomingUrl).href;
} catch (e) {
throw new RequestError("Invalid absolute URL", { cause: e });
}
}
const hostHeader = incoming.headers.host;
const host = hostHeader !== undefined && hostHeader !== "" ? hostHeader : (defaultHostname ?? "");
if (host === "") throw new RequestError("Missing host header");
try {
return buildUrl("http", host, incomingUrl);
} catch (e) {
if (e instanceof RequestError) throw e;
throw new RequestError("Invalid URL", { cause: e });
}
}
function requestHeaders(incoming: IncomingMessage): Headers {
const headers = new Headers();
const raw = incoming.rawHeaders;
for (let i = 0; i < raw.length; i += 2) {
const name = raw[i]!;
if (!name.startsWith(":")) headers.append(name, raw[i + 1]!);
}
return headers;
}
/** What the listener knows about the incoming body: whether it ended
* (the package's `readableEnded`) and whether a drain started. */
type IncomingState = { ended: boolean; draining: boolean };
/** The incoming message's readableHighWaterMark: Readable.toWeb queues
* this many bytes before it pauses the message. */
const BODY_HIGH_WATER_MARK = 64 * 1024;
/** The request body as the package streams it: Readable.toWeb over the
* incoming message, read one chunk per pull. Chunks queue until
* BODY_HIGH_WATER_MARK bytes wait unread, then the message pauses until a
* pull takes them, so the socket holds the rest. The stream pulls once on
* its own when it is constructed; the package builds its stream only when
* the app reads the body, so until the app's first read a drain discards
* the chunks instead of queueing them. */
function incomingBody(incoming: IncomingMessage, state: IncomingState): ReadableStream<Uint8Array> {
let pulls = 0;
let listening = false;
let ended = false;
let failure: Error | undefined = undefined;
const queue: Buffer[] = [];
let queued = 0;
let wake: (() => void) | undefined = undefined;
const woken = (): void => {
const resolve = wake;
wake = undefined;
if (resolve !== undefined) resolve();
};
const listen = (): void => {
listening = true;
incoming.on("data", (chunk: Buffer) => {
if (state.draining && pulls === 1) return;
queue.push(chunk);
queued += chunk.length;
if (queued >= BODY_HIGH_WATER_MARK) incoming.pause();
woken();
});
incoming.on("end", () => {
ended = true;
woken();
});
incoming.on("error", (err: Error) => {
failure = err;
woken();
});
};
return new ReadableStream<Uint8Array>({
async pull(controller) {
pulls++;
if (!listening) listen();
for (;;) {
const chunk = queue.shift();
if (chunk !== undefined) {
queued -= chunk.length;
if (queued < BODY_HIGH_WATER_MARK) incoming.resume();
controller.enqueue(chunk);
return;
}
if (failure !== undefined) {
controller.error(failure);
return;
}
if (ended) {
controller.close();
return;
}
await new Promise<void>((resolve) => {
wake = resolve;
});
}
},
});
}
function newRequest(
incoming: IncomingMessage,
defaultHostname: string | undefined,
state: IncomingState,
): Request {
const method = normalizeIncomingMethod(incoming.method);
const url = requestUrl(incoming, defaultHostname);
const headers = requestHeaders(incoming);
if (method === "GET" || method === "HEAD") return new Request(url, { method, headers });
return new Request(url, {
method,
headers,
body: incomingBody(incoming, state),
duplex: "half",
});
}
const DRAIN_TIMEOUT_MS = 500;
const MAX_DRAIN_BYTES = 64 * 1024 * 1024;
/** The package's drain of a body the app left unread: read and discard
* up to MAX_DRAIN_BYTES or DRAIN_TIMEOUT_MS, then close the connection
* once its writes are out. */
function drainIncoming(incoming: IncomingMessage, state: IncomingState): void {
const socket = incoming.socket;
if (socket.destroyed || state.draining) return;
state.draining = true;
let bytesRead = 0;
let done = false;
const cleanup = (): void => {
if (done) return;
done = true;
clearTimeout(timer);
};
const forceClose = (): void => {
if (done) return;
cleanup();
if (!socket.destroyed) socket.destroySoon();
};
const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS);
timer.unref();
incoming.on("data", (chunk: Buffer) => {
if (done) return;
bytesRead += chunk.length;
if (bytesRead > MAX_DRAIN_BYTES) forceClose();
});
incoming.on("end", cleanup);
incoming.on("error", cleanup);
incoming.resume();
}
/** The package's close handler cleanup: an unread body drains two timer
* turns after the response closes. */
function drainAfterClose(incoming: IncomingMessage, state: IncomingState): void {
if (state.ended) return;
setTimeout(() => {
if (!state.ended)
setTimeout(() => {
drainIncoming(incoming, state);
});
});
}
/** The package's outgoing header record: Headers order (lowercase,
* sorted), Set-Cookie values gathered into one array entry at the end,
* and the default content type for a response with a body. */
function buildOutgoingHttpHeaders(
headers: Headers,
hasBody: boolean,
): Record<string, string | string[]> {
const res: Record<string, string | string[]> = {};
if (headers.has("set-cookie")) {
const cookies: string[] = [];
headers.forEach((v, k) => {
if (k === "set-cookie") cookies.push(v);
else res[k] = v;
});
// Headers.forEach joins nothing but set-cookie, which getSetCookie
// lists value by value, as the package's iteration does.
if (cookies.length > 0) res["set-cookie"] = headers.getSetCookie();
} else {
headers.forEach((v, k) => {
res[k] = v;
});
}
if (hasBody && res["content-type"] === undefined) res["content-type"] = DEFAULT_CONTENT_TYPE;
return res;
}
/* Spelled out rather than ReadableStreamReadResult<Uint8Array>, whose
* declaration depends on the program's lib and @types/node settings: the
* shim compiles inside every program that imports the package. */
type ReadResult = { done: boolean; value?: Uint8Array };
function readWithoutBlocking(read: Promise<ReadResult>): Promise<ReadResult | undefined> {
return Promise.race([read, Promise.resolve().then(() => Promise.resolve(undefined))]);
}
function nextTick(): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
/** Writes the rest of a body stream. As in the package, a response that
* closes before the stream ends (the client disconnected) cancels the
* stream, so its producer stops; the pending read then answers done. A
* response that fails closes too, and its stream is cancelled the same way
* (the package passes the error as the reason). The listener stays attached
* and does nothing once the stream has ended. */
async function pump(
reader: ReadableStreamDefaultReader<Uint8Array>,
outgoing: ServerResponse,
pending: Promise<ReadResult> | undefined,
): Promise<void> {
let finished = false;
outgoing.on("close", () => {
if (!finished) reader.cancel().catch(() => {});
});
try {
let read = pending ?? reader.read();
for (;;) {
const { done, value } = await read;
if (done) {
outgoing.end();
return;
}
outgoing.write(value!);
read = reader.read();
}
} finally {
finished = true;
}
}
function handleResponseError(e: unknown, outgoing: ServerResponse): void {
const err = e instanceof Error ? e : new Error("unknown error", { cause: e });
console.error(e);
if (!outgoing.headersSent) outgoing.writeHead(500, { "Content-Type": "text/plain" });
outgoing.end(`Error: ${err.message}`);
outgoing.destroy();
}
function emptyResponse(status: number): Response {
return new Response(null, { status });
}
/** The lightweight state of a constructed Response (the runtime's
* scr_fetch_response_light): [form, flags, byte length, ...record pairs]. */
declare function scriptcResponseLight(res: Response): string[];
const UNDICI_TEXT_TYPE = "text/plain;charset=UTF-8";
/** Content-Length as Node's implicit header: set the headers one by one and
* let end(body) compute the length, which Node then writes after its own
* Date/Connection headers, as the package's preset length does. */
function endWithImplicitLength(
outgoing: ServerResponse,
status: number,
header: Record<string, string | string[]>,
body: Uint8Array,
): void {
outgoing.statusCode = status;
for (const name of Object.keys(header)) outgoing.setHeader(name, header[name]!);
outgoing.end(body);
}
/** The package's cached-response path: a Response that is still exactly
* its constructor arguments (no body or metadata read since) is written
* from them. Form 1 has no init headers, form 2 Headers, form 3 a plain
* record kept verbatim. */
async function writeLightResponse(
res: Response,
outgoing: ServerResponse,
light: string[],
head: boolean,
): Promise<void> {
const form = light[0]!;
const flags = light[1]!;
const length = Number(light[2]!);
const status = res.status;
const sized = length >= 0;
if (form === "1") {
if (length === -1) {
outgoing.writeHead(status);
outgoing.end();
} else if (sized) {
const body = await res.bytes();
if (head) {
outgoing.writeHead(status, {
"Content-Type": DEFAULT_CONTENT_TYPE,
"Content-Length": String(length),
});
outgoing.end(body);
} else
endWithImplicitLength(outgoing, status, { "Content-Type": DEFAULT_CONTENT_TYPE }, body);
} else {
outgoing.writeHead(status, { "Content-Type": DEFAULT_CONTENT_TYPE });
outgoing.flushHeaders();
await pump(res.body!.getReader(), outgoing, undefined);
}
return;
}
let header: Record<string, string | string[]>;
let hasContentLength = false;
let plain = false;
let canAutoLength = true;
if (form === "2") {
const headers = new Headers(res.headers);
if (flags.includes("a") && headers.get("content-type") === UNDICI_TEXT_TYPE) {
if (flags.includes("d")) headers.set("content-type", DEFAULT_CONTENT_TYPE);
else headers.delete("content-type");
} else if (flags.includes("d") && !headers.has("content-type")) {
headers.set("content-type", DEFAULT_CONTENT_TYPE);
}
hasContentLength = headers.has("content-length");
header = buildOutgoingHttpHeaders(headers, length !== -1);
} else {
plain = true;
header = {};
for (let i = 3; i + 1 < light.length; i += 2) {
const name = light[i]!;
const value = light[i + 1]!;
const previous = header[name];
if (previous === undefined) header[name] = value;
else if (typeof previous === "string") header[name] = [previous, value];
else previous.push(value);
const lower = name.toLowerCase();
if (lower === "content-length") hasContentLength = true;
else if (lower === "transfer-encoding" || lower === "trailer") canAutoLength = false;
}
}
const body = sized ? await res.bytes() : undefined;
if (!hasContentLength && body !== undefined) {
if (plain && canAutoLength && !head && status !== 204 && status !== 304) {
endWithImplicitLength(outgoing, status, header, body);
return;
}
header["Content-Length"] = String(length);
}
outgoing.writeHead(status, header);
if (length === -1) outgoing.end();
else if (body !== undefined) outgoing.end(body);
else {
outgoing.flushHeaders();
await pump(res.body!.getReader(), outgoing, undefined);
}
}
async function writeResponse(
res: Response,
outgoing: ServerResponse,
head: boolean,
): Promise<void> {
const light = scriptcResponseLight(res);
if (light[0] !== "0") return writeLightResponse(res, outgoing, light, head);
const body = res.body;
const record = buildOutgoingHttpHeaders(res.headers, body !== null);
if (body !== null) {
const reader: ReadableStreamDefaultReader<Uint8Array> = body.getReader();
const values: Uint8Array[] = [];
let done = false;
let pending: Promise<ReadResult> | undefined = undefined;
if (record["transfer-encoding"] !== "chunked") {
let maxReadCount = 2;
for (let i = 0; i < maxReadCount; i++) {
pending ??= reader.read();
let chunk: ReadResult | undefined = undefined;
try {
chunk = await readWithoutBlocking(pending);
} catch (e) {
console.error(e);
done = true;
}
if (chunk === undefined) {
if (i === 1 && !done) {
await nextTick();
maxReadCount = 3;
continue;
}
break;
}
pending = undefined;
if (chunk.value !== undefined) values.push(chunk.value);
if (chunk.done) {
done = true;
break;
}
}
if (done && record["content-length"] === undefined) {
let length = 0;
for (const value of values) length += value.length;
record["content-length"] = String(length);
}
}
outgoing.writeHead(res.status, record);
for (const value of values) outgoing.write(value);
if (done) outgoing.end();
else {
if (values.length === 0) outgoing.flushHeaders();
await pump(reader, outgoing, pending);
}
} else if (record[X_ALREADY_SENT] === undefined) {
outgoing.writeHead(res.status, record);
outgoing.end();
}
}
async function respond(
pending: Response | Promise<Response>,
outgoing: ServerResponse,
errorHandler: CustomErrorHandler | undefined,
head: boolean,
): Promise<void> {
let res: Response;
if (pending instanceof Promise) {
try {
res = await pending;
} catch (err) {
if (errorHandler === undefined) {
res = handleFetchError(err);
} else {
const errRes = await errorHandler(err);
if (errRes === undefined) return;
res = errRes;
}
}
} else res = pending;
await writeResponse(res, outgoing, head);
}
/** A Response the package writes from its cache without waiting: still
* exactly its constructor arguments, with no body or a string or bytes
* body. */
function immediateCacheable(res: Response): boolean {
const light = scriptcResponseLight(res);
return light[0] !== "0" && Number(light[2]!) >= -1;
}
function handleFetchError(e: unknown): Response {
return emptyResponse(e instanceof Error && e.name === "TimeoutError" ? 504 : 500);
}
async function handleRequest(
fetchCallback: FetchCallback,
options: RequestListenerOptions,
incoming: IncomingMessage,
outgoing: ServerResponse,
): Promise<void> {
let req: Request | undefined = undefined;
let res: Response | Promise<Response> | undefined = undefined;
const state: IncomingState = { ended: false, draining: false };
let needsBodyCleanup = false;
let closeHandlerAttached = false;
const ensureCloseHandler = (): void => {
if (req === undefined || closeHandlerAttached) return;
closeHandlerAttached = true;
if (needsBodyCleanup)
outgoing.on("close", () => {
drainAfterClose(incoming, state);
});
};
try {
req = newRequest(incoming, options.hostname, state);
needsBodyCleanup =
options.autoCleanupIncoming !== false &&
!(incoming.method === "GET" || incoming.method === "HEAD");
if (needsBodyCleanup)
incoming.on("end", () => {
state.ended = true;
});
res = fetchCallback(req, { incoming, outgoing });
if (!(res instanceof Promise) && immediateCacheable(res)) {
if (needsBodyCleanup)
outgoing.once("finish", () => {
if (!state.ended) drainIncoming(incoming, state);
});
} else ensureCloseHandler();
} catch (e) {
if (res !== undefined) return handleResponseError(e, outgoing);
const errorHandler = options.errorHandler;
if (errorHandler !== undefined) {
ensureCloseHandler();
const handled = await errorHandler(
req !== undefined ? e : e instanceof RequestError ? e : toRequestError(e),
);
if (handled === undefined) return;
res = handled;
} else if (req === undefined) res = emptyResponse(400);
else res = handleFetchError(e);
}
try {
await respond(res, outgoing, options.errorHandler, incoming.method === "HEAD");
} catch (e) {
handleResponseError(e, outgoing);
}
}
function toRequestError(e: unknown): RequestError {
return new RequestError(e instanceof Error ? e.message : String(e), { cause: e });
}
export function getRequestListener(
fetchCallback: FetchCallback,
options: RequestListenerOptions = {},
): (incoming: IncomingMessage, outgoing: ServerResponse) => Promise<void> {
return (incoming, outgoing) => handleRequest(fetchCallback, options, incoming, outgoing);
}
export function createAdaptorServer(options: Options): ServerType {
const listener = getRequestListener(options.fetch, {
hostname: options.hostname,
overrideGlobalObjects: options.overrideGlobalObjects,
autoCleanupIncoming: options.autoCleanupIncoming,
});
return createServer((incoming, outgoing) => {
void listener(incoming, outgoing);
});
}
export function serve(
options: Options,
listeningListener?: (info: AddressInfo) => void,
): ServerType {
const server = createAdaptorServer(options);
server.listen(options.port ?? 3000, options.hostname, () => {
const info = server.address() as AddressInfo;
if (listeningListener !== undefined) listeningListener(info);
});
return server;
}
@@ -28,6 +28,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.dynTier ? { dynTier: true } : {}),
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.declAdopt ? { declAdopt: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
+26 -24
View File
@@ -3,6 +3,7 @@
import type { DynamicFieldStores } from "../ir/int32-slots.js";
import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js";
import {
computeCycleMutability,
constructorEscapePoint,
@@ -97,7 +98,10 @@ export interface ConstructionFacts {
privateStores: Set<IrStmt>;
}
export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
/** The nodes that can reach a constructor with a reused receiver. */
const CONSTRUCTOR_USE_KINDS: readonly IrNodeKind[] = ["call", "closure"];
export function computeConstructionFacts(mod: IrModule, nodes?: IrNodeIndex): ConstructionFacts {
const facts: ConstructionFacts = { privateStores: new Set() };
if (mod.lib !== undefined) return facts;
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const));
@@ -122,6 +126,7 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
c = classes.get(c)!.base;
}
};
const scan = nodes?.scan(CONSTRUCTOR_USE_KINDS);
for (const fn of mod.functions) {
const own = ctorClass.get(fn.name);
const thisId = own !== undefined ? thisOf(own) : null;
@@ -129,22 +134,19 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
own !== undefined && classes.get(own)!.base !== undefined
? `%${classes.get(own)!.base}.constructor`
: null;
const expr = (e: IrExpr): boolean => {
if (e.kind === "call") {
const target = ctorClass.get(e.callee);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") {
const target = ctorClass.get(node.callee);
if (
target !== undefined &&
!(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
!(node.callee === baseCtor && node.args.length > 0 && isThisRef(node.args[0]!, thisId))
)
markReused(target);
} else if (e.kind === "closure") {
const target = ctorClass.get(e.fnName);
} else if (node.kind === "closure") {
const target = ctorClass.get(node.fnName);
if (target !== undefined) markReused(target);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt);
fn.body.every(stmt);
});
}
// The first escaping statement of each constructor (body length: none).
@@ -192,32 +194,32 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
return facts;
}
/** The nodes that write a binding: statement and expression assignments. */
const ASSIGNMENT_KINDS: readonly IrNodeKind[] = ["assign", "assignExpr"];
/** Module constants whose value never changes once initialized: immutable,
* not TDZ-checked (every read follows the initialization), no import-cycle
* init flag, and at most one assignment in the whole module. Such a global
* owns its value for the rest of the program, so a read of it can be
* borrowed across any later operand or call. */
export function stableGlobals(mod: IrModule): Set<string> {
export function stableGlobals(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const candidates = new Set(
(mod.globals ?? [])
.filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
.map((g) => g.id),
);
const assigned = new Set<string>();
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "assign" && candidates.has(s.localId)) {
if (assigned.has(s.localId)) candidates.delete(s.localId);
assigned.add(s.localId);
}
return everyStmtChild(s, expr, stmt);
};
const scan = nodes?.scan(ASSIGNMENT_KINDS);
for (const fn of mod.functions) {
if (candidates.size === 0) break;
fn.body.every(stmt);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "assignExpr" && candidates.has(node.localId))
candidates.delete(node.localId);
else if (node.kind === "assign" && candidates.has(node.localId)) {
if (assigned.has(node.localId)) candidates.delete(node.localId);
assigned.add(node.localId);
}
});
}
return candidates;
}
@@ -9,8 +9,8 @@
* one directly or through a binding read from it. The choice never changes
* behavior. */
import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { IrExpr, IrModule } from "../../ir/ir.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
const APPENDING_METHODS: ReadonlySet<string> = new Set([
"push",
@@ -30,7 +30,27 @@ function isEmptyLiteral(e: IrExpr): boolean {
);
}
export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr> {
/** The nodes that name an appended binding or field, and the stores that
* initialize one. */
const TARGET_KINDS: readonly IrNodeKind[] = [
"arrIntrinsic",
"assignExpr",
"arraySet",
"varDecl",
"assign",
];
const STORE_KINDS: readonly IrNodeKind[] = [
"assignExpr",
"varDecl",
"assign",
"fieldSet",
"recordSet",
];
export function findAppendedArrayLiterals(
module: IrModule,
index?: IrNodeIndex,
): ReadonlySet<IrExpr> {
// Bindings are function-scoped by id; module globals are shared.
const localsOf = new Map<string, ReadonlySet<string>>();
for (const fn of module.functions) {
@@ -65,20 +85,15 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr>
const field = fieldOf(target);
if (field !== null) appendedFields.add(field);
};
const targets = index?.scan(TARGET_KINDS);
for (const fn of module.functions) {
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "arrIntrinsic" && APPENDING_METHODS.has(e.method))
noteTarget(fn.name, e.receiver);
else if (e.kind === "assignExpr") noteAlias(fn.name, e.localId, e.value);
return true;
},
stmt: (s) => {
if (s.kind === "arraySet") noteTarget(fn.name, s.arr);
else if (s.kind === "varDecl") noteAlias(fn.name, s.localId, s.init);
else if (s.kind === "assign") noteAlias(fn.name, s.localId, s.value);
return true;
},
eachNodeOfKinds(fn, targets, (node) => {
if (node.kind === "arrIntrinsic" && APPENDING_METHODS.has(node.method))
noteTarget(fn.name, node.receiver);
else if (node.kind === "assignExpr") noteAlias(fn.name, node.localId, node.value);
else if (node.kind === "arraySet") noteTarget(fn.name, node.arr);
else if (node.kind === "varDecl") noteAlias(fn.name, node.localId, node.init);
else if (node.kind === "assign") noteAlias(fn.name, node.localId, node.value);
});
}
for (const binding of appendedBindings)
@@ -89,23 +104,18 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr>
const consider = (value: IrExpr | null, appended: boolean): void => {
if (value && appended && isEmptyLiteral(value)) result.add(value);
};
const stores = index?.scan(STORE_KINDS);
for (const fn of module.functions) {
const binding = (localId: string): boolean =>
appendedBindings.has(bindingKey(fn.name, localId));
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "assignExpr") consider(e.value, binding(e.localId));
return true;
},
stmt: (s: IrStmt) => {
if (s.kind === "varDecl") consider(s.init, binding(s.localId));
else if (s.kind === "assign") consider(s.value, binding(s.localId));
else if (s.kind === "fieldSet")
consider(s.value, appendedFields.has(fieldKey(s.className, s.field)));
else if (s.kind === "recordSet")
consider(s.value, appendedFields.has(fieldKey(s.shapeId, s.field)));
return true;
},
eachNodeOfKinds(fn, stores, (node) => {
if (node.kind === "assignExpr") consider(node.value, binding(node.localId));
else if (node.kind === "varDecl") consider(node.init, binding(node.localId));
else if (node.kind === "assign") consider(node.value, binding(node.localId));
else if (node.kind === "fieldSet")
consider(node.value, appendedFields.has(fieldKey(node.className, node.field)));
else if (node.kind === "recordSet")
consider(node.value, appendedFields.has(fieldKey(node.shapeId, node.field)));
});
}
return result;
@@ -5,6 +5,8 @@
export function preservesDynTest(test: string): boolean {
switch (test) {
case "truthy":
case "classCapsule":
case "funcKind":
case "nullish":
case "promise":
case "buffer":
@@ -1,9 +1,28 @@
import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js";
import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js";
import {
exprFunctionReference,
FUNCTION_REFERENCE_KINDS,
moduleFunctionReferences,
} from "../../ir/function-references.js";
import { writtenLocals } from "../../ir/callback-specialization.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
/** The nodes the body census below reads. */
const CENSUS_KINDS: readonly IrNodeKind[] = [
"varDecl",
"assign",
"forOf",
"rethrow",
"tryCatch",
"call",
"callValue",
"assignExpr",
"incDec",
...FUNCTION_REFERENCE_KINDS,
];
/** Closed direct-call consumers can devirtualize a callback when every
* incoming call supplies the same function. Keep its evaluated environment,
* arguments and ordinary ownership; only the loaded code pointer changes.
@@ -15,6 +34,7 @@ import type { CallLifetimes } from "./call-lifetimes.js";
export function findConstantCallbacks(
mod: IrModule,
lifetimes: CallLifetimes,
index?: IrNodeIndex,
): Map<string, Map<string, string>> {
const functions = new Map(mod.functions.map((fn) => [fn.name, fn]));
const definitions = new Map<string, Map<string, IrExpr>>();
@@ -50,7 +70,7 @@ export function findConstantCallbacks(
const writes = new Set<string>();
// Parameters and captures each function (or a closure sharing the
// binding) writes after entry.
const rebinds = writtenLocals(mod);
const rebinds = writtenLocals(mod, index);
// A function named anywhere but as a direct call's callee can be entered
// by a generated adapter or the runtime, which a direct-call census cannot
// specialize: the entry, exports, class helpers and methods here, and
@@ -66,6 +86,7 @@ export function findConstantCallbacks(
// The initializer of each immutable function-typed local declared once:
// its only write, even when a capture box (not a stable slot) holds it.
const constDefinitions = new Map<string, Map<string, IrExpr | null>>();
const scan = index?.scan(CENSUS_KINDS);
for (const fn of mod.functions) {
const defs = new Map<string, IrExpr>();
const consts = new Map<string, IrExpr | null>();
@@ -85,51 +106,64 @@ export function findConstantCallbacks(
const emptyDecls = new Map<string, number>();
const assigned = new Map<string, IrExpr[]>();
const rebound = new Set<string>();
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "varDecl" && stmt.init?.type.kind === "func" && stable?.has(stmt.localId))
defs.set(stmt.localId, stmt.init);
if (stmt.kind === "varDecl" && immutable.has(stmt.localId))
consts.set(stmt.localId, consts.has(stmt.localId) ? null : stmt.init);
if (stmt.kind === "varDecl" && hoisted.has(stmt.localId)) {
if (stmt.init !== null) rebound.add(stmt.localId);
emptyDecls.set(stmt.localId, (emptyDecls.get(stmt.localId) ?? 0) + 1);
}
if (stmt.kind === "assign" && hoisted.has(stmt.localId)) {
const values = assigned.get(stmt.localId) ?? [];
values.push(stmt.value);
assigned.set(stmt.localId, values);
}
if (stmt.kind === "forOf" || stmt.kind === "rethrow") rebound.add(stmt.localId);
if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) rebound.add(stmt.catchLocalId);
if (stmt.kind === "assign") {
if (globalIds.has(stmt.localId)) {
if (globals.has(stmt.localId)) writes.add(stmt.localId);
else globals.set(stmt.localId, stmt.value);
eachNodeOfKinds(fn, scan, (node) => {
switch (node.kind) {
case "varDecl":
if (node.init?.type.kind === "func" && stable?.has(node.localId))
defs.set(node.localId, node.init);
if (immutable.has(node.localId))
consts.set(node.localId, consts.has(node.localId) ? null : node.init);
if (hoisted.has(node.localId)) {
if (node.init !== null) rebound.add(node.localId);
emptyDecls.set(node.localId, (emptyDecls.get(node.localId) ?? 0) + 1);
}
return;
case "assign":
if (hoisted.has(node.localId)) {
const values = assigned.get(node.localId) ?? [];
values.push(node.value);
assigned.set(node.localId, values);
}
if (globalIds.has(node.localId)) {
if (globals.has(node.localId)) writes.add(node.localId);
else globals.set(node.localId, node.value);
}
return;
case "forOf":
case "rethrow":
rebound.add(node.localId);
return;
case "tryCatch":
if (node.catchLocalId !== null) rebound.add(node.catchLocalId);
return;
case "call":
if (consumers.has(node.callee)) {
const calls = incoming.get(node.callee) ?? [];
calls.push({ owner: fn.name, args: node.args });
incoming.set(node.callee, calls);
}
return;
case "callValue":
if (node.callee.kind === "varRef") callees.add(node.callee.localId);
return;
case "closure": {
escaped.add(node.fnName);
const sites = closureSites.get(node.fnName) ?? [];
sites.push({ owner: fn.name, captures: node.captures });
closureSites.set(node.fnName, sites);
return;
}
return true;
},
expr: (expr) => {
if (expr.kind === "call" && consumers.has(expr.callee)) {
const calls = incoming.get(expr.callee) ?? [];
calls.push({ owner: fn.name, args: expr.args });
incoming.set(expr.callee, calls);
case "orDefault": {
const referenced = exprFunctionReference(node);
if (referenced !== undefined) escaped.add(referenced);
return;
}
if (expr.kind === "callValue" && expr.callee.kind === "varRef")
callees.add(expr.callee.localId);
const referenced = exprFunctionReference(expr);
if (referenced !== undefined) escaped.add(referenced);
if (expr.kind === "closure") {
const sites = closureSites.get(expr.fnName) ?? [];
sites.push({ owner: fn.name, captures: expr.captures });
closureSites.set(expr.fnName, sites);
}
if ((expr.kind === "assignExpr" || expr.kind === "incDec") && globalIds.has(expr.localId))
writes.add(expr.localId);
if (expr.kind === "assignExpr" || expr.kind === "incDec") rebound.add(expr.localId);
return true;
},
case "assignExpr":
case "incDec":
if (globalIds.has(node.localId)) writes.add(node.localId);
rebound.add(node.localId);
return;
}
});
for (const [id, values] of assigned)
if (emptyDecls.get(id) === 1 && values.length === 1 && !rebound.has(id))
@@ -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)`);
@@ -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<string, IrLocal>([
["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"]);
});
@@ -20,16 +20,24 @@
* 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 { f64Lit } from "./common.js";
export const INT32_MAX = 2147483647;
@@ -47,6 +55,17 @@ export interface DenseArrayLoop {
arrays: ReadonlyMap<string, IrExpr & { kind: "varRef" }>;
/** 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 +81,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 +128,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<IrStmt, ElementRead> {
const result = new Map<IrStmt, ElementRead>();
const writes = new Map<string, number>();
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<string, ElementRead>();
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<string, ElementRead>,
): { 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 +257,8 @@ export function matchDenseArrayLoop(
initialized: ReadonlySet<string>,
): 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 +267,21 @@ export function matchDenseArrayLoop(
// element write.
const disturbed = new Set<string>();
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 +296,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 +325,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<string, IrExpr & { kind: "varRef" }>();
for (const [id, ref] of candidates) {
const local = locals.get(id);
@@ -173,6 +343,7 @@ export function matchDenseArrayLoop(
local.boxed ||
local.tdz ||
disturbed.has(id) ||
mayStoreUndefined(id) ||
(id !== length?.localId && !initialized.has(id))
)
continue;
@@ -180,18 +351,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 +393,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 +441,54 @@ 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 integer = (expr: IrExpr, min: number, max: number): { value: string; ok: string } => {
const start = host.emitExpr(expr);
const low = B.tmp(),
high = B.tmp(),
raw = B.tmp(),
value = B.tmp(),
back = B.tmp(),
exact = B.tmp(),
inRange = B.tmp(),
ok = B.tmp();
B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(min)}`);
B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(max)}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${raw} = fptosi double ${start.name} to i64`);
B.line(`${value} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${value} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${start.name}`);
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
return { value, ok };
};
const low = integer(pair.lowStart, 0, INT32_MAX - 1);
const high = integer(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.value}, ${wide}`);
B.line(`${both} = and i1 ${valid}, ${covered}`);
valid = both;
}
return valid;
}
+170 -70
View File
@@ -1,3 +1,4 @@
import { emitPerKeyOrder } from "./key-orders.js";
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
import { BYTES_ELEM_NUM, SCR_ARR_FIELD, SCR_ARR_STATES, f64Lit, sizeToDouble } from "./common.js";
@@ -37,6 +38,8 @@ import {
accessorSlotProp,
DYN_HANDLE_KINDS,
isDirentShape,
KEY_ORDER_FIELD,
shapeKeyOrders,
isDynTypedRefType,
isRefCounted,
isUnitType,
@@ -153,6 +156,8 @@ export interface DynHost extends WalkerHost {
/** See moduleUsesDynBuiltinStream. */
usesDynBuiltinStream?(): boolean;
liveDynUnionRefAdapter(t: IrType & { kind: "union" }): string;
/** The module has declaration-typed boundary casts (IrModule.declBoundary). */
declBoundary?(): boolean;
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
classSubtypes(className: string): readonly string[];
@@ -365,6 +370,36 @@ export class LlDyn {
B.line(`call void @scr_dyn_build_add(ptr %d, ptr @${name}, ptr ${value})`);
}
/** The order a dyn value tries a union's arms in: the canonical order,
* except that among the record arms, those with more required fields go
* first, then those with more fields. Records are width-tolerant, so an
* arm with fewer fields (`{}`, or one whose fields are all optional,
* matches every object) would otherwise take an object that a wider arm
* also matches and drop the fields only the wider arm has. */
private unionArmOrder(def: IrUnionDef): number[] {
const width = (i: number): [number, number] | null => {
const arm = def.arms[i]!;
if (arm.kind !== "record") return null;
const shape = this.host.recordsById.get(arm.shapeId);
if (!shape || shape.tuple) return null;
const required = shape.fields.filter(
(field) =>
field.type.kind !== "union" ||
!this.host.unionsById.get(field.type.unionId)?.arms.some((x) => x.kind === "undefinedT"),
).length;
return [required, shape.fields.length];
};
const order = def.arms.map((_, i) => i);
const records = order.filter((i) => width(i) !== null);
const widest = [...records].sort((x, y) => {
const [rx, fx] = width(x)!;
const [ry, fy] = width(y)!;
return ry - rx || fy - fx || x - y;
});
records.forEach((slot, k) => (order[slot] = widest[k]!));
return order;
}
/** Branch to a variant only after its literal discriminator and complete
* structure match. Use the same path for predicates and builders. */
private unionArmMatch(
@@ -1107,9 +1142,10 @@ export class LlDyn {
}
B.condBr(readable, l0, fail);
B.startBlock(l0);
// dyn ('unknown') fields match ANY value, present or missing.
// dyn ('unknown') fields match ANY value, present or missing. The
// order index is no key.
for (const f of shape.fields) {
if (f.type.kind === "dyn") continue;
if (f.type.kind === "dyn" || f.name === KEY_ORDER_FIELD) continue;
const { value: m, release } = this.memberRead(B, f.name, f.type);
const lNext = B.newLabel("dm.n");
this.pendingBail(
@@ -1292,9 +1328,10 @@ export class LlDyn {
return (
shape.fields.every(
(f) =>
!f.name.startsWith("%") &&
f.name !== "__proto__" &&
this.jsonSchemaSupported(f.type, visiting, true),
f.name === KEY_ORDER_FIELD ||
(!f.name.startsWith("%") &&
f.name !== "__proto__" &&
this.jsonSchemaSupported(f.type, visiting, true)),
) &&
(!index || this.jsonSchemaSupported(index, visiting))
);
@@ -1380,7 +1417,9 @@ export class LlDyn {
const base = host.nullableUnions.fieldBase(t.shapeId);
const index = new Map(shape.fields.map((f, i) => [f.name, i + base]));
// The runtime names kept-source keys by this index (record-sources.ts).
const order = recordSourceFieldOrder(shape);
// The order index is no key: parsed values keep the table's order
// and their source's own order.
const order = recordSourceFieldOrder(shape).filter((name) => name !== KEY_ORDER_FIELD);
if (recordMayKeepSource(shape)) this.keepsRecordSources = true;
const fieldTy = `{ ptr, ${S}, ${S}, ptr }`;
const entries = order.map((fieldName) => {
@@ -2156,7 +2195,10 @@ export class LlDyn {
}
B.line(`%r0 = call ptr @${mangleRecordNew(t.shapeId)}()`);
if (recursive) this.recordBuilt(B, name, "%r0");
// The order index stays 0: the source's own order is kept below
// when it differs from the table.
for (const f of shape.fields) {
if (f.name === KEY_ORDER_FIELD) continue;
const { value: m, release } = this.memberRead(B, f.name, f.type);
this.pendingBail(
B,
@@ -2356,9 +2398,10 @@ export class LlDyn {
throw new InternalCompilerError(
`llvm emitter bug: dynCheck of unknown union ${t.unionId}`,
);
// Arms in CANONICAL order, first FULL match wins. The matched
// arm's builder can no longer fail.
def.arms.forEach((arm, i) => {
// Arms in unionArmOrder, first FULL match wins. The matched arm's
// builder can no longer fail.
for (const i of this.unionArmOrder(def)) {
const arm = def.arms[i]!;
const lHit = B.newLabel("dcu.h");
const lNext = B.newLabel("dcu.n");
this.unionArmMatch(B, def, i, lHit, lNext, preserveRefs);
@@ -2402,7 +2445,7 @@ export class LlDyn {
B.terminate(`ret ptr ${u}`);
}
B.startBlock(lNext);
});
}
if (preserveRefs) {
B.line(
`call void @scr_dyn_check_fail(ptr %path, ptr ${host.cstr("reference with its original layout")}, ptr %d)`,
@@ -2889,70 +2932,79 @@ export class LlDyn {
host.declare(`declare ptr @scr_dyn_new_obj()`);
B.line(`${d} = call ptr @scr_dyn_new_obj()`);
}
// Keys insert in DECLARED order (JS insertion order); internal
// '%'-fields follow so a record→dyn→record round trip keeps them.
// Keys insert in DECLARED order (JS insertion order), or in the
// value's own order for a shape with orderVariants; internal
// '%'-fields follow so a record→dyn→record round trip keeps them
// (all but the order index, which is no key).
const byName = new Map(shape.fields.map((f) => [f.name, f]));
// An accessor property is one key whose getter and setter are the
// slot closures (see accessorSlotProp), in accessorOrder's place.
const order = shape.accessorOrder ?? shape.declaredOrder ?? shape.fields.map((f) => f.name);
const inOrder = new Set(order);
const accessors = new Set(
shape.accessorOrder === undefined
? []
: shape.fields.flatMap((f) => {
const slot = accessorSlotProp(f.name);
return slot === null ? [] : [slot.prop];
}),
);
type DynField = { name: string; type: IrType } | { accessor: string };
const dynFields: DynField[] = [
...order.flatMap((n) => {
if (accessors.has(n)) return [{ accessor: n } as DynField];
const f = byName.get(n);
return f === undefined ? [] : [f as DynField];
}),
...shape.fields.filter(
(f) =>
!inOrder.has(f.name) && !(accessors.size > 0 && accessorSlotProp(f.name) !== null),
),
];
for (const entry of dynFields) {
if ("accessor" in entry) {
host.declare(
`declare void @scr_dyn_obj_set_accessor(ptr, ptr, ${host.sizeType}, ptr, ptr)`,
);
const boxed = (kind: "get" | "set"): string => {
const slot = byName.get(`%${kind}:${entry.accessor}`);
if (slot === undefined) return "null";
const fv = loadFieldOf(slot.name, slot.type);
const conv = B.tmp();
B.line(
`${conv} = call ptr @${this.toDynHelper(slot.type)}(${this.valTy(slot.type)} ${fv})`,
);
return conv;
};
const getter = boxed("get");
const setter = boxed("set");
B.line(
`call void @scr_dyn_obj_set_accessor(ptr ${d}, ptr ${host.cstr(entry.accessor)}, ${host.sizeType} ${Buffer.byteLength(entry.accessor, "utf8")}, ptr ${getter}, ptr ${setter}) ; ${llvmCommentText(entry.accessor)}`,
);
continue;
}
const f = entry;
const klen = Buffer.byteLength(f.name, "utf8");
const fv = loadFieldOf(f.name, f.type);
// An ABSENT optional field contributes no key.
const lSkip = f.type.kind === "union" ? this.skipIfAbsent(B, fv, f.type) : null;
const conv = B.tmp();
B.line(`${conv} = call ptr @${this.toDynHelper(f.type)}(${this.valTy(f.type)} ${fv})`);
B.line(
`call void @scr_dyn_obj_set(ptr ${d}, ptr ${host.cstr(f.name)}, ${host.sizeType} ${klen}, ptr ${conv}) ; ${llvmCommentText(f.name)}`,
const orders =
shape.accessorOrder !== undefined ? [shape.accessorOrder] : shapeKeyOrders(shape);
const perOrder = (order: readonly string[]): void => {
const inOrder = new Set(order);
const accessors = new Set(
shape.accessorOrder === undefined
? []
: shape.fields.flatMap((f) => {
const slot = accessorSlotProp(f.name);
return slot === null ? [] : [slot.prop];
}),
);
if (lSkip !== null) {
B.br(lSkip);
B.startBlock(lSkip);
type DynField = { name: string; type: IrType } | { accessor: string };
const dynFields: DynField[] = [
...order.flatMap((n) => {
if (accessors.has(n)) return [{ accessor: n } as DynField];
const f = byName.get(n);
return f === undefined ? [] : [f as DynField];
}),
...shape.fields.filter(
(f) =>
!inOrder.has(f.name) &&
f.name !== KEY_ORDER_FIELD &&
!(accessors.size > 0 && accessorSlotProp(f.name) !== null),
),
];
for (const entry of dynFields) {
if ("accessor" in entry) {
host.declare(
`declare void @scr_dyn_obj_set_accessor(ptr, ptr, ${host.sizeType}, ptr, ptr)`,
);
const boxed = (kind: "get" | "set"): string => {
const slot = byName.get(`%${kind}:${entry.accessor}`);
if (slot === undefined) return "null";
const fv = loadFieldOf(slot.name, slot.type);
const conv = B.tmp();
B.line(
`${conv} = call ptr @${this.toDynHelper(slot.type)}(${this.valTy(slot.type)} ${fv})`,
);
return conv;
};
const getter = boxed("get");
const setter = boxed("set");
B.line(
`call void @scr_dyn_obj_set_accessor(ptr ${d}, ptr ${host.cstr(entry.accessor)}, ${host.sizeType} ${Buffer.byteLength(entry.accessor, "utf8")}, ptr ${getter}, ptr ${setter}) ; ${llvmCommentText(entry.accessor)}`,
);
continue;
}
const f = entry;
const klen = Buffer.byteLength(f.name, "utf8");
const fv = loadFieldOf(f.name, f.type);
// An ABSENT optional field contributes no key.
const lSkip = f.type.kind === "union" ? this.skipIfAbsent(B, fv, f.type) : null;
const conv = B.tmp();
B.line(`${conv} = call ptr @${this.toDynHelper(f.type)}(${this.valTy(f.type)} ${fv})`);
B.line(
`call void @scr_dyn_obj_set(ptr ${d}, ptr ${host.cstr(f.name)}, ${host.sizeType} ${klen}, ptr ${conv}) ; ${llvmCommentText(f.name)}`,
);
if (lSkip !== null) {
B.br(lSkip);
B.startBlock(lSkip);
}
}
}
};
if (orders.length === 1) perOrder(orders[0]!);
else emitPerKeyOrder(B, shape, base, "%v", perOrder);
// A Dirent row takes Node's Dirent prototype (the type probes) and
// its Symbol(type) key; %dtype stays readable but non-enumerable.
if (isDirentShape(shape)) {
@@ -4526,6 +4578,31 @@ export class LlDyn {
/** The dyn argument spelling of one static value: dyn passes through
* (+1), funcs box anonymously, everything else rides toDyn. `expr` is
* BORROWED in every arm. Emits into the given builder. */
/** A live capsule of a record or array value (+1; the value is
* borrowed), as `dynFrom` with `liveRef` builds it; null for other
* types. Closures boxed in a declaration-typed boundary program return
* their records and arrays this way. */
private liveToDyn(B: BlockBuilder, t: IrType, expr: string): string | null {
const host = this.host;
if (t.kind === "union") {
const arms = host.unionsById.get(t.unionId)?.arms ?? [];
if (!arms.some((arm) => arm.kind === "record" || arm.kind === "array")) return null;
const boxed = B.tmp();
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${expr})`);
return boxed;
}
if ((t.kind !== "record" && t.kind !== "array") || !streamTypedRefEligible(t)) return null;
const key = typeKey(t);
const adapter = host.liveDynRefAdapter(t);
const rc = vAdapters(host, t);
const boxed = B.tmp();
B.line(
`${boxed} = call ptr ${typedRefConstructor(host, t)}(ptr ${expr}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
for (const line of bindLiveArrayOps(host, adapter, boxed)) B.line(line);
return boxed;
}
private toDynExpr(B: BlockBuilder, t: IrType, expr: string): string {
if (t.kind === "dyn") return this.retainDyn(B, expr);
if (t.kind === "func") {
@@ -4657,6 +4734,26 @@ export class LlDyn {
},
"ptr null",
);
// Declaration-typed boundary programs: an argument package code
// passes keeps its identity (scr_dyn_decl_adopt).
const capsule = this.host.declBoundary?.() === true ? this.liveToDyn(B, p, a) : null;
if (capsule !== null) {
host.declare(`declare void @scr_dyn_decl_adopt(ptr, ptr)`);
host.declare(`declare void @scr_dyn_release(ptr)`);
B.line(`call void @scr_dyn_decl_adopt(ptr ${ad}, ptr ${capsule})`);
B.line(`call void @scr_dyn_release(ptr ${capsule})`);
this.pendingBail(
B,
"dfk.adopt",
() => {
t.params.slice(0, i + 1).forEach((q, j) => {
const name = j === i ? a : argNames[j];
if (isRefCounted(q)) B.line(`call void ${releaseSym(host, q)}(ptr ${name})`);
});
},
"ptr null",
);
}
argNames.push(a);
}
});
@@ -4689,7 +4786,10 @@ export class LlDyn {
const r = B.tmp();
B.line(`${r} = call ${retTy} ${fn}(${callArgs})`);
this.pendingBail(B, "dfkr", () => {}, "ptr null");
const out = this.toDynExpr(B, t.ret, r);
const out =
this.host.declBoundary?.() === true
? (this.liveToDyn(B, t.ret, r) ?? this.toDynExpr(B, t.ret, r))
: this.toDynExpr(B, t.ret, r);
if (isRefCounted(t.ret)) B.line(`call void ${releaseSym(host, t.ret)}(ptr ${r})`);
B.terminate(`ret ptr ${out}`);
}
@@ -873,6 +873,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
out.push(`@${sym} = internal constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr } { ${body} }`);
}
if (emitter.mapOpsConsts.size > 0) out.push(``);
for (const sym of emitter.methodIdSlots.values()) out.push(`@${sym} = internal global i32 -1`);
if (emitter.methodIdSlots.size > 0) out.push(``);
for (const line of ffiCallbacks.globals) out.push(line);
if (ffiCallbacks.globals.length > 0) out.push(``);
@@ -52,7 +52,12 @@ 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,
@@ -966,7 +971,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(
@@ -1144,11 +1151,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) {
@@ -459,6 +459,33 @@ test("class brand probes borrow a local only across an inert literal key", () =>
expect(computed).toContain("call ptr @scr_dyn_retain_v(");
});
test("class constructor probes test the function tag and borrow across a literal key", () => {
const dyn: IrType = { kind: "dyn" };
const key: IrExpr = { kind: "strLit", value: "classval:Example", type: STRING, loc: receiverLoc };
const probe: IrExpr = {
kind: "libCall",
fn: "dyn.classIs",
args: [ref(dyn), key],
type: BOOL,
loc: receiverLoc,
};
const borrowed = work(probe, dyn);
expect(borrowed).not.toContain("call ptr @scr_dyn_retain_v(");
expect(borrowed).toContain("icmp ne ptr");
expect(borrowed).toContain("icmp eq i32 %t");
expect(borrowed).toMatch(/icmp eq i32 %t\d+, 8\b/);
expect(borrowed).toContain("call zeroext i1 @scr_dyn_class_is_key(");
const computed = work(
{
...probe,
args: [ref(dyn), { kind: "toString", operand: ref(dyn), type: STRING, loc: receiverLoc }],
},
dyn,
);
expect(computed).toContain("call ptr @scr_dyn_retain_v(");
expect(computed).toContain("call zeroext i1 @scr_dyn_class_is_key(");
});
test("class brand probes compare the program's key constant before the key text", () => {
const dyn: IrType = { kind: "dyn" };
const key: IrExpr = { kind: "strLit", value: "object:Example", type: STRING, loc: receiverLoc };
@@ -472,6 +499,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");
+75 -20
View File
@@ -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";
@@ -92,6 +93,7 @@ import {
type IntegerRanges,
} from "../../ir/integer-ranges.js";
import { unshareNodes } from "../../ir/unshare.js";
import { runPairedWhileLoopsAsFor } from "../../ir/paired-while-loops.js";
import { copyStableCaptures } from "../../ir/capture-values.js";
import { resolveSelfCaptures } from "../../ir/self-captures.js";
import { analyzeInt32Slots, dynamicFieldStores, type Int32Slots } from "../../ir/int32-slots.js";
@@ -168,6 +170,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
import { IrNodeIndex } from "../../ir/node-index.js";
import { lastUseRead } from "./local-extents.js";
import { findReboundParameters } from "./rebound-parameters.js";
import { ReferenceEffects } from "./reference-effects.js";
@@ -508,6 +511,15 @@ function provenInt32Arrays(mod: IrModule): ReadonlySet<string> {
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<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const targets = new Set<string>();
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
@@ -527,7 +539,9 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
@@ -541,6 +555,7 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
),
),
provenInt32Arrays,
resultEntryTargets,
)
: mod,
options,
@@ -777,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;
@@ -795,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))
);
}
@@ -970,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 (
@@ -979,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)
);
}
@@ -1076,6 +1092,10 @@ export class LlEmitter {
readonly cstrs = new Map<string, { sym: string; len: number }>();
/** Interned ScrMapOps descriptors (scr_map.h): initializer → symbol. */
readonly mapOpsConsts = new Map<string, string>();
/** Method-id slots of checked-dynamic method calls
* (scr_dyn_invoke_prepared_id): method name → symbol of an i32 the
* runtime fills with the name's id on the first call. */
readonly methodIdSlots = new Map<string, string>();
/** Type-directed walker functions (JSON serializers, the indent
* rewriter, union ToString/join) — interned per typeKey/unionId, defs
* flushed with the shape helpers. */
@@ -1176,6 +1196,9 @@ export class LlEmitter {
/** Module constants that keep one value once initialized
* (immutability.ts stableGlobals). */
stableGlobals: ReadonlySet<string> = new Set();
/** Every function body's nodes in walk order (ir/node-index.ts), for the
* constructor's analyses. */
readonly nodeIndex: IrNodeIndex;
private readonly tdzGlobals = new Map<string, string>();
readonly constantNumericTables: ReadonlyMap<string, ConstantNumericTable>;
/** Constant-key reads on checked-dynamic objects, each with its own
@@ -1244,7 +1267,7 @@ export class LlEmitter {
/** The module's runtime features (moduleRuntimeFeatures), found with one
* walk of the module the first time any of them is read. */
get runtimeFeatures(): RuntimeFeatures {
return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod));
return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod, this.nodeIndex));
}
/** Whether timer and tick scheduling goes through the *_als entry
* points (RuntimeFeatures asyncContext). */
@@ -1541,8 +1564,15 @@ export class LlEmitter {
((options.functionWorkers ?? 1) > 1 &&
options.forkPrimitives !== undefined &&
options.debugSources === undefined);
this.stackTraces = observesStacks(mod);
this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined);
// One walk of every body, which the analyses below read instead of
// walking the bodies themselves.
this.nodeIndex = new IrNodeIndex(mod);
this.stackTraces = observesStacks(mod, this.nodeIndex);
this.nullableUnions = new NullableUnions(
mod,
options.debugSources === undefined,
this.nodeIndex,
);
this.mockTimers = this.runtimeFeatures.mockTimers;
this.debug =
options.debugSources === undefined
@@ -1555,19 +1585,20 @@ export class LlEmitter {
this.nullableUnions.armTypes(),
);
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.initializerBindings = findInitializerBindings(mod, this.nodeIndex);
// One walk finds the dynamic field stores every analysis below consults.
const dynamicStores = dynamicFieldStores(mod);
const dynamicStores = dynamicFieldStores(mod, undefined, undefined, this.nodeIndex);
this.int32Slots = analyzeInt32Slots(mod, {
dynamicStores,
initializerBindings: this.initializerBindings,
nodes: this.nodeIndex,
});
this.liveViewsReadOnly =
mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0;
this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map();
this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []);
this.programLifetime = new Set(mod.contracts?.programLifetime ?? []);
this.finalFields = analyzeFinalFields(mod, false, dynamicStores);
this.finalFields = analyzeFinalFields(mod, false, dynamicStores, this.nodeIndex);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
@@ -1615,7 +1646,7 @@ export class LlEmitter {
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
for (const fn of mod.functions) this.fnByName.set(fn.name, fn);
this.appendedArrayLiterals = findAppendedArrayLiterals(mod);
this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex);
for (const entry of mod.ffiImports ?? []) {
this.ffiByName.set(entry.name, entry);
}
@@ -1623,10 +1654,11 @@ export class LlEmitter {
// and per async fiber; WASI, Windows and library builds have none.
const triple = (options.targetTriple ?? "").toLowerCase();
const windows = triple === "" ? process.platform === "win32" : triple.includes("windows");
this.publishedTypes = computePublishedTypes(mod);
this.publishedTypes = computePublishedTypes(mod, this.nodeIndex);
const mt = computeMayThrow(mod, {
stackChecks: !this.wasi && mod.lib === undefined && !windows,
published: this.publishedTypes,
nodes: this.nodeIndex,
});
this.mayThrow = mt.fns;
this.workerEntryPolls = mt.workerEntryPolls ?? null;
@@ -1685,12 +1717,14 @@ export class LlEmitter {
new Set((mod.globals ?? []).map((g) => g.id)),
{ unions: mod.unions, records: mod.records },
this.immutableFields,
this.nodeIndex,
);
const walkParameters = new Map<string, Set<number>>();
if (this.debug === null) {
const host = {
pointerLocal: (local: IrLocal) => this.plainReference(local.type),
borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e),
nodes: this.nodeIndex,
};
for (const fn of this.fnByName.values()) {
const preserving = this.referenceEffects.functions.has(fn.name);
@@ -1743,7 +1777,7 @@ export class LlEmitter {
);
this.lentStringParameters =
this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map();
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes);
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex);
// Integer clones signal a stack overflow through their result, which
// needs the single-threaded guard; worker programs also poll for
// termination, and debug builds and stack traces keep every frame.
@@ -1844,6 +1878,7 @@ export class LlEmitter {
liveDynRefAdapter: (type) => this.liveDynRefAdapter(type),
usesDynBuiltinStream: () => this.usesDynBuiltinStream,
liveDynUnionRefAdapter: (type) => this.liveDynUnionRefAdapter(type),
declBoundary: () => this.mod.declBoundary === true,
dynPromiseAdapter: (type) => this.dynPromiseAdapter(type),
isErrorClass: (name) => this.classMeta.get(name)?.root.def.name === "%Error",
classRootOf: (name) => this.classMeta.get(name)?.root.def.name,
@@ -1884,7 +1919,7 @@ export class LlEmitter {
this.globalTypes.set(g.id, g.type);
if (g.tdz) this.tdzGlobals.set(g.id, g.name);
}
this.stableGlobals = stableGlobals(mod);
this.stableGlobals = stableGlobals(mod, this.nodeIndex);
// Runtime error, EventEmitter, stream, EventTarget and Event classes
// have known layouts. Subclasses embed the runtime prefixes from
// classes.ts.
@@ -2736,6 +2771,17 @@ export class LlEmitter {
return `@${c.sym}`;
}
/** The method-id slot for a constant method name (methodIdSlots). */
methodIdSlot(name: string): string {
let sym = this.methodIdSlots.get(name);
if (!sym) {
sym = this.contentSymbol("sc_mid_", name);
this.methodIdSlots.set(name, sym);
}
this.journal?.entry("methodIdSlot", name, sym);
return `@${sym}`;
}
needBadTag(): void {
this.needsBadTag = true;
}
@@ -4853,6 +4899,15 @@ export class LlEmitter {
if (own !== value) names.set(value as string, own);
return;
}
case "methodIdSlot": {
let own = this.methodIdSlots.get(key);
if (own === undefined) {
own = this.contentSymbol("sc_mid_", key);
this.methodIdSlots.set(key, own);
}
if (own !== value) names.set(value as string, own);
return;
}
case "classObj":
addEntry(this.classObjs, key, value as { nameSym: string });
return;
@@ -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);
}
@@ -46,4 +46,7 @@ export interface LlStreamTypedRefAdapter {
recordOps?: string;
/** `@sym` of the ScrDynTypedClassOps table for class instance capsules. */
classOps?: string;
/** `@sym` of the ScrDynTypedBagClassOps table for capsules of bag-tracked
* class instances whose views inherit from prototype data. */
bagClassOps?: string;
}
@@ -250,8 +250,10 @@ export function emitDynamicExpr(
}
// Buffer.prototype method names take their scr_bytes_dyn.c entry, which
// runs them on Buffer receivers and dispatches any other receiver.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared";
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
// Each call passes its method name's id slot, so only the first call
// looks the name up.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
let name = host.cstr(e.calleeName);
if (e.calleeNameValue !== undefined) {
const value = emitBorrowedInput(host, e.calleeNameValue);
@@ -260,7 +262,7 @@ export function emitDynamicExpr(
}
const t = B.tmp();
B.line(
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
@@ -717,6 +719,10 @@ export function emitDynamicExpr(
B.line(
`${test} = call zeroext i1 @scr_dyn_typed_array_is(ptr ${d.name}, i32 ${BYTES_ELEM_NUM[e.bytesElem ?? "u8"]})`,
);
} else if (e.test === "classCapsule") {
host.declare(`declare zeroext i1 @scr_dyn_is_class_capsule(ptr)`);
test = B.tmp();
B.line(`${test} = call zeroext i1 @scr_dyn_is_class_capsule(ptr ${d.name})`);
} else if (e.test === "truthy") {
host.declare(`declare zeroext i1 @scr_dyn_truthy(ptr)`);
test = B.tmp();
@@ -837,6 +843,7 @@ export function emitDynamicExpr(
undefined: DYN_KIND.UNDEF,
null: DYN_KIND.NULL,
bytes: DYN_KIND.BYTES,
funcKind: DYN_KIND.FUNC,
};
test = oneOf([kindOf[e.test]!]);
}
@@ -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 };
}
@@ -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<string, number>`, 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,
@@ -141,7 +141,7 @@ test("live record capsules bind field hooks in snapshot order", () => {
const llvm = emitLlvmModule(module, { pointerBits: 64 });
const tables = [
...llvm.matchAll(
/^@(\S+)_rops = internal global \{ i64, ptr, ptr, ptr, i8, ptr \} \{ i64 (\d+), /gm,
/^@(\S+)_rops = internal global \{ i64, ptr, ptr, ptr, i8, ptr, ptr \} \{ i64 (\d+), /gm,
),
];
// root and leaf get field hooks; the record with an index-like field name
@@ -13,6 +13,8 @@ import {
classFieldAttributesName,
isClassOwnEnumerableFieldName,
isDirentShape,
KEY_ORDER_FIELD,
shapeKeyOrders,
isDynTypedRefType,
isRefCounted,
shapeHasAccessorSlots,
@@ -44,6 +46,7 @@ import type { NullableRefField } from "./nullable-fields.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { inlineUnionCopyLines } from "./inline-unions.js";
import { recordMayKeepSource } from "./record-sources.js";
import { emitPerKeyOrder } from "./key-orders.js";
import { registerReplay } from "./emit-journal.js";
/** An owned (+1) union value for a nullable-pointer class field slot `p`
@@ -764,6 +767,7 @@ export function streamTypedRefMaterializeAdapter(
// class view ops are published first.
const opsMeta = isDynTypedRefType(t) ? host.classMeta.get(t.className) : undefined;
if (opsMeta && liveClassOpsEligible(opsMeta)) adapter.classOps = `@${snapshot}_cops`;
else if (opsMeta && liveBagClassOpsEligible(opsMeta)) adapter.bagClassOps = `@${snapshot}_bops`;
adapter.commit = `@${snapshot}_commit`;
adapter.commit = host.streamTypedRefCommitAdapter(t, snapshot);
const B = new BlockBuilder();
@@ -776,6 +780,7 @@ export function streamTypedRefMaterializeAdapter(
);
}
if (adapter.classOps !== undefined) liveClassOps(host, snapshot, meta);
if (adapter.bagClassOps !== undefined) liveBagClassOps(host, snapshot, meta, t);
if (meta.hierarchy && meta.children.length) {
const pre = B.tmp();
for (const line of classPreLines(meta, classStructSym(t.className), "%p", pre, host.sizeType))
@@ -847,11 +852,7 @@ export function streamTypedRefMaterializeAdapter(
B.line(`call void @scr_dyn_release_v(ptr ${prototype})`);
B.line(`call void @scr_dyn_release_v(ptr ${attached})`);
}
for (const field of meta.def.fields.filter(
(f) =>
isClassOwnEnumerableFieldName(f.name) &&
!(meta.root.def.name === "%Error" && (f.name === "message" || f.name === "name")),
)) {
for (const field of classViewFields(meta)) {
const afterField = meta.def.tracksOwnFields ? B.newLabel("field.after") : null;
if (afterField) {
host.declare(`declare ptr @scr_dyn_obj_get(ptr, ptr, ${host.sizeType})`);
@@ -865,32 +866,7 @@ export function streamTypedRefMaterializeAdapter(
B.condBr(present, yes, afterField);
B.startBlock(yes);
}
const { index, compressed } = classFieldIndex(meta, field.name, "compressed");
const fieldPtr = B.tmp();
let fieldValue = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`,
);
if (host.int32Slots.isField(t.className, field.name)) {
const raw = B.tmp();
B.line(`${raw} = load i32, ptr ${fieldPtr}`);
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (host.inlineUnions.classField(t.className, field.name))
fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else if (compressed) {
const idx = B.tmp();
B.line(`${idx} = load i32, ptr ${fieldPtr}`);
for (const line of compactRefLoadLines(idx, fieldValue, `${fieldValue}.r`))
B.line(line.trimStart());
} else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
if (llFieldType(field.type, false) === "i8") {
const boolValue = B.tmp();
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
const { boxed, nullable, fieldValue } = classFieldDynValue(host, B, meta, t, field);
// Attributes copied from the bag do not stop the view taking the value.
const setter = withAttributes ? "scr_dyn_obj_set_field" : "scr_dyn_obj_set";
if (withAttributes)
@@ -1065,52 +1041,68 @@ export function streamTypedRefMaterializeAdapter(
const out = B.tmp();
B.line(`${out} = call ptr @scr_dyn_new_obj()`);
const byName = new Map(shape.fields.map((field) => [field.name, field]));
const order = shape.declaredOrder ?? shape.fields.map((field) => field.name);
const inOrder = new Set(order);
const fields = [
...order.map((name) => byName.get(name)).filter((field) => field !== undefined),
...shape.fields.filter((field) => !inOrder.has(field.name)),
];
// A shape with orderVariants has one field list per key order; its
// order index is no key.
const orders = shapeKeyOrders(shape);
const fieldsOf = (order: readonly string[]): IrRecordShape["fields"][number][] => {
const inOrder = new Set(order);
return [
...order.map((name) => byName.get(name)).filter((field) => field !== undefined),
...shape.fields.filter(
(field) => !inOrder.has(field.name) && field.name !== KEY_ORDER_FIELD,
),
];
};
// Published before the field converters, like the array hooks below.
// A Dirent row reads through its finished view (the prototype and
// the non-enumerable %dtype).
const dirent = isDirentShape(shape);
if (!dirent && liveRecordOpsEligible(fields)) {
adapter.recordOps = `@${snapshot}_rops`;
liveRecordOps(host, t, snapshot, fields);
}
for (const field of fields) {
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`,
);
const fieldValue = loadStructField(
B,
host.sizeType,
fieldPtr,
field.type,
host.inlineUnions.record(field.type),
);
// An ABSENT optional field contributes no key.
const undefinedTag = undefinedArmTag(field.type, host.unionsById);
let skip: string | null = null;
if (undefinedTag >= 0 && field.type.kind === "union") {
const absent = host.fieldAbsentTestIn(B, fieldValue, field.type.unionId);
skip = B.newLabel("live.record.absent");
const set = B.newLabel("live.record.set");
B.condBr(absent, skip, set);
B.startBlock(set);
}
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
B.line(
`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`,
);
if (skip !== null) {
B.br(skip);
B.startBlock(skip);
if (!dirent && orders.every((order) => liveRecordOpsEligible(fieldsOf(order)))) {
if (orders.length === 1) {
adapter.recordOps = `@${snapshot}_rops`;
liveRecordOps(host, t, snapshot, fieldsOf(orders[0]!));
} else {
orders.forEach((order, i) =>
liveRecordOps(host, t, `${snapshot}_o${i}`, fieldsOf(order)),
);
adapter.recordOps = `@${snapshot}_rops`;
liveRecordOrderSelect(host, shape, snapshot);
}
}
emitPerKeyOrder(B, shape, host.nullableUnions.fieldBase(t.shapeId), "%p", (order) => {
for (const field of fieldsOf(order)) {
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${mangleRecordStruct(t.shapeId)}, ptr %p, i64 0, i32 ${index}`,
);
const fieldValue = loadStructField(
B,
host.sizeType,
fieldPtr,
field.type,
host.inlineUnions.record(field.type),
);
// An ABSENT optional field contributes no key.
const undefinedTag = undefinedArmTag(field.type, host.unionsById);
let skip: string | null = null;
if (undefinedTag >= 0 && field.type.kind === "union") {
const absent = host.fieldAbsentTestIn(B, fieldValue, field.type.unionId);
skip = B.newLabel("live.record.absent");
const set = B.newLabel("live.record.set");
B.condBr(absent, skip, set);
B.startBlock(set);
}
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
B.line(
`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`,
);
if (skip !== null) {
B.br(skip);
B.startBlock(skip);
}
}
});
if (dirent) {
host.declare(`declare void @scr_dyn_dirent_finish(ptr)`);
B.line(`call void @scr_dyn_dirent_finish(ptr ${out})`);
@@ -1373,7 +1365,35 @@ function liveRecordOps(
``,
`@${names} = internal constant [${fields.length} x ptr] [${literals.join(", ")}]`,
// Mutable: the runtime fills the trailing key-array slot on first use.
`@${ops} = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get}, i8 ${optional.length === 0 ? 1 : 0}, ptr null }`,
`@${ops} = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get}, i8 ${optional.length === 0 ? 1 : 0}, ptr null, ptr null }`,
``,
);
}
/** `@<snapshot>_rops` for a shape with orderVariants: a record-ops table
* whose `select` hook answers the `_o<i>_rops` table (liveRecordOps) of the
* record's current key order, which a write of an optional key can change
* after the record is boxed. */
function liveRecordOrderSelect(
host: LlvmEmitterContext,
shape: IrRecordShape,
snapshot: string,
): void {
const B = new BlockBuilder();
const out = B.slot();
B.entryAllocas.push(`${out} = alloca ptr`);
emitPerKeyOrder(B, shape, host.nullableUnions.fieldBase(shape.id), "%p", (_, i) => {
B.line(`store ptr @${snapshot}_o${i}_rops, ptr ${out}`);
});
const ops = B.tmp();
B.line(`${ops} = load ptr, ptr ${out}`);
B.terminate(`ret ptr ${ops}`);
host.resolveThunkDefs.push(
`define internal ptr @${snapshot}_select(ptr %p) ${FN_ATTRS} { ; live record ops by key order`,
B.render(),
`}`,
``,
`@${snapshot}_rops = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr } { ${host.sizeType} 0, ptr null, ptr null, ptr null, i8 0, ptr null, ptr @${snapshot}_select }`,
``,
);
}
@@ -1444,14 +1464,157 @@ function liveClassOps(host: LlvmEmitterContext, snapshot: string, meta: LlClassM
);
}
/** The checked-dynamic value a class instance's view holds for `field`, read
* from its native slot of `%p`: `boxed` (+1), and the nullable union
* `fieldValue` the caller releases when `nullable` is set. */
function classFieldDynValue(
host: LlvmEmitterContext,
B: BlockBuilder,
meta: LlClassMeta,
t: Extract<IrType, { kind: "object" }>,
field: LlClassMeta["def"]["fields"][number],
): { boxed: string; nullable: NullableRefField | null; fieldValue: string } {
const { index, compressed } = classFieldIndex(meta, field.name, "compressed");
const fieldPtr = B.tmp();
let fieldValue = B.tmp();
B.line(
`${fieldPtr} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`,
);
if (host.int32Slots.isField(t.className, field.name)) {
const raw = B.tmp();
B.line(`${raw} = load i32, ptr ${fieldPtr}`);
B.line(`${fieldValue} = sitofp i32 ${raw} to double`);
} else if (host.inlineUnions.classField(t.className, field.name))
fieldValue = loadStructField(B, host.sizeType, fieldPtr, field.type, true);
else if (compressed) {
const idx = B.tmp();
B.line(`${idx} = load i32, ptr ${fieldPtr}`);
for (const line of compactRefLoadLines(idx, fieldValue, `${fieldValue}.r`))
B.line(line.trimStart());
} else B.line(`${fieldValue} = load ${llFieldType(field.type, false)}, ptr ${fieldPtr}`);
if (llFieldType(field.type, false) === "i8") {
const boolValue = B.tmp();
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
return { boxed: host.streamTypedRefBoxValue(B, field.type, fieldValue), nullable, fieldValue };
}
/** The fields an instance's view takes from their native slots, in order:
* its own enumerable fields, except an Error's message and name. */
function classViewFields(meta: LlClassMeta): LlClassMeta["def"]["fields"] {
return meta.def.fields.filter(
(f) =>
isClassOwnEnumerableFieldName(f.name) &&
!(meta.root.def.name === "%Error" && (f.name === "message" || f.name === "name")),
);
}
/** Classes whose instance views the materializer builds from the property
* bag and the prototype data alone (see liveBagClassOps): JavaScript classes
* tracking their own fields, outside the Error and runtime families, with no
* subclass whose view another materializer builds. */
function liveBagClassOpsEligible(meta: LlClassMeta): boolean {
return (
meta.def.tracksOwnFields === true &&
(meta.def.prototypeDataHelper !== undefined ||
meta.def.instancePrototypeHelper !== undefined) &&
!meta.def.runtime &&
!meta.root.def.runtime &&
meta.root.def.name !== "%Error" &&
!(meta.hierarchy && meta.children.length > 0) &&
meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) &&
!meta.def.fields.some(
(field) =>
field.name === CLASS_ATTRIBUTES ||
meta.def.fields.some((f) => f.name === classFieldAttributesName(field.name)),
)
);
}
/** The hooks of a bag-tracked class instance capsule
* (ScrDynTypedBagClassOps). The materializer gives the instance's view
* exactly the bag's string-keyed properties, the values of the declared
* fields among them from their native slots, and the prototype these hooks
* return, so a method call resolves without building the view. */
function liveBagClassOps(
host: LlvmEmitterContext,
snapshot: string,
meta: LlClassMeta,
t: Extract<IrType, { kind: "object" }>,
): void {
const struct = classStructSym(t.className);
const bag = new BlockBuilder();
const slot = bag.tmp(),
value = bag.tmp();
bag.line(
`${slot} = getelementptr inbounds %${struct}, ptr %p, i64 0, i32 ${classFieldIndex(meta, DYN_CLASS_PROPERTIES).index}`,
);
bag.line(`${value} = load ptr, ptr ${slot}`);
bag.terminate(`ret ptr ${value}`);
const proto = new BlockBuilder();
const prototype = proto.tmp();
if (meta.def.instancePrototypeHelper) {
const receiver = proto.tmp();
proto.line(`${receiver} = call ptr ${vAdapters(host.shapeHost, t).retain}(ptr %p)`);
proto.line(
`${prototype} = call ptr @${mangleFunction(meta.def.instancePrototypeHelper)}(ptr ${receiver})`,
);
} else proto.line(`${prototype} = call ptr @${mangleFunction(meta.def.prototypeDataHelper!)}()`);
proto.terminate(`ret ptr ${prototype}`);
// Field `%i` of the view's field list, as the materializer boxes it.
const fields = classViewFields(meta);
const get = new BlockBuilder();
const cases = fields.map((_, i) => get.newLabel(`field.${i}`));
const none = get.newLabel("field.none");
get.terminate(
`switch ${host.sizeType} %i, label %${none} [ ${cases.map((label, i) => `${host.sizeType} ${i}, label %${label}`).join(" ")} ]`,
);
fields.forEach((field, i) => {
get.startBlock(cases[i]!);
const { boxed, nullable, fieldValue } = classFieldDynValue(host, get, meta, t, field);
if (nullable) {
host.declare(`declare void @scr_union_release(ptr)`);
get.line(`call void @scr_union_release(ptr ${fieldValue})`);
}
get.terminate(`ret ptr ${boxed}`);
});
get.startBlock(none);
get.terminate(`ret ptr null`);
const names = fields.map((field) => `ptr ${host.internLiteral(field.name)}`);
host.resolveThunkDefs.push(
`define internal ptr @${snapshot}_field(ptr %p, ${host.sizeType} %i) ${FN_ATTRS} { ; view field of ${meta.def.name}`,
get.render(),
`}`,
``,
`@${snapshot}_fnames = internal constant [${names.length} x ptr] ${names.length ? `[${names.join(", ")}]` : "zeroinitializer"}`,
);
host.resolveThunkDefs.push(
`define internal ptr @${snapshot}_bag(ptr %p) ${FN_ATTRS} { ; property bag of ${meta.def.name}`,
bag.render(),
`}`,
``,
`define internal ptr @${snapshot}_proto(ptr %p) ${FN_ATTRS} { ; view prototype of ${meta.def.name}`,
proto.render(),
`}`,
``,
`@${snapshot}_bops = internal constant { ptr, ptr, ${host.sizeType}, ptr, ptr } { ptr @${snapshot}_bag, ptr @${snapshot}_proto, ${host.sizeType} ${fields.length}, ptr @${snapshot}_fnames, ptr @${snapshot}_field }`,
``,
);
}
/** The live view a capsule of this adapter binds: the runtime bind call
* and its ops constant, with the view code a deferred receiver recipe
* records (1 array, 2 record, 3 class). */
* records (1 array, 2 record, 3 class, 4 bag-tracked class). */
export function liveRefView(
adapter: LlStreamTypedRefAdapter,
): { bind: string; ops: string; code: 1 | 2 | 3 } | null {
): { bind: string; ops: string; code: 1 | 2 | 3 | 4 } | null {
if (adapter.classOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_class", ops: adapter.classOps, code: 3 };
if (adapter.bagClassOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_bag_class", ops: adapter.bagClassOps, code: 4 };
if (adapter.recordOps !== undefined)
return { bind: "scr_dyn_typed_ref_bind_record", ops: adapter.recordOps, code: 2 };
if (adapter.arrayOps !== undefined)
@@ -727,6 +727,17 @@ export function streamCbThunkFor(
const ty = host.llType(p);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`);
// An entry `encoding` declared as a string (the declarations'
// `encoding: string`) receives "" where Node passes undefined or
// null (an object-mode write), as a write's encoding parameter does.
const entry =
p.kind === "array" && p.elem.kind === "record"
? host.recordsById.get(p.elem.shapeId)
: undefined;
if (entry?.fields.some((f) => f.name === "encoding" && f.type.kind === "string")) {
host.declare(`declare void @scr_stream_writev_fill_encodings(ptr)`);
d.push(` call void @scr_stream_writev_fill_encodings(ptr %chunks)`);
}
d.push(
` %vt${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %chunks, ptr null)`,
` %vtbad${i} = call zeroext i1 @scr_exc_pending()`,
+1 -1
View File
@@ -732,7 +732,7 @@ function emitKeyed(host: Host, e: ExprOf<"call">, callee: IrFunction): LlValue {
/** dynInvoke's method read on an ordinary object through the site's
* cache, else scr_dyn_prepare_method. Returns the owned method (or NULL,
* which scr_dyn_invoke_prepared answers with the full dispatch). */
* which scr_dyn_invoke_prepared_id answers with the full dispatch). */
export function emitJsIcMethod(host: Host, e: ExprOf<"dynInvoke">, receiver: string): string {
const B = host.B;
const method = e.method;
@@ -0,0 +1,49 @@
/* Key-order dispatch for shapes whose values record the order their keys
* were written in (IrRecordShape.orderVariants): an emitter that walks a
* record's keys emits one walk per order and branches on the value's
* KEY_ORDER_FIELD. */
import type { IrRecordShape } from "../../ir/ir.js";
import { KEY_ORDER_FIELD, shapeKeyOrders } from "../../ir/ir.js";
import { mangleRecordStruct } from "../mangle.js";
import type { BlockBuilder } from "./blocks.js";
/** Emits `walk(order, index)` for record `rec` of `shape`, once per order behind a
* switch on the value's order index when the shape has several. Each walk
* starts in its own block, and the code after this call continues in the
* block they all branch to. */
export function emitPerKeyOrder(
B: BlockBuilder,
shape: IrRecordShape,
fieldBase: number,
rec: string,
walk: (order: readonly string[], index: number) => void,
): void {
const orders = shapeKeyOrders(shape);
if (orders.length === 1) {
walk(orders[0]!, 0);
return;
}
const index = shape.fields.findIndex((f) => f.name === KEY_ORDER_FIELD) + fieldBase;
const p = B.tmp();
const n = B.tmp();
const i = B.tmp();
B.line(
`${p} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr ${rec}, i64 0, i32 ${index} ; .${KEY_ORDER_FIELD}`,
);
B.line(`${n} = load double, ptr ${p}`);
B.line(`${i} = fptosi double ${n} to i32`);
const labels = orders.map(() => B.newLabel("ko"));
const join = B.newLabel("ko.join");
B.terminate(
`switch i32 ${i}, label %${labels[0]} [ ${labels
.slice(1)
.map((label, k) => `i32 ${k + 1}, label %${label}`)
.join(" ")} ]`,
);
orders.forEach((order, k) => {
B.startBlock(labels[k]!);
walk(order, k);
B.br(join);
});
B.startBlock(join);
}
@@ -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") {
@@ -281,6 +284,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") {
@@ -297,6 +332,41 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
B.line(`${result} = load i1, ptr ${slot}`);
return { name: result, type: e.type };
}
if (e.fn === "dyn.classIs") {
// Only a class constructor's function value can match. Expose the
// function tag to LLVM so a chain of constructor probes (a static
// property bag's dispatch) costs one test for every other value, and
// borrow the receiver across a literal key as the capsule probes do.
const literal = e.args[1]!.kind === "strLit";
const value = literal ? host.emitReadReceiver(e.args[0]!) : host.emitExpr(e.args[0]!);
const key = host.emitExpr(e.args[1]!);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca i1`);
B.line(`store i1 false, ptr ${slot}`);
const present = B.tmp();
const inspect = B.newLabel("ctor.inspect");
const compare = B.newLabel("ctor.compare");
const done = B.newLabel("ctor.done");
B.line(`${present} = icmp ne ptr ${value.name}, null`);
B.condBr(present, inspect, done);
B.startBlock(inspect);
const kind = host.dynKind(value.name);
const fn = B.tmp();
B.line(`${fn} = icmp eq i32 ${kind}, ${DYN_KIND.FUNC}`);
B.condBr(fn, compare, done);
B.startBlock(compare);
host.declare("declare zeroext i1 @scr_dyn_class_is_key(ptr, ptr)");
const matches = B.tmp();
B.line(
`${matches} = call zeroext i1 @scr_dyn_class_is_key(ptr ${value.name}, ptr ${key.name})`,
);
B.line(`store i1 ${matches}, ptr ${slot}`);
B.br(done);
B.startBlock(done);
const result = B.tmp();
B.line(`${result} = load i1, ptr ${slot}`);
return { name: result, type: e.type };
}
if (e.fn === "crypto.randomFillCb" || e.fn === "crypto.randomFillCbDyn") {
host.usesTimers = true;
const args = e.args.map((arg) => host.emitExpr(arg));
@@ -134,6 +134,9 @@ export const LIB_FN_SYMS: Record<string, string> = {
"error.setCodeDyn": "scr_error_set_code_dyn",
"error.assign": "scr_error_assign_dyn",
"error.hasKey": "scr_error_has_key",
"error.ownAccessor": "scr_error_own_accessor",
"error.accessorGet": "scr_error_accessor_get",
"error.accessorSet": "scr_error_accessor_set",
"error.hasCode": "scr_error_has_code",
"error.defineCause": "scr_error_define_cause",
"error.deleteCause": "scr_error_delete_cause",
@@ -582,6 +585,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"crypto.ecdhConvertKey": "scr_crypto_ecdh_convert_key",
"crypto.dhNew": "scr_crypto_dh_new",
"crypto.checkPrimeSync": "scr_crypto_check_prime_sync",
"crypto.getCipherInfo": "scr_crypto_get_cipher_info",
"crypto.checkPrimeCb": "scr_crypto_check_prime_async",
"crypto.generatePrimeSync": "scr_crypto_generate_prime_sync",
"crypto.generatePrimeCb": "scr_crypto_generate_prime_async",
@@ -893,6 +897,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// ambient-this read never throw. The fs dyn read is the sync-fs story.
"fetch.start": "scr_fetch_static",
"fetch.responseNew": "scr_fetch_response_new",
"fetch.responseLight": "scr_fetch_response_light",
"fetch.responseJsonStatic": "scr_fetch_response_json_static",
"fetch.input": "scr_fetch_input",
"abort.controllerNew": "scr_fetch_abort_controller_new",
@@ -1135,6 +1140,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"readable.pushEncoding": "scr_stream_set_push_encoding",
"stream.objectMode": "scr_stream_object_mode",
"stream.decodeStrings": "scr_stream_decode_strings",
"stream.optionValues": "scr_stream_option_values",
"stream.checkEncodingOption": "scr_stream_check_encoding_option",
"stream.duplexFromGen": "scr_stream_duplex_from_gen",
"stream.duplexFromDyn": "scr_stream_duplex_from_dyn",
"stream.duplexifyReadable": "scr_stream_duplexify_readable",
@@ -1319,6 +1326,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"http.reqPipeClient": "scr_http_req_pipe_client",
"http.reqPipeSock": "scr_http_req_pipe_sock",
"http.reqResume": "scr_http_req_resume",
"http.reqPause": "scr_http_req_pause",
"http.reqDestroy": "scr_http_req_destroy",
"http.reqSetEncoding": "scr_http_req_set_encoding",
"http.resSetHeader": "scr_http_res_set_header",
@@ -1553,11 +1561,13 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.assignPrototype": "scr_dyn_assign_prototype",
"dyn.definePrototypeProps": "scr_dyn_define_prototype_props",
"dyn.bagGet": "scr_dyn_bag_get",
"dyn.bagCreateField": "scr_dyn_bag_create_field",
"dyn.bagSet": "scr_dyn_bag_set",
"dyn.objCreate": "scr_dyn_obj_create",
"dyn.nameClassPrototype": "scr_dyn_name_class_prototype",
"dyn.objCreateWithProperties": "scr_dyn_obj_create_with_properties",
"dyn.getPrototype": "scr_dyn_get_prototype",
"dyn.getPrototypeForIdentity": "scr_dyn_get_prototype_for_identity",
"dyn.setPrototype": "scr_dyn_set_prototype",
"dyn.getOwnPropertyNames": "scr_dyn_get_own_property_names",
"dyn.ownKeys": "scr_dyn_own_keys",
@@ -1571,6 +1581,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.objValueList": "scr_dyn_obj_value_list",
"dyn.objEntries": "scr_dyn_obj_entries",
"dyn.structuredClone": "scr_structured_clone",
"dyn.declAdopt": "scr_dyn_decl_adopt",
"dyn.cloneMissing": "scr_structured_clone_missing",
"dyn.cloneTransferFail": "scr_structured_clone_transfer_fail",
"dyn.cloneTransferFailWith": "scr_structured_clone_transfer_fail_with",
@@ -1765,6 +1776,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"stream.setTransform",
"stream.setFlush",
"stream.setConstruct",
"stream.replaceConstruct",
"stream.duplexFromGen",
"stream.duplexFromDyn",
"stream.duplexifyReadable",
@@ -189,6 +189,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
e.fn === "stream.setTransform" ||
e.fn === "stream.setFlush" ||
e.fn === "stream.setConstruct" ||
e.fn === "stream.replaceConstruct" ||
e.fn === "stream.setWritev" ||
e.fn === "stream.setWritevDefault"
) {
@@ -203,6 +204,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
"stream.setTransform": "t",
"stream.setFlush": "l",
"stream.setConstruct": "c",
"stream.replaceConstruct": "c",
"stream.setWritev": "v",
"stream.setWritevDefault": "v",
};
@@ -214,6 +216,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
"stream.setTransform": "scr_stream_set_transform",
"stream.setFlush": "scr_stream_set_flush",
"stream.setConstruct": "scr_stream_set_construct",
"stream.replaceConstruct": "scr_stream_replace_construct",
"stream.setWritev": "scr_stream_set_writev",
"stream.setWritevDefault": "scr_stream_set_writev_default",
};
@@ -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,
@@ -6,6 +6,7 @@ import {
type IrUnionDef,
} from "../../ir/ir.js";
import { everyModuleNode } from "../../ir/traverse.js";
import type { IrNodeIndex } from "../../ir/node-index.js";
/** A union of exactly one reference arm and one or two unit arms
* (`C | undefined`, `C | null`, `C | null | undefined`) whose VALUES are
@@ -120,12 +121,12 @@ export class NullableUnions {
/** `shapes`: allow shape unions (debug builds keep boxes, whose
* debug-info layouts describe every arm). */
constructor(mod: IrModule, shapes = true) {
constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex) {
const emitted = new Set<string>();
for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name);
const records = new Set<string>();
for (const shape of mod.records ?? []) records.add(shape.id);
const boxed = boxedUnionIds(mod);
const boxed = boxedUnionIds(mod, nodes);
// Shape unions stay out of library builds (their host ABI marshals
// boxes) and of programs with lifetime regions (whose walks report a
// reference slot by its type's release entry point).
@@ -277,13 +278,20 @@ function shapeUnion(
/** Unions the runtime itself inspects as tagged boxes keep that
* representation: Map keys and Set elements hash and compare union keys by
* tag and payload inside the C map. */
function boxedUnionIds(mod: IrModule): Set<string> {
function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const boxed = new Set<string>();
// Only the module's union definitions consult the result.
if (!mod.unions?.length) return boxed;
const mark = (t: IrType): void => {
if (t.kind === "union") boxed.add(t.unionId);
};
if (nodes !== undefined) {
for (const t of nodes.types) {
if (t.kind === "map") mark(t.key);
else if (t.kind === "set") mark(t.elem);
}
return boxed;
}
everyModuleNode(mod, {
expr: () => true,
stmt: () => true,
@@ -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);
@@ -342,6 +345,7 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void {
borrowsWithoutOwning: (e: IrExpr) => emitter.borrowsWithoutOwning(e),
borrowedReturn: (callee: string) => emitter.borrowedReturns.has(callee),
parameterAllowed: (id: string) => allowed.has(id),
nodes: emitter.nodeIndex,
};
return emitter.referenceEffects.functions.has(fn.name)
? host
@@ -409,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)
);
}
@@ -9,6 +9,7 @@ import {
type IrUnionDef,
} from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js";
import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js";
import { borrowsStringInputs } from "./string-lifetimes.js";
@@ -227,6 +228,8 @@ export class ReferenceWrites {
/** Fields no code writes once their object is constructed
* (immutability.ts, from cycle-mutability's construction-only facts). */
private readonly immutable: ImmutableFields | null = null,
/** The module's node index, read instead of walking the bodies. */
private readonly nodes?: IrNodeIndex,
) {
this.key(HEAP_KEY);
this.unions = new Map((types.unions ?? []).map((u) => [u.id, u]));
@@ -236,25 +239,25 @@ export class ReferenceWrites {
const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`));
this.constructors = constructors;
this.freshConstructors = [...functions.values()].every((fn) =>
fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)),
this.onlySuperCalls(fn, constructors),
);
this.callTargets = new CallTargets(functions, this.virtualTargets);
this.callTargets = new CallTargets(functions, this.virtualTargets, nodes);
this.solve(constructors);
}
/** Every direct call of a constructor is a `super` call from a
/** Every direct call of a constructor in `fn` is a `super` call from a
* constructor on its own `this`. */
private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet<string>): boolean {
private onlySuperCalls(fn: IrFunction, constructors: ReadonlySet<string>): boolean {
const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined;
const expr = (e: IrExpr): boolean => {
if (e.kind === "call" && constructors.has(e.callee)) {
const self = e.args[0];
if (thisId === undefined || !self || !isThisRef(self, thisId)) return false;
let only = true;
eachNodeOfKinds(fn, this.nodes?.scan(CALL_KINDS), (node) => {
if (node.kind === "call" && constructors.has(node.callee)) {
const self = node.args[0];
if (thisId === undefined || !self || !isThisRef(self, thisId)) only = false;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt);
return stmt(s);
return only;
});
return only;
}
private key(k: string): number {
@@ -299,7 +302,7 @@ export class ReferenceWrites {
}
const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null;
const boxed = boxedLocals(fn);
const privates = privateArrays(fn, boxed);
const privates = privateArrays(fn, boxed, this.nodes);
const sink: WriteSink = {
add: (k) => {
// Interned for queries; a callee's locals are private to it.
@@ -749,7 +752,7 @@ export class ReferenceWrites {
enter(fn: IrFunction): void {
this.current = fn;
this.currentBoxed = boxedLocals(fn);
this.currentPrivateArrays = privateArrays(fn, this.currentBoxed);
this.currentPrivateArrays = privateArrays(fn, this.currentBoxed, this.nodes);
this.currentLocals = new Set(fn.locals.map((local) => local.id));
this.inConstructor = this.constructors.has(fn.name);
for (const p of fn.params) this.currentLocals.add(p.localId);
@@ -973,6 +976,29 @@ function privateReceiver(arr: IrExpr, privates: ReadonlySet<string>): boolean {
* element out nor let the array escape. */
const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]);
/** The nodes the scans below read. */
const CALL_KINDS: readonly IrNodeKind[] = ["call"];
const CALL_SITE_KINDS: readonly IrNodeKind[] = ["call", "new", "virtualCall", "closure"];
const DEFINITION_KINDS: readonly IrNodeKind[] = ["assignExpr", "incDec", "varDecl", "assign"];
/** The nodes privateArrays reads. */
const PRIVATE_ARRAY_KINDS: readonly IrNodeKind[] = [
"varRef",
"assignExpr",
"incDec",
"closure",
"classRef",
"arrIntrinsic",
"arrayHas",
"varDecl",
"assign",
"arraySet",
"arraySetUndefined",
"arrayDelete",
"arraySetLength",
"return",
];
/** Array locals whose storage the function allocated itself and that
* nothing else can reach before the function returns them: declared once
* from an array literal or `new Array(n)`, never rebound, unboxed, and
@@ -985,7 +1011,11 @@ const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]);
* stay out of the function's summary (a caller's borrows predate the
* array). The literal map, filter and fused-pipeline helpers fill their
* results this way. */
function privateArrays(fn: IrFunction, boxed: ReadonlySet<string>): ReadonlySet<string> {
function privateArrays(
fn: IrFunction,
boxed: ReadonlySet<string>,
nodes: IrNodeIndex | undefined,
): ReadonlySet<string> {
if (fn.async || fn.generator) return new Set();
const candidates = new Set<string>();
for (const local of fn.locals)
@@ -1001,54 +1031,50 @@ function privateArrays(fn: IrFunction, boxed: ReadonlySet<string>): ReadonlySet<
if (e.kind === "varRef" && candidates.has(e.localId))
permitted.set(e, (permitted.get(e) ?? 0) + 1);
};
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
eachNodeOfKinds(fn, nodes?.scan(PRIVATE_ARRAY_KINDS), (node) => {
switch (node.kind) {
case "varRef": {
const left = permitted.get(e) ?? 0;
if (left === 0) rejected.add(e.localId);
else permitted.set(e, left - 1);
const left = permitted.get(node) ?? 0;
if (left === 0) rejected.add(node.localId);
else permitted.set(node, left - 1);
break;
}
case "assignExpr":
case "incDec":
rejected.add(e.localId);
rejected.add(node.localId);
break;
case "closure":
case "classRef":
for (const id of e.captures ?? []) rejected.add(id);
for (const id of node.captures ?? []) rejected.add(id);
break;
case "arrIntrinsic":
if (PRIVATE_ARRAY_METHODS.has(e.method)) permit(e.receiver);
if (PRIVATE_ARRAY_METHODS.has(node.method)) permit(node.receiver);
break;
case "arrayHas":
permit(e.arr);
permit(node.arr);
break;
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
declarations.set(s.localId, (declarations.get(s.localId) ?? 0) + 1);
if (s.init === null || (s.init.kind !== "arrayLit" && s.init.kind !== "arrayNewLen"))
rejected.add(s.localId);
declarations.set(node.localId, (declarations.get(node.localId) ?? 0) + 1);
if (
node.init === null ||
(node.init.kind !== "arrayLit" && node.init.kind !== "arrayNewLen")
)
rejected.add(node.localId);
break;
case "assign":
rejected.add(s.localId);
rejected.add(node.localId);
break;
case "arraySet":
case "arraySetUndefined":
case "arrayDelete":
case "arraySetLength":
permit(s.arr);
permit(node.arr);
break;
case "return":
if (s.value !== null) permit(s.value);
if (node.value !== null) permit(node.value);
break;
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
});
const out = new Set<string>();
for (const id of candidates) if (!rejected.has(id) && declarations.get(id) === 1) out.add(id);
return out;
@@ -1081,30 +1107,30 @@ class CallTargets {
constructor(
private readonly functions: ReadonlyMap<string, IrFunction>,
virtualTargets: VirtualTargets,
private readonly nodes: IrNodeIndex | undefined,
) {
const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => {
let list = this.sites.get(callee);
if (!list) this.sites.set(callee, (list = []));
list.push({ caller, args });
};
const scan = nodes?.scan(CALL_SITE_KINDS);
for (const fn of functions.values()) {
if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name);
const expr = (e: IrExpr): boolean => {
if (e.kind === "call") site(e.callee, fn, e.args);
else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]);
else if (e.kind === "virtualCall") {
const targets = virtualTargets.targets(e.className, e.method);
for (const t of targets ?? []) site(t, fn, e.args);
} else if (e.kind === "closure") {
this.opaque.add(e.fnName);
let list = this.creators.get(e.fnName);
if (!list) this.creators.set(e.fnName, (list = []));
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") site(node.callee, fn, node.args);
else if (node.kind === "new")
site(`%${node.className}.constructor`, fn, [node, ...node.args]);
else if (node.kind === "virtualCall") {
const targets = virtualTargets.targets(node.className, node.method);
for (const t of targets ?? []) site(t, fn, node.args);
} else if (node.kind === "closure") {
this.opaque.add(node.fnName);
let list = this.creators.get(node.fnName);
if (!list) this.creators.set(node.fnName, (list = []));
list.push(fn);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt);
fn.body.every(stmt);
});
}
// Fixpoint over function-typed parameter positions.
const positions: [IrFunction, number][] = [];
@@ -1225,18 +1251,13 @@ class CallTargets {
defs = new Map();
const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id));
const counts = new Map<string, number>();
const expr = (e: IrExpr): boolean => {
if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2);
return everyExprChild(e, expr, stmt);
};
const stmt = (st: IrStmt): boolean => {
if (st.kind === "varDecl") {
counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1);
defs!.set(st.localId, st.init);
} else if (st.kind === "assign") counts.set(st.localId, 2);
return everyStmtChild(st, expr, stmt);
};
fn.body.every(stmt);
eachNodeOfKinds(fn, this.nodes?.scan(DEFINITION_KINDS), (node) => {
if (node.kind === "assignExpr" || node.kind === "incDec") counts.set(node.localId, 2);
else if (node.kind === "varDecl") {
counts.set(node.localId, (counts.get(node.localId) ?? 0) + 1);
defs!.set(node.localId, node.init);
} else if (node.kind === "assign") counts.set(node.localId, 2);
});
for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null);
this.definitions.set(fn, defs);
}
@@ -1,5 +1,5 @@
import { isRefCounted, type IrExpr, type IrFunction, type IrLocal } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js";
/** What the emitter knows about a projection's storage. */
export interface WalkBorrowHost {
@@ -22,8 +22,24 @@ export interface WalkBorrowHost {
* (whose own rebinding releases nothing). Absent: the body preserves
* every edge. */
unwritten?(value: IrExpr, candidates: ReadonlySet<string>): boolean;
/** The module's node index, read instead of walking the body. */
nodes?: IrNodeIndex;
}
/** The node kinds analyzeWalks reads from a body. */
const WALK_KINDS: readonly IrNodeKind[] = [
"varDecl",
"assign",
"forOf",
"rethrow",
"tryCatch",
"return",
"assignExpr",
"incDec",
"closure",
"classRef",
];
export interface WalkFacts {
locals: ReadonlySet<string>;
/** Every return yields a walk (or a throw), so the body can return its
@@ -71,44 +87,36 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts {
if (!list) definitions.set(id, (list = []));
list.push(value);
};
everyStmtList(fn.body, {
stmt: (s) => {
switch (s.kind) {
case "varDecl":
define(s.localId, s.init);
break;
case "assign":
define(s.localId, s.value);
break;
case "forOf":
excluded.add(s.localId);
break;
case "rethrow":
excluded.add(s.localId);
break;
case "tryCatch":
guarded = true;
if (s.catchLocalId !== null) excluded.add(s.catchLocalId);
break;
case "return":
returns.push(s.value);
break;
}
return true;
},
expr: (e) => {
switch (e.kind) {
case "assignExpr":
case "incDec":
excluded.add(e.localId);
break;
case "closure":
case "classRef":
for (const id of e.captures ?? []) excluded.add(id);
break;
}
return true;
},
eachNodeOfKinds(fn, host.nodes?.scan(WALK_KINDS), (node) => {
switch (node.kind) {
case "varDecl":
define(node.localId, node.init);
break;
case "assign":
define(node.localId, node.value);
break;
case "forOf":
excluded.add(node.localId);
break;
case "rethrow":
excluded.add(node.localId);
break;
case "tryCatch":
guarded = true;
if (node.catchLocalId !== null) excluded.add(node.catchLocalId);
break;
case "return":
returns.push(node.value);
break;
case "assignExpr":
case "incDec":
excluded.add(node.localId);
break;
case "closure":
case "classRef":
for (const id of node.captures ?? []) excluded.add(id);
break;
}
});
const plain = (id: string): boolean => {
const local = locals.get(id);
+71 -68
View File
@@ -1,3 +1,4 @@
import { emitPerKeyOrder } from "./key-orders.js";
import { f64Lit } from "./common.js";
import { InternalCompilerError } from "../../errors.js";
/* Structure-walking helper EMITTERS for the LLVM backend — the .ll mirror
@@ -328,78 +329,80 @@ export class LlWalkers {
if (cyclic) this.jbLeave(B);
return;
}
// Fields serialize in DECLARED order (JS insertion order); internal
// Fields serialize in DECLARED order (JS insertion order), or in the
// value's own order for a shape with orderVariants; internal
// '%'-fields stay hidden — jsonWriteHelper's contract, ported comment
// and all.
const order = shape.declaredOrder ?? shape.fields.map((f) => f.name);
const inOrder = new Set(order);
if (shape.fields.some((f) => !inOrder.has(f.name) && !f.name.startsWith("%"))) {
throw new InternalCompilerError(
`llvm emitter bug: declaredOrder of shape ${shapeId} omits a non-internal field`,
);
}
const byName = new Map(shape.fields.map((f) => [f.name, f]));
// Function-valued properties are omitted, as JSON.stringify omits them.
const emitFields = order
.map((n) => byName.get(n))
.filter((f) => f !== undefined)
.filter((f) => f.type.kind !== "func");
const droppable =
emitFields.some((f) => undefinedArmTag(f.type, this.host.unionsById) >= 0) ||
!!shape.indexValue;
this.putc(B, "%b", "123"); // '{'
if (!droppable) {
emitFields.forEach((f, i) => {
this.puts(B, "%b", `${i > 0 ? "," : ""}${jsonObjectKeyLabel(f.name)}`);
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
B.line(
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
emitPerKeyOrder(B, shape, fieldBase, "%v", (order) => {
const inOrder = new Set(order);
if (shape.fields.some((f) => !inOrder.has(f.name) && !f.name.startsWith("%"))) {
throw new InternalCompilerError(
`llvm emitter bug: declaredOrder of shape ${shapeId} omits a non-internal field`,
);
});
} else {
const first = B.slot();
B.entryAllocas.push(`${first} = alloca i1`);
B.line(`store i1 true, ptr ${first}`);
const comma = (): void => {
const isf = B.tmp();
const lc = B.newLabel("jwc.c");
const lj = B.newLabel("jwc.j");
B.line(`${isf} = load i1, ptr ${first}`);
B.condBr(isf, lj, lc);
B.startBlock(lc);
this.putc(B, "%b", "44"); // ','
B.br(lj);
B.startBlock(lj);
B.line(`store i1 false, ptr ${first}`);
};
for (const f of emitFields) {
const utag = undefinedArmTag(f.type, this.host.unionsById);
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
let skip: string | null = null;
if (utag >= 0 && f.type.kind === "union") {
// Undefined-valued field: dropped, like Node.
const tag = this.unionTag(B, v, f.type.unionId);
const isu = B.tmp();
B.line(`${isu} = icmp eq i32 ${tag}, ${utag}`);
const lw = B.newLabel("jwf.w");
skip = B.newLabel("jwf.s");
B.condBr(isu, skip, lw);
B.startBlock(lw);
}
comma();
this.puts(B, "%b", jsonObjectKeyLabel(f.name));
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
B.line(
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
);
if (skip !== null) {
B.br(skip);
B.startBlock(skip);
}
}
if (shape.indexValue) this.emitOverflowEntries(B, shape, first, edgeable(shape.indexValue));
}
const byName = new Map(shape.fields.map((f) => [f.name, f]));
// Function-valued properties are omitted, as JSON.stringify omits them.
const emitFields = order
.map((n) => byName.get(n))
.filter((f) => f !== undefined)
.filter((f) => f.type.kind !== "func");
const droppable =
emitFields.some((f) => undefinedArmTag(f.type, this.host.unionsById) >= 0) ||
!!shape.indexValue;
this.putc(B, "%b", "123"); // '{'
if (!droppable) {
emitFields.forEach((f, i) => {
this.puts(B, "%b", `${i > 0 ? "," : ""}${jsonObjectKeyLabel(f.name)}`);
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
B.line(
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
);
});
} else {
const first = B.slot();
B.entryAllocas.push(`${first} = alloca i1`);
B.line(`store i1 true, ptr ${first}`);
const comma = (): void => {
const isf = B.tmp();
const lc = B.newLabel("jwc.c");
const lj = B.newLabel("jwc.j");
B.line(`${isf} = load i1, ptr ${first}`);
B.condBr(isf, lj, lc);
B.startBlock(lc);
this.putc(B, "%b", "44"); // ','
B.br(lj);
B.startBlock(lj);
B.line(`store i1 false, ptr ${first}`);
};
for (const f of emitFields) {
const utag = undefinedArmTag(f.type, this.host.unionsById);
const v = this.loadField(B, "%v", shapeId, fieldIndex.get(f.name)!, f.type);
let skip: string | null = null;
if (utag >= 0 && f.type.kind === "union") {
// Undefined-valued field: dropped, like Node.
const tag = this.unionTag(B, v, f.type.unionId);
const isu = B.tmp();
B.line(`${isu} = icmp eq i32 ${tag}, ${utag}`);
const lw = B.newLabel("jwf.w");
skip = B.newLabel("jwf.s");
B.condBr(isu, skip, lw);
B.startBlock(lw);
}
comma();
this.puts(B, "%b", jsonObjectKeyLabel(f.name));
if (edgeable(f.type)) this.jbEdgeProp(B, f.name);
B.line(
`call void @${this.jsonWriteHelper(f.type)}(ptr %b, ${this.valTy(f.type)} ${v}) ; ${llvmCommentText(f.name)}`,
);
if (skip !== null) {
B.br(skip);
B.startBlock(skip);
}
}
if (shape.indexValue) this.emitOverflowEntries(B, shape, first, edgeable(shape.indexValue));
}
});
this.putc(B, "%b", "125"); // '}'
if (cyclic) this.jbLeave(B);
}
+27 -11
View File
@@ -3,7 +3,8 @@
import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js";
import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js";
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex } from "../ir/node-index.js";
import { hasRetainedFfiCallback } from "./ffi-callbacks.js";
import { computePublishedTypes, writesPublished } from "./publication.js";
@@ -40,6 +41,8 @@ export function computeMayThrow(
explain?: boolean;
/** computePublishedTypes(mod), when the caller already has it. */
published?: ReturnType<typeof computePublishedTypes>;
/** The module's node index, read instead of walking the bodies. */
nodes?: IrNodeIndex;
} = {},
): {
fns: Set<string>;
@@ -52,8 +55,9 @@ export function computeMayThrow(
* entry (and may therefore throw the stack-overflow RangeError). */
stackChecks?: Set<string>;
} {
const terminations = mod.workers === true ? workerTerminationPolls(mod) : null;
const stackChecks = options.stackChecks === true ? stackCheckedFunctions(mod) : null;
const terminations = mod.workers === true ? workerTerminationPolls(mod, options.nodes) : null;
const stackChecks =
options.stackChecks === true ? stackCheckedFunctions(mod, options.nodes) : null;
// Stores that can reach a published object throw the frozen TypeError.
const published =
options.published !== undefined ? options.published : computePublishedTypes(mod);
@@ -898,7 +902,19 @@ function boundedNode(rec: IrExpr | IrStmt): boolean {
* finishes after finitely many steps or reaches a polling callee. `polls`
* names the functions that poll (and therefore may unwind with the
* termination sentinel); `entry` the subset that polls on entry. */
export function workerTerminationPolls(mod: IrModule): {
/** The kinds call-graph census below can skip: boundedNode answers true
* for them, and the census reads nothing else from them. */
const CENSUS_SKIPPED_KINDS: ReadonlySet<string> = new Set(
[...BOUNDED_KINDS].filter(
(kind) =>
!["while", "doWhile", "for", "forOf", "call", "new", "virtualCall", "closure"].includes(kind),
),
);
export function workerTerminationPolls(
mod: IrModule,
nodes?: IrNodeIndex,
): {
entry: Set<string>;
polls: Set<string>;
} {
@@ -914,11 +930,12 @@ export function workerTerminationPolls(mod: IrModule): {
const edges = new Map<string, string[]>();
const entry = new Set<string>();
const polls = new Set<string>();
const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS);
for (const fn of mod.functions) {
const out: string[] = [];
let bounded = fn.async !== true && fn.generator === undefined && fn.name !== mod.entry;
let loops = false;
const visit = (rec: IrExpr | IrStmt): boolean => {
const visit = (rec: IrExpr | IrStmt): void => {
if (!boundedNode(rec)) bounded = false;
switch (rec.kind) {
case "while":
@@ -937,9 +954,8 @@ export function workerTerminationPolls(mod: IrModule): {
out.push(...(methodImpls.get(rec.method) ?? []));
break;
}
return true;
};
everyStmtList(fn.body, { expr: visit, stmt: visit });
eachNodeOfKinds(fn, scan, visit);
edges.set(
fn.name,
out.filter((callee) => names.has(callee)),
@@ -960,7 +976,7 @@ export function workerTerminationPolls(mod: IrModule): {
* library call; see boundedNode) also reaches every function that such an
* operation can invoke: closure targets and class members. Non-recursive
* functions, including leaf callbacks, stay check-free. */
export function stackCheckedFunctions(mod: IrModule): Set<string> {
export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set<string> {
const reentry = "\0reentry";
const methodImpls = new Map<string, string[]>();
const memberPrefixes = new Set<string>();
@@ -975,12 +991,13 @@ export function stackCheckedFunctions(mod: IrModule): Set<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const edges = new Map<string, string[]>();
const targets = new Set<string>();
const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS);
for (const fn of mod.functions) {
const dot = fn.name.indexOf(".");
if (dot > 0 && memberPrefixes.has(fn.name.slice(0, dot + 1))) targets.add(fn.name);
const out: string[] = [];
let reenters = false;
const visit = (rec: IrExpr | IrStmt): boolean => {
const visit = (rec: IrExpr | IrStmt): void => {
if (!boundedNode(rec)) reenters = true;
switch (rec.kind) {
case "call":
@@ -996,9 +1013,8 @@ export function stackCheckedFunctions(mod: IrModule): Set<string> {
targets.add(rec.fnName);
break;
}
return true;
};
everyStmtList(fn.body, { expr: visit, stmt: visit });
eachNodeOfKinds(fn, scan, visit);
const callees = out.filter((callee) => names.has(callee));
if (reenters) callees.push(reentry);
edges.set(fn.name, callees);
@@ -49,6 +49,7 @@ export interface NativeLinkFeatures {
/** Own properties JavaScript adds to children: scr_dyn_handle_props.c.
* Absent (false) for programs without them. */
handleProps?: boolean;
declAdopt?: boolean;
/** for await over checked-dynamic values: scr_dyn_for_await.c. Absent
* (false) for programs without it. */
dynForAwait?: boolean;
@@ -122,6 +122,7 @@ export interface CcOptions {
streamDyn?: boolean;
/** Own properties JavaScript adds to children: compiles scr_dyn_handle_props.c. */
handleProps?: boolean;
declAdopt?: boolean;
/** for await over checked-dynamic values: compiles scr_dyn_for_await.c. */
dynForAwait?: boolean;
/** events.once with options (events.onceSignal): compiles
@@ -279,6 +279,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.streamDyn === true, "scr_stream_dyn.c"),
// Own properties JavaScript adds to children.
unit((u) => u.opts.handleProps === true, "scr_dyn_handle_props.c"),
// The declaration-typed boundary's adoption.
unit((u) => u.opts.declAdopt === true, "scr_decl_adopt.c"),
// for await over checked-dynamic values.
unit((u) => u.opts.dynForAwait === true, "scr_dyn_for_await.c"),
// for await over checked-dynamic values that may hold native streams.
@@ -72,6 +72,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_itable.c",
"scr_slots.c",
"scr_alloc.c",
"scr_cipher_info.c",
] as const;
export function runtimeSrcDir(): string {
+16 -11
View File
@@ -3,7 +3,7 @@
* emitter guards them. */
import type { IrExpr, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { typeKey } from "../ir/ir.js";
import { everyStmtList } from "../ir/traverse.js";
import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js";
import { computeConstructionFacts } from "./immutability.js";
/** Array methods that write their receiver (the guarded ones). */
@@ -71,17 +71,22 @@ export class PublishedTypes {
}
}
export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
/** The nodes that publish a value: intrinsics. */
const PUBLISHING_KINDS: readonly IrNodeKind[] = ["intrinsic"];
export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): PublishedTypes | null {
const roots: IrType[] = [];
const visit = (e: IrExpr): boolean => {
// Values read from an InternTable were published by whichever thread
// stored them.
if (e.kind === "intrinsic" && (e.name === "threads.publish" || e.name === "itable.value"))
roots.push(e.type);
return true;
};
const scan = nodes?.scan(PUBLISHING_KINDS);
for (const fn of mod.functions)
everyStmtList(fn.body, { expr: visit, stmt: (_s: IrStmt) => true });
eachNodeOfKinds(fn, scan, (node) => {
// Values read from an InternTable were published by whichever thread
// stored them.
if (
node.kind === "intrinsic" &&
(node.name === "threads.publish" || node.name === "itable.value")
)
roots.push(node.type);
});
if (roots.length === 0) return null;
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
@@ -95,7 +100,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null {
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();
out.privateStores = computeConstructionFacts(mod).privateStores;
out.privateStores = computeConstructionFacts(mod, nodes).privateStores;
const seen = new Set<string>();
const stack = [...roots];
const addHierarchy = (className: string): void => {
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 33 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v33" as const;
export const RUNTIME_ABI_VERSION = 37 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v37" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -86,6 +86,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
dynTier: boolean;
streamDyn: boolean;
handleProps: boolean;
declAdopt: boolean;
dynForAwait: boolean;
eventsOnceSignal: boolean;
dynBuffer: boolean;
@@ -283,6 +284,7 @@ export function effectiveRuntimeFeatures(
dynTier: features.dynTier === true,
streamDyn: features.streamDyn === true,
handleProps: features.handleProps === true,
declAdopt: features.declAdopt === true,
dynForAwait: features.dynForAwait === true,
eventsOnceSignal: features.eventsOnceSignal === true,
dynBuffer: features.dynBuffer === true,
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -127,7 +127,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 33, marker: "scr_runtime_abi_v33" },
runtime_abi: { version: 37, marker: "scr_runtime_abi_v37" },
compiler: {
command: "clang",
identity: "fixture clang",
@@ -1,15 +1,32 @@
/* Whole-program stack-trace gate (see observesStacks). Pure function of the
* IR module; the emitter keeps a source frame in every function only when
* it answers true. */
import { exprFunctionReference, moduleFunctionReferences } from "../ir/function-references.js";
import {
exprFunctionReference,
FUNCTION_REFERENCE_KINDS,
moduleFunctionReferences,
} from "../ir/function-references.js";
import {
eachNodeOfKinds,
type IrKindScan,
type IrNodeIndex,
type IrNodeKind,
} from "../ir/node-index.js";
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { MAY_THROW_ARR_METHODS } from "../ir/ir.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import { unreachableStatements } from "./may-throw.js";
import type { PublishedTypes } from "./publication.js";
import { writesPublished } from "./publication.js";
/** The nodes the scans below read: stack reads, the "stack" literal, call
* sites and function references, declarations. */
const STACK_READ_KINDS: readonly IrNodeKind[] = ["libCall", "dynKeyGet"];
const STRING_KINDS: readonly IrNodeKind[] = ["strLit"];
const CENSUS_KINDS: readonly IrNodeKind[] = ["call", ...FUNCTION_REFERENCE_KINDS];
const DECLARATION_KINDS: readonly IrNodeKind[] = ["varDecl"];
/** Lib calls that read a captured stack trace. */
const STACK_READS: ReadonlySet<string> = new Set(["error.stack"]);
@@ -40,10 +57,13 @@ class StackKeys {
// The module walks run on the first question that needs them: a program
// without a dynamic key read asks none.
constructor(private readonly mod: IrModule) {}
constructor(
private readonly mod: IrModule,
private readonly nodes: IrNodeIndex | undefined,
) {}
private spellsStack(): boolean {
return (this.spelled ??= spellsStack(this.mod));
return (this.spelled ??= spellsStack(this.mod, this.nodes));
}
private callCensus(): {
@@ -56,20 +76,18 @@ class StackKeys {
const named = new Set(moduleFunctionReferences(mod));
// Class methods, accessors and constructors are called through dispatch.
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
const scan = this.nodes?.scan(CENSUS_KINDS);
for (const fn of mod.functions) {
if (isClassMember(fn.name, classNames)) named.add(fn.name);
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
if (expr.kind === "call") {
const sites = calls.get(expr.callee) ?? [];
sites.push({ caller: fn, args: expr.args });
calls.set(expr.callee, sites);
}
const referenced = exprFunctionReference(expr);
eachNodeOfKinds(fn, scan, (node) => {
if (node.kind === "call") {
const sites = calls.get(node.callee) ?? [];
sites.push({ caller: fn, args: node.args });
calls.set(node.callee, sites);
} else if (node.kind === "closure" || node.kind === "orDefault") {
const referenced = exprFunctionReference(node);
if (referenced !== undefined) named.add(referenced);
return true;
},
}
});
}
return (this.census = { calls, named });
@@ -127,13 +145,9 @@ class StackKeys {
let inits = this.inits.get(fn);
if (inits === undefined) {
inits = new Map();
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "varDecl")
inits!.set(stmt.localId, inits!.has(stmt.localId) ? null : (stmt.init ?? null));
return true;
},
expr: () => true,
eachNodeOfKinds(fn, this.nodes?.scan(DECLARATION_KINDS), (node) => {
if (node.kind === "varDecl")
inits!.set(node.localId, inits!.has(node.localId) ? null : (node.init ?? null));
});
this.inits.set(fn, inits);
}
@@ -161,21 +175,25 @@ function readsStack(fn: IrFunction, expr: IrExpr, keys: StackKeys): boolean {
}
/** Whether the module contains the string literal "stack". */
function spellsStack(mod: IrModule): boolean {
return mod.functions.some(
(fn) =>
!everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !(expr.kind === "strLit" && expr.value === "stack"),
}),
);
function spellsStack(mod: IrModule, nodes: IrNodeIndex | undefined): boolean {
const scan = nodes?.scan(STRING_KINDS);
return mod.functions.some((fn) => {
let found = false;
eachNodeOfKinds(fn, scan, (node) => {
found = node.kind === "strLit" && node.value === "stack";
return !found;
});
return found;
});
}
function containsStackRead(fn: IrFunction, keys: StackKeys): boolean {
return !everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !readsStack(fn, expr, keys),
function containsStackRead(fn: IrFunction, keys: StackKeys, scan: IrKindScan | undefined): boolean {
let found = false;
eachNodeOfKinds(fn, scan, (node, statement) => {
found = !statement && readsStack(fn, node as IrExpr, keys);
return !found;
});
return found;
}
/** Visit every expression of `body` that execution can reach: statements
@@ -199,9 +217,10 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void {
* Flight server keep development-only stack parsing behind `throw` stubs,
* and those reads must not cost every call of the program a frame.
* Programs without any stack read answer false without the walk. */
export function observesStacks(mod: IrModule): boolean {
const keys = new StackKeys(mod);
if (!mod.functions.some((fn) => containsStackRead(fn, keys))) return false;
export function observesStacks(mod: IrModule, nodes?: IrNodeIndex): boolean {
const keys = new StackKeys(mod, nodes);
const reads = nodes?.scan(STACK_READ_KINDS);
if (!mod.functions.some((fn) => containsStackRead(fn, keys, reads))) return false;
const byName = new Map(mod.functions.map((fn) => [fn.name, fn]));
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
const pending: string[] = [...moduleFunctionReferences(mod)];
+3 -3
View File
@@ -77,9 +77,9 @@ Options:
--npm-static <pkg[,pkg…]|auto>
compile the named npm packages' shipped JS statically as
program modules (repeatable; "auto" opts in every eligible
direct import: own .d.ts, unminified JS, no build-transform
markers; "pkg..." also names every package pkg depends
on). Packages that fail preflight are refused with a
direct import: own .d.ts, no build-transform markers;
"pkg..." also names every package pkg depends on).
Packages that fail preflight are refused with a
diagnostic. Eligible dependencies compile automatically.
--provenance-sources
EXPERIMENTAL: compile npm dependencies from their
@@ -70,6 +70,7 @@ export interface EarlyExecutableNativeFeatures {
dynTier?: boolean;
streamDyn?: boolean;
handleProps?: boolean;
declAdopt?: boolean;
dynForAwait?: boolean;
eventsOnceSignal?: boolean;
dynBuffer?: boolean;
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { applyEdits, isDeclBoundaryFile, isDeclBoundaryTypeName } from "./decl-projection.js";
describe("decl-projection", () => {
test("applyEdits closes the wrap that opened last first", () => {
// `x.y` wrapped from 0 (outer) and from 2 (inner), both closing at 5:
// closing parts rank by their wrap's negated start.
const out = applyEdits("x = a.b;", [
{ start: 4, end: 4, text: "(" },
{ start: 7, end: 7, text: " as Outer)", rank: -4 },
{ start: 6, end: 6, text: "(" },
{ start: 7, end: 7, text: " as Inner)", rank: -6 },
]);
expect(out).toBe("x = (a.(b as Inner) as Outer);");
});
test("applyEdits keeps the first of overlapping replacements and every line", () => {
const text = "type A = z.infer<typeof S>;\nconst x = 1;";
const out = applyEdits(text, [
{ start: 9, end: 26, text: "__scriptc_decl_0" },
{ start: 11, end: 16, text: "nope" },
]);
expect(out).toBe("type A = __scriptc_decl_0;\nconst x = 1;");
});
test("names", () => {
expect(isDeclBoundaryTypeName("__scriptc_decl_3")).toBe(true);
expect(isDeclBoundaryTypeName("Product")).toBe(false);
expect(isDeclBoundaryFile("/app/src/__scriptc_decl_boundary__.d.ts")).toBe(true);
expect(isDeclBoundaryFile("/app/src/types.d.ts")).toBe(false);
});
});
@@ -0,0 +1,870 @@
/* The declaration-typed boundary's projection pass.
*
* An opted-in npm package compiles from its JavaScript, and the program the
* lowering compiles types it from that JavaScript: where the package's
* published declarations are richer (zod's `z.infer`), user code sees `any`.
* This pass asks the DECLARATION world (the same entry checked against the
* packages' declarations) for the types of those user-code positions. Where
* the declared type is pure data, it is printed as structural TypeScript into
* a generated global declaration file, and the user file is rewritten in the
* lowering world:
*
* - type positions that name package types (`z.infer<typeof S>`) name the
* projected alias instead;
* - package-produced expressions (`S.parse(x)`, `r.data`) are wrapped in
* `(<expr> as __scriptc_decl_N)`, the boundary cast;
* - untyped parameters of user callbacks the package calls gain the alias.
*
* The lowering world is then rebuilt from the rewritten text, so the
* checker's own inference carries the projected types through the rest of
* the user code, and the lowering sees an ordinary checked cast at each
* crossing (lowered with `adopt`, see narrowing.ts). No edit adds or removes
* a line break, so positions keep their lines. IR records are structural,
* so a printed type interns to the same shape as any equal type.
*
* Only programs whose user code has such positions are rewritten; every
* other program is untouched (the zero-cost rule). See
* scriptc-perf/results/design-decl-boundary/DESIGN.md. */
import * as ts from "./ts7/adapter.js";
/** Alias names of projected types start with this prefix; the lowering
* recognizes boundary casts and parameters by it. */
export const DECL_BOUNDARY_PREFIX = "__scriptc_decl_";
/** The generated declaration file's base name. */
export const DECL_BOUNDARY_FILE = "__scriptc_decl_boundary__.d.ts";
export function isDeclBoundaryFile(fileName: string): boolean {
return fileName.split("\\").join("/").endsWith(`/${DECL_BOUNDARY_FILE}`);
}
export function isDeclBoundaryTypeName(name: string): boolean {
return name.startsWith(DECL_BOUNDARY_PREFIX);
}
/** True for `T` in `x as T` / `(p: T) =>` when T names a projected alias. */
export function isDeclBoundaryTypeNode(node: ts.TypeNode | undefined): boolean {
return (
node !== undefined &&
ts.isTypeReferenceNode(node) &&
ts.isIdentifier(node.typeName) &&
isDeclBoundaryTypeName(node.typeName.text) &&
node.typeArguments === undefined
);
}
interface Edit {
start: number;
end: number;
text: string;
/** Tie-break among edits at one position (lower first). */
rank?: number;
}
export interface DeclProjection {
/** Rewritten text per user file. */
files: Map<string, string>;
/** The generated declaration file (path and text). */
declFile: string;
declText: string;
/** Human-readable plan (SCRIPTC_DEBUG_DECL_BOUNDARY). */
plan: string[];
}
export interface DeclProjectionInput {
jsProgram: ts.Program;
declProgram: ts.Program;
/** User source files to consider (TypeScript, outside node_modules). */
userFiles: readonly string[];
/** Directory of the generated declaration file. */
outDir: string;
}
function isPackageDeclarationFile(sf: ts.SourceFile | undefined): boolean {
if (sf === undefined || !sf.isDeclarationFile) return false;
const f = sf.fileName.split("\\").join("/");
// The TypeScript standard library ships inside node_modules too.
if (/(^|\/)lib(\.[\w.-]+)?\.d\.ts$/.test(f) || f.startsWith("bundled:")) return false;
return (
f.includes("/node_modules/") &&
!f.includes("/node_modules/@types/node/") &&
!f.includes("/node_modules/typescript/") &&
!f.includes("/node_modules/@typescript/")
);
}
function declaredInPackage(symbol: ts.Symbol | undefined): boolean {
if (symbol === undefined) return false;
return symbol.declarations.some((d) => isPackageDeclarationFile(ts.getSourceFileOfNode(d)));
}
/** The parts of a call signature's declaration that pass-through detection
* reads, for the declaration kinds a resolved call signature has. */
function signatureParts(node: ts.Node | undefined): {
typeParameters: readonly ts.TypeParameterDeclaration[];
parameters: readonly ts.ParameterDeclaration[];
type: ts.TypeNode | undefined;
} | null {
if (node === undefined) return null;
if (ts.isFunctionTypeNode(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isFunctionDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isMethodDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isMethodSignatureDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isCallSignatureDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
return null;
}
/** A signature that returns one of its own type parameters which also types
* a parameter (`<T extends object>(obj: T) => T`) hands back the argument
* or something standing in for it, a proxy or a marked object, whose
* identity the package may track (Effect's `Equal.byReference`). Its result
* type comes from the caller's argument, not from data the package
* declares, so the boundary leaves it alone: converting it would build a
* separate native value. */
function passesArgumentThrough(declaration: ts.Node | undefined): boolean {
const sig = signatureParts(declaration);
if (sig === null || sig.type === undefined || !ts.isTypeReferenceNode(sig.type)) return false;
const ret = sig.type;
if (!ts.isIdentifier(ret.typeName) || ret.typeArguments !== undefined) return false;
const name = ret.typeName.text;
if (!sig.typeParameters.some((tp) => tp.name.text === name)) return false;
return sig.parameters.some(
(param) =>
param.type !== undefined &&
ts.isTypeReferenceNode(param.type) &&
ts.isIdentifier(param.type.typeName) &&
param.type.typeName.text === name,
);
}
function declaredInUserFile(symbol: ts.Symbol | undefined): boolean {
if (symbol === undefined) return false;
const decls = symbol.declarations;
return (
decls.length > 0 &&
decls.every((d) => {
const sf = ts.getSourceFileOfNode(d);
return !sf.isDeclarationFile && !sf.fileName.split("\\").join("/").includes("/node_modules/");
})
);
}
const ANYISH = ts.TypeFlags.Any | ts.TypeFlags.Unknown;
const READONLY_CHECK_FLAG = 8;
/** Prints declaration-world types as structural TypeScript, or answers null
* when a type is not pure data (DESIGN.md §4.3). */
class TypePrinter {
readonly aliases = new Map<string, string>();
readonly declarations: string[] = [];
private readonly inProgress = new Map<number, { name: string | null }>();
constructor(private readonly checker: ts.TypeChecker) {}
/** The alias naming a projectable type, or null. Top-level any/unknown
* never projects. */
project(type: ts.Type): string | null {
if (type.flags & ANYISH) return null;
const text = this.print(type, 0);
if (text === null || text === "unknown") return null;
let name = this.aliases.get(text);
if (name === undefined) {
name = `${DECL_BOUNDARY_PREFIX}${this.aliases.size}`;
this.aliases.set(text, name);
this.declarations.push(`type ${name} = ${text};`);
}
return name;
}
private print(type: ts.Type, depth: number): string | null {
if (depth > 24) return null;
const f = type.flags;
if (f & ANYISH) return "unknown";
if (f & ts.TypeFlags.String) return "string";
if (f & ts.TypeFlags.Number) return "number";
if (f & ts.TypeFlags.Boolean) return "boolean";
if (f & ts.TypeFlags.Null) return "null";
if (f & ts.TypeFlags.Undefined) return "undefined";
if (f & ts.TypeFlags.EnumLiteral || f & ts.TypeFlags.Enum) return null;
if (f & ts.TypeFlags.BooleanLiteral) return type.intrinsicName === "true" ? "true" : "false";
if (f & ts.TypeFlags.StringLiteral) return JSON.stringify(String(type.value));
if (f & ts.TypeFlags.NumberLiteral) {
const v = Number(type.value);
return Number.isFinite(v) ? String(v) : null;
}
if (type.isUnionType()) {
const parts: string[] = [];
for (const arm of type.getTypes()) {
const p = this.print(arm, depth + 1);
if (p === null || p === "unknown") return null;
if (!parts.includes(p)) parts.push(p);
}
// `true | false` reads better as boolean, and maps the same.
const t = parts.indexOf("true");
const fl = parts.indexOf("false");
if (t >= 0 && fl >= 0) {
parts.splice(Math.max(t, fl), 1);
parts.splice(Math.min(t, fl), 1, "boolean");
}
return parts.map((p) => (/=>/.test(p) ? `(${p})` : p)).join(" | ");
}
if (type.isIntersectionType()) {
// Brands: drop members that only carry unique-symbol keys.
const kept = type.getTypes().filter((m) => !this.isBrandOnly(m));
if (kept.length === 1) return this.print(kept[0]!, depth + 1);
if (kept.length === 0) return null;
if (!kept.every((m) => (m.flags & ts.TypeFlags.Object) !== 0)) return null;
return this.printObject(type, depth);
}
if (f & ts.TypeFlags.Object) return this.printObject(type, depth);
return null;
}
private isBrandOnly(type: ts.Type): boolean {
if (!(type.flags & ts.TypeFlags.Object) || type.isIntersectionType()) return false;
const props = this.checker.getPropertiesOfType(type);
return props.length > 0 && props.every((p) => String(p.escapedName).startsWith("__@"));
}
/** Whether an interface type and every type it extends are declared in
* user files. */
private userInterface(type: ts.Type, seen = new Set<ts.Type>()): boolean {
if (!declaredInUserFile(type.getSymbol())) return false;
const target = type.isTypeReference() ? type.getTarget() : type;
if (target === undefined || !target.isClassOrInterface() || seen.has(target)) return true;
seen.add(target);
return this.checker.getBaseTypes(target).every((base) => this.userInterface(base, seen));
}
private printObject(type: ts.Type, depth: number): string | null {
const checker = this.checker;
if (checker.isArrayType(type)) {
const [elem] = checker.getTypeArguments(type);
if (elem === undefined) return null;
const e = this.print(elem, depth + 1);
if (e === null) return null;
const ro = type.getSymbol()?.escapedName === "ReadonlyArray" ? "readonly " : "";
return `${ro}(${e})[]`;
}
if (checker.isTupleType(type)) {
const target = type.getTarget() ?? type;
const flags = target.elementFlags ?? [];
if (flags.some((fl) => fl !== ts.ElementFlags.Required)) return null;
const elems = checker.getTypeArguments(type).slice(0, flags.length);
const out: string[] = [];
for (const e of elems) {
const p = this.print(e, depth + 1);
if (p === null) return null;
out.push(p);
}
return `${target.readonly === true ? "readonly " : ""}[${out.join(", ")}]`;
}
const symbol = type.getSymbol();
if (symbol !== undefined) {
if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Function | ts.SymbolFlags.Method))
return null;
// Interfaces: only user-declared ones describe plain data reliably
// (package and lib interfaces often describe class instances), and
// only when every interface they extend is user-declared too: a user
// interface extending a package one (effect's Request.Request)
// inherits members the package's own constructors create.
if (symbol.flags & ts.SymbolFlags.Interface && !this.userInterface(type)) return null;
}
if (
checker.getCallSignatures(type).length > 0 ||
checker.getConstructSignatures(type).length > 0
)
return null;
const memo = this.inProgress.get(type.id);
if (memo !== undefined) {
memo.name ??= `${DECL_BOUNDARY_PREFIX}r${type.id}`;
return memo.name;
}
const frame: { name: string | null } = { name: null };
this.inProgress.set(type.id, frame);
try {
const members: string[] = [];
let readonlyCount = 0;
const props = checker.getPropertiesOfType(type);
for (const prop of props) {
const key = String(prop.escapedName);
if (key.startsWith("__@")) continue; // symbol-keyed (brands)
if (
prop.flags &
(ts.SymbolFlags.Method | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)
)
return null;
const propType = checker.getTypeOfSymbol(prop);
const optional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
let p = this.print(propType, depth + 1);
if (p === null) return null;
const readonly = (prop.checkFlags & READONLY_CHECK_FLAG) !== 0 || hasReadonlyModifier(prop);
if (readonly) readonlyCount++;
const name = /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
if (optional && !/\bundefined\b/.test(p)) p = `${p} | undefined`;
members.push(`${readonly ? "readonly " : ""}${name}${optional ? "?" : ""}: ${p}`);
}
for (const info of checker.getIndexInfosOfType(type)) {
if (!(info.keyType.flags & ts.TypeFlags.String)) return null;
const v = this.print(info.type, depth + 1);
if (v === null) return null;
members.push(`[key: string]: ${v}`);
}
if (members.length === 0) return null; // `{}` admits any non-nullish value
// All-readonly object types may describe frozen values (.readonly()).
if (props.length > 0 && readonlyCount === props.length) return null;
const text = `{ ${members.join("; ")} }`;
if (frame.name !== null) {
this.declarations.push(`type ${frame.name} = ${text};`);
return frame.name;
}
return text;
} finally {
this.inProgress.delete(type.id);
}
}
}
function hasReadonlyModifier(prop: ts.Symbol): boolean {
return prop.declarations.some(
(d) =>
(ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) &&
(d.modifiers?.nodes ?? []).some((m) => m.kind === ts.SyntaxKind.ReadonlyKeyword),
);
}
/** Children of a node in source order (forEachChild order). */
function childrenOf(node: ts.Node): ts.Node[] {
const out: ts.Node[] = [];
ts.forEachChild(
node,
(c) => {
out.push(c);
return undefined;
},
(list) => {
for (const c of list.nodes) out.push(c);
return undefined;
},
);
return out;
}
function isWriteTarget(node: ts.Node): boolean {
let child = node;
let parent = node.parent;
while (parent !== undefined && ts.isParenthesizedExpression(parent)) {
child = parent;
parent = parent.parent;
}
if (parent === undefined) return false;
if (ts.isBinaryExpression(parent) && parent.left === child) {
const k = parent.operatorToken.kind;
return k >= ts.SyntaxKind.FirstAssignment && k <= ts.SyntaxKind.LastAssignment;
}
if (ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) {
const op = parent.operator;
return op === ts.SyntaxKind.PlusPlusToken || op === ts.SyntaxKind.MinusMinusToken;
}
if (ts.isDeleteExpression(parent)) return true;
if (ts.isForOfStatement(parent) || ts.isForInStatement(parent))
return parent.initializer === child;
if (ts.isShorthandPropertyAssignment(parent)) return true;
if (
ts.isArrayLiteralExpression(parent) ||
ts.isPropertyAssignment(parent) ||
ts.isSpreadElement(parent)
) {
// Destructuring assignment targets: conservative, skip.
let up: ts.Node | undefined = parent;
while (
up !== undefined &&
(ts.isArrayLiteralExpression(up) ||
ts.isObjectLiteralExpression(up) ||
ts.isPropertyAssignment(up) ||
ts.isSpreadElement(up))
)
up = up.parent;
if (
up !== undefined &&
ts.isBinaryExpression(up) &&
up.operatorToken.kind === ts.SyntaxKind.EqualsToken
)
return true;
}
return false;
}
function inOptionalChain(node: ts.Node): boolean {
const parent = node.parent;
if (parent === undefined) return false;
if (
(ts.isPropertyAccessExpression(parent) ||
ts.isElementAccessExpression(parent) ||
ts.isCallExpression(parent)) &&
parent.expression === node &&
(parent.questionDotToken !== undefined || (parent.flags & ts.NodeFlags.OptionalChain) !== 0)
)
return true;
return (
(ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node)) &&
(node.flags & ts.NodeFlags.OptionalChain) !== 0
);
}
function isCallee(node: ts.Node): boolean {
const p = node.parent;
return (
p !== undefined &&
(ts.isCallExpression(p) || ts.isNewExpression(p) || ts.isTaggedTemplateExpression(p)) &&
(ts.isTaggedTemplateExpression(p) ? p.tag === node : p.expression === node)
);
}
function inTypeContext(node: ts.Node): boolean {
for (let p = node.parent; p !== undefined; p = p.parent) {
if (ts.isTypeNode(p) || ts.isTypeQueryNode(p)) return true;
if (ts.isExpressionWithTypeArguments(p) && p.parent && ts.isHeritageClause(p.parent))
return true;
if (ts.isStatement(p)) return false;
}
return false;
}
function unwrapTransparent(node: ts.Expression): ts.Expression {
let e = node;
while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e) || ts.isAwaitExpression(e))
e = e.expression;
return e;
}
/** Plans the projection for one program pair. Answers null when nothing
* in the user files projects (the program stays as it is). */
export function planDeclProjection(input: DeclProjectionInput): DeclProjection | null {
const jsChecker = input.jsProgram.getTypeChecker();
const declChecker = input.declProgram.getTypeChecker();
const printer = new TypePrinter(declChecker);
const plan: string[] = [];
const edits = new Map<string, Edit[]>();
const typeAt = (checker: ts.TypeChecker, node: ts.Node): ts.Type | null => {
try {
return checker.getTypeAtLocation(node);
} catch {
return null;
}
};
const isAnyish = (t: ts.Type | null): boolean => t !== null && (t.flags & ANYISH) !== 0;
const resolveAlias = (s: ts.Symbol | undefined): ts.Symbol | undefined => {
if (s === undefined || !(s.flags & ts.SymbolFlags.Alias)) return s;
try {
return declChecker.getAliasedSymbol(s);
} catch {
return s;
}
};
const declSymbol = (n: ts.Node): ts.Symbol | undefined => {
try {
return resolveAlias(declChecker.getSymbolAtLocation(n));
} catch {
return undefined;
}
};
const namesPackageSymbol = (node: ts.Node): boolean => {
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
if (ts.isIdentifier(n) && declaredInPackage(declSymbol(n))) {
found = true;
return;
}
for (const c of childrenOf(n)) visit(c);
};
visit(node);
return found;
};
for (const file of input.userFiles) {
const jsSf = input.jsProgram.getSourceFile(file);
const declSf = input.declProgram.getSourceFile(file);
if (jsSf === undefined || declSf === undefined || jsSf.text !== declSf.text) continue;
const fileEdits: Edit[] = [];
/** Candidate expressions (by js node) with their alias. */
const exprCandidates = new Map<ts.Node, string>();
const exprOrder: ts.Node[] = [];
const considerType = (jsNode: ts.Node, declNode: ts.Node): boolean => {
if (
!ts.isTypeReferenceNode(jsNode) &&
!ts.isIndexedAccessTypeNode(jsNode) &&
!ts.isExpressionWithTypeArguments(jsNode)
)
return false;
if (ts.isExpressionWithTypeArguments(jsNode)) {
if (!jsNode.parent || !ts.isHeritageClause(jsNode.parent)) return false;
if (!jsNode.parent.parent || !ts.isInterfaceDeclaration(jsNode.parent.parent)) return false;
}
let jsType: ts.Type | null;
let declType: ts.Type | null;
try {
jsType = ts.isExpressionWithTypeArguments(jsNode)
? jsChecker.getTypeAtLocation(jsNode)
: jsChecker.getTypeFromTypeNode(jsNode as ts.TypeNode);
declType = ts.isExpressionWithTypeArguments(declNode)
? declChecker.getTypeAtLocation(declNode)
: declChecker.getTypeFromTypeNode(declNode as ts.TypeNode);
} catch {
return false;
}
if (!isAnyish(jsType) || declType === null) return false;
if (!namesPackageSymbol(declNode)) return false;
const alias = printer.project(declType);
if (alias === null) return false;
const start = ts.tokenStartOfNode(jsNode);
fileEdits.push({ start, end: jsNode.end, text: alias });
plan.push(`${file}:${start}: type ${jsSf.text.slice(start, jsNode.end)} -> ${alias}`);
return true;
};
const considerExpr = (jsNode: ts.Node, declNode: ts.Node): void => {
if (!ts.isExpression(jsNode)) return;
const isCall =
ts.isCallExpression(jsNode) ||
ts.isNewExpression(jsNode) ||
ts.isTaggedTemplateExpression(jsNode);
const isAccess =
ts.isPropertyAccessExpression(jsNode) || ts.isElementAccessExpression(jsNode);
const isAwait = ts.isAwaitExpression(jsNode);
if (!isCall && !isAccess && !isAwait && !ts.isIdentifier(jsNode)) return;
if (inTypeContext(jsNode) || isCallee(jsNode) || isWriteTarget(jsNode)) return;
if (inOptionalChain(jsNode)) return;
const parent = jsNode.parent;
if (parent !== undefined && ts.isExpressionStatement(parent)) return;
if (
parent !== undefined &&
(ts.isImportSpecifier(parent) ||
ts.isImportClause(parent) ||
ts.isNamespaceImport(parent) ||
ts.isExportSpecifier(parent))
)
return;
if (ts.isIdentifier(jsNode)) {
if (parent !== undefined && ts.isPropertyAccessExpression(parent) && parent.name === jsNode)
return;
}
const jsType = typeAt(jsChecker, jsNode);
if (!isAnyish(jsType)) return;
// Package-produced?
const d = declNode as ts.Expression;
let produced = false;
if (ts.isCallExpression(d) || ts.isNewExpression(d)) {
const callee = d.expression;
const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
let decl: ts.Node | undefined;
try {
decl = declChecker.getResolvedSignature(d)?.declaration;
} catch {
decl = undefined;
}
produced =
(declaredInPackage(declSymbol(target)) ||
(decl !== undefined && isPackageDeclarationFile(ts.getSourceFileOfNode(decl)))) &&
!passesArgumentThrough(decl);
} else if (ts.isTaggedTemplateExpression(d)) {
produced = declaredInPackage(declSymbol(d.tag));
} else if (ts.isPropertyAccessExpression(d) || ts.isElementAccessExpression(d)) {
const recvDecl = typeAt(declChecker, d.expression);
const recvJs = typeAt(jsChecker, (jsNode as ts.PropertyAccessExpression).expression);
const recvProjectable = recvDecl !== null && printer.project(recvDecl) !== null;
produced = !recvProjectable || (recvJs !== null && !isAnyish(recvJs));
} else if (ts.isAwaitExpression(d)) {
const inner = unwrapTransparent(d.expression);
produced =
(ts.isCallExpression(inner) || ts.isPropertyAccessExpression(inner)) &&
(declaredInPackage(
declSymbol(
ts.isCallExpression(inner)
? ts.isPropertyAccessExpression(inner.expression)
? inner.expression.name
: inner.expression
: inner.name,
),
) ||
isAnyish(typeAt(jsChecker, inner)));
} else if (ts.isIdentifier(d)) {
const raw = (() => {
try {
return declChecker.getSymbolAtLocation(d);
} catch {
return undefined;
}
})();
produced =
raw !== undefined &&
(raw.flags & ts.SymbolFlags.Alias) !== 0 &&
declaredInPackage(resolveAlias(raw));
}
if (!produced) return;
// A consumer that takes anything needs no conversion.
try {
const ctx = declChecker.getContextualType(d);
if (ctx !== undefined && ctx.flags & ANYISH) return;
} catch {
// no contextual type
}
const declType = typeAt(declChecker, d);
if (declType === null) return;
const alias = printer.project(declType);
if (alias === null) return;
exprCandidates.set(jsNode, alias);
exprOrder.push(jsNode);
};
const annotated = new Set<ts.Node>();
const considerParam = (jsNode: ts.Node, declNode: ts.Node): void => {
if (!ts.isParameter(jsNode) || jsNode.type !== undefined || !ts.isIdentifier(jsNode.name))
return;
const fn = jsNode.parent;
if (fn === undefined || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return;
if (jsNode.dotDotDotToken !== undefined) return;
const jsType = typeAt(jsChecker, jsNode.name);
if (!isAnyish(jsType)) return;
const declType = typeAt(declChecker, (declNode as ts.ParameterDeclaration).name);
if (declType === null) return;
// Only parameters the package's declarations type (a callback the
// package calls), not ones the user typed elsewhere.
try {
const ctxFn = declChecker.getContextualType(declNode.parent as ts.Expression);
if (ctxFn === undefined) return;
const sigs = declChecker.getCallSignatures(ctxFn);
if (
!sigs.some(
(s) =>
s.declaration !== undefined &&
isPackageDeclarationFile(ts.getSourceFileOfNode(s.declaration)),
)
)
return;
} catch {
return;
}
const alias = printer.project(declType);
if (alias === null) return;
annotated.add(jsNode);
const name = jsNode.name;
const nameStart = ts.tokenStartOfNode(name);
const text = jsSf.text;
// `x => ...` or `async x => ...`: nothing but the modifiers comes
// before the parameter, which then needs parentheses for its type.
const head = ts.isArrowFunction(fn)
? ((ts.getModifiers(fn) ?? []).at(-1)?.end ?? ts.tokenStartOfNode(fn))
: nameStart;
const bareArrow =
ts.isArrowFunction(fn) &&
fn.parameters.nodes.length === 1 &&
text.slice(head, nameStart).trim() === "";
const afterName = jsNode.questionToken !== undefined ? jsNode.questionToken.end : name.end;
if (bareArrow) {
fileEdits.push({ start: nameStart, end: nameStart, text: "(" });
fileEdits.push({ start: afterName, end: afterName, text: `: ${alias})` });
} else {
fileEdits.push({ start: afterName, end: afterName, text: `: ${alias}` });
}
plan.push(`${file}:${nameStart}: parameter ${name.text}: ${alias}`);
};
const walk = (jsNode: ts.Node, declNode: ts.Node): void => {
if (jsNode.kind !== declNode.kind) return;
if (ts.isTypeNode(jsNode) || ts.isExpressionWithTypeArguments(jsNode)) {
if (considerType(jsNode, declNode)) return;
} else {
considerParam(jsNode, declNode);
considerExpr(jsNode, declNode);
}
const a = childrenOf(jsNode);
const b = childrenOf(declNode);
if (a.length !== b.length) return;
for (let i = 0; i < a.length; i++) walk(a[i]!, b[i]!);
};
walk(jsSf, declSf);
// Suppress candidates whose receiver chain another candidate types.
const symbolTyped = new Map<ts.Symbol, boolean>();
const willBeTyped = (e: ts.Node, depth = 0): boolean => {
if (depth > 32) return false;
if (exprCandidates.has(e)) return true;
if (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e) || ts.isAwaitExpression(e))
return willBeTyped(e.expression, depth + 1);
if (ts.isAsExpression(e)) return false;
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
return willBeTyped(e.expression, depth + 1);
if (ts.isIdentifier(e)) {
let sym: ts.Symbol | undefined;
try {
sym = jsChecker.getSymbolAtLocation(e);
} catch {
return false;
}
if (sym === undefined) return false;
const known = symbolTyped.get(sym);
if (known !== undefined) return known;
symbolTyped.set(sym, false);
let result = false;
const decls = sym.declarations;
if (decls.length === 1) {
const decl = decls[0]!;
if (
ts.isVariableDeclaration(decl) &&
decl.initializer !== undefined &&
decl.type === undefined
)
result = willBeTyped(decl.initializer, depth + 1);
else if (ts.isParameter(decl) && annotated.has(decl)) result = true;
else if (ts.isBindingElement(decl)) result = false;
}
symbolTyped.set(sym, result);
return result;
}
return false;
};
const receiverOf = (e: ts.Node): ts.Node | null => {
if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) return e.expression;
if (ts.isIdentifier(e)) return null;
return null;
};
const kept: ts.Node[] = [];
for (const e of exprOrder) {
const recv = receiverOf(e);
if (recv !== null && willBeTyped(recv)) continue;
// An identifier candidate that is a user binding never gets here
// (only package imports are candidates).
kept.push(e);
}
// Nested candidates: keep only the innermost (an outer one is typed by
// the inner wrap).
const keptSet = new Set(kept);
const containsKept = (outer: ts.Node): boolean => {
let found = false;
const visit = (n: ts.Node): void => {
if (found) return;
for (const c of childrenOf(n)) {
if (keptSet.has(c)) {
found = true;
return;
}
visit(c);
}
};
visit(outer);
return found;
};
for (const e of kept) {
if (
!ts.isAwaitExpression(e) &&
containsKept(e) &&
(ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e))
) {
// `a.b.c` with `a.b` wrapped: the outer access reads typed data.
const recv = receiverOf(e);
if (
recv !== null &&
(keptSet.has(unwrapTransparent(recv as ts.Expression)) || willBeTyped(recv))
)
continue;
}
const alias = exprCandidates.get(e)!;
const start = ts.tokenStartOfNode(e);
const stmt = e.parent;
let prefix = "(";
// A statement that would start with `(` after a line without a
// semicolon continues the previous expression: guard it.
let up: ts.Node | undefined = e;
while (up?.parent !== undefined && !ts.isStatement(up.parent)) up = up.parent;
// Only a statement in a statement list can follow such a line; an
// unbraced body (`for (...) S.parse(x).run()`) follows its header,
// and a `;` there would become the body.
const list = up?.parent?.parent;
if (
up?.parent !== undefined &&
ts.isExpressionStatement(up.parent) &&
ts.tokenStartOfNode(up.parent) === start &&
list !== undefined &&
(ts.isBlock(list) ||
ts.isSourceFile(list) ||
ts.isModuleBlock(list) ||
ts.isCaseClause(list) ||
ts.isDefaultClause(list))
)
prefix = ";(";
void stmt;
fileEdits.push({ start, end: start, text: prefix });
// Closing parts at one position close inner wraps first.
fileEdits.push({ start: e.end, end: e.end, text: ` as ${alias})`, rank: -start });
plan.push(
`${file}:${start}: cast ${jsSf.text.slice(start, Math.min(e.end, start + 60)).replace(/\s+/g, " ")} as ${alias}`,
);
}
if (fileEdits.length === 0) continue;
edits.set(file, fileEdits);
}
if (edits.size === 0) return null;
const files = new Map<string, string>();
for (const [file, list] of edits) {
const sf = input.jsProgram.getSourceFile(file)!;
files.set(file, applyEdits(sf.text, list));
}
const declFile = `${input.outDir.split("\\").join("/")}/${DECL_BOUNDARY_FILE}`;
const declText =
"// Generated by scriptc: types projected from npm package declarations.\n" +
printer.declarations.join("\n") +
"\n";
return { files, declFile, declText, plan };
}
/** Applies insertions and replacements; overlapping replacements keep the
* first. Insertions at one position keep their order. */
export function applyEdits(text: string, edits: readonly Edit[]): string {
const sorted = edits
.map((e, i) => ({ ...e, i }))
.sort(
(a, b) => a.start - b.start || a.end - b.end || (a.rank ?? 0) - (b.rank ?? 0) || a.i - b.i,
);
let out = "";
let at = 0;
for (const e of sorted) {
if (e.start < at) continue; // overlaps an earlier replacement
out += text.slice(at, e.start) + e.text;
at = e.end;
}
return out + text.slice(at);
}
@@ -869,9 +869,17 @@ const IMPLEMENTED_CIPHERS = new Set([
"id-aes192-gcm",
"id-aes256-gcm",
"chacha20-poly1305",
...["128", "192", "256"].flatMap((bits) =>
["ecb", "cfb", "cfb8", "cfb1", "ofb"].map((mode) => `aes-${bits}-${mode}`),
),
...["128", "192", "256"].flatMap((bits) => [
...["ecb", "cfb", "cfb8", "cfb1", "ofb", "ccm"].map((mode) => `aes-${bits}-${mode}`),
`id-aes${bits}-ccm`,
...["wrap", "wrap-pad"].flatMap((wrap) => [
`aes-${bits}-${wrap}`,
`aes${bits}-${wrap}`,
`id-aes${bits}-${wrap}`,
`aes-${bits}-${wrap}-inv`,
`aes${bits}-${wrap}-inv`,
]),
]),
"des-ede3-cbc",
"des3",
"des-ede3",
@@ -889,9 +897,10 @@ const IMPLEMENTED_CIPHERS = new Set([
]);
const SUPPORTED_CIPHERS_HINT =
"the supported ciphers are aes-128, aes-192 and aes-256 in cbc, ctr, gcm, ecb, cfb, cfb8, cfb1 " +
"and ofb modes, des-ede3 in cbc, ecb, cfb, cfb8, cfb1 and ofb modes, des-ede in cbc, ecb, cfb " +
"and ofb modes, chacha20, and chacha20-poly1305";
"the supported ciphers are aes-128, aes-192 and aes-256 in cbc, ctr, gcm, ccm, ecb, cfb, cfb8, " +
"cfb1 and ofb modes and their wrap, wrap-pad, wrap-inv and wrap-pad-inv key wraps, des-ede3 in " +
"cbc, ecb, cfb, cfb8, cfb1 and ofb modes, des-ede in cbc, ecb, cfb and ofb modes, chacha20, and " +
"chacha20-poly1305";
/** A literal cipher name that Node knows but the runtime does not
* implement is refused at compile time. Unknown literal names keep Node's
@@ -1380,10 +1389,11 @@ function takesUndefinedError(lowerer: Lowerer, callback: ts.Expression): boolean
}
/** checkPrime, checkPrimeSync, generatePrime and generatePrimeSync
* (scr_dh.c): checked-dynamic arguments, which the runtime checks as Node
* does (a function in the options position is the callback), and the
* sync forms' boolean, ArrayBuffer or bigint converted to the call site's
* type. Null for other members. */
* (scr_dh.c), and getCipherInfo (scr_cipher_info.c): checked-dynamic
* arguments, which the runtime checks as Node does (a function in the
* options position is the callback), and the sync forms' boolean,
* ArrayBuffer, bigint or info object converted to the call site's type.
* Null for other members. */
function lowerPrimeCall(
lowerer: Lowerer,
expr: ts.CallExpression,
@@ -1395,6 +1405,7 @@ function lowerPrimeCall(
checkPrimeSync: ["crypto.checkPrimeSync", 1, 2, "checkPrimeSync(candidate[, options])"],
checkPrime: ["crypto.checkPrimeCb", 1, 3, "checkPrime(candidate[, options], callback)"],
generatePrimeSync: ["crypto.generatePrimeSync", 1, 2, "generatePrimeSync(size[, options])"],
getCipherInfo: ["crypto.getCipherInfo", 0, 2, "getCipherInfo(nameOrNid[, options])"],
generatePrime: ["crypto.generatePrimeCb", 1, 3, "generatePrime(size[, options], callback)"],
};
const form = forms[member];
@@ -1455,14 +1466,10 @@ function lowerPkiCall(
expr,
`${supported} is supported`,
);
// generateKeyPair(type, callback): Node reads a function in the options
// position as the callback.
const shifted = fn === "crypto.generateKeyPairCb" && args.length === 2;
// generateKeyPair(type, callback): the runtime reads a function in the
// options position as the callback, as Node does.
const values: IrExpr[] = [];
for (let i = 0; i < max; i++) {
const node = shifted ? (i === 1 ? undefined : i === 2 ? args[1] : args[i]) : args[i];
values.push(dynArg(lowerer, node, loc));
}
for (let i = 0; i < max; i++) values.push(dynArg(lowerer, args[i], loc));
if (fn === "crypto.generateKeyPairCb")
return { kind: "libCall", fn, args: values, type: VOID, loc };
const call: IrExpr = { kind: "libCall", fn, args: values, type: DYN, loc };
@@ -6,9 +6,11 @@ import {
DYN,
F64,
type IrExpr,
type IrType,
type IrStmt,
type SrcLoc,
STRING,
UNDEFINED_T,
VOID,
arrayOf,
BYTES_U8,
@@ -54,6 +56,21 @@ export function isReadlineTyped(lowerer: Lowerer, node: ts.Expression): boolean
});
}
/** `rl.cursor`: @types/node declares a number, but an interface that is
* not a terminal answers undefined, so the read keeps its
* `number | undefined` union. */
export function isOptionalReadlineCursorRead(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
): boolean {
return (
expr.name.text === "cursor" &&
!expr.questionDotToken &&
isReadlineTyped(lowerer, expr.expression) &&
lowerer.isStdlibMember(expr)
);
}
/** The runtime's listener event numbers (scr_readline.c). */
const RL_EVENTS: Record<string, number> = {
line: 0,
@@ -1341,7 +1358,7 @@ export function lowerReadlineCreateCall(
/** Property reads on an Interface: `history` (the history option itself,
* or the interface's own empty array), `historySize`, `historyIndex`,
* `crlfDelay`, `tabSize`, `escapeCodeTimeout`, `line`, `terminal` and
* `cursor` (a non-terminal interface's undefined cursor throws SC2020).
* `cursor` (undefined on a non-terminal interface).
* Null for other receivers. */
export function lowerReadlineProperty(
lowerer: Lowerer,
@@ -1373,10 +1390,38 @@ export function lowerReadlineProperty(
loc,
};
}
const reads: Record<string, ["rl.line" | "rl.isTerminal" | "rl.cursor", IrExpr["type"]]> = {
if (name === "cursor") {
// `number | undefined`: a non-terminal interface has no cursor
const recv = lowerer.lowerExprExpecting(access.expression, F64);
const type: IrType = { kind: "union", unionId: lowerer.unions.intern([F64, UNDEFINED_T]) };
const local = recv.kind === "varRef" ? null : lowerer.declareHiddenLocal("%rlCursor", F64);
const id: IrExpr = local ? { kind: "varRef", localId: local.id, type: F64, loc } : recv;
const result: IrExpr = {
kind: "ternary",
cond: { kind: "libCall", fn: "rl.isTerminal", args: [id], type: BOOL, loc },
then: lowerer.coerceToExpected(
{ kind: "libCall", fn: "rl.cursor", args: [id], type: F64, loc },
type,
),
else_: lowerer.coerceToExpected(
{ kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc },
type,
),
type,
loc,
};
if (!local) return result;
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: local.id, init: recv, loc }],
result,
type,
loc,
};
}
const reads: Record<string, ["rl.line" | "rl.isTerminal", IrExpr["type"]]> = {
line: ["rl.line", STRING],
terminal: ["rl.isTerminal", BOOL],
cursor: ["rl.cursor", F64],
};
const read = reads[name];
if (read !== undefined) {
@@ -31,6 +31,7 @@ import {
requireBindingEarlyUse7,
requireSpecOf,
resolveImport,
splitRequireCallOf,
} from "../program.js";
import { createRequireCalleeFileOf } from "./builtins/module-bindings.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
@@ -172,6 +173,10 @@ function computeSplitRequireBinding(
const req = inlineProgramRequireOf(lowerer, assign.right);
if (req === null) return null;
// Preflight treats the same bindings as require declarations (module edge,
// cycle admission, early-read fence); a binding it does not know keeps
// ordinary storage.
if (splitRequireCallOf(lowerer.program, decl) !== req.call) return null;
// Any other write makes the value depend on runtime control flow.
let writes = 0;
@@ -6,12 +6,14 @@ import {
STRING,
VOID,
isClassOwnEnumerableFieldName,
typeEquals,
type IrExpr,
type IrStmt,
type SrcLoc,
} from "../../ir/ir.js";
import { isJsSourceFile, locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import { PoisonError, type Lowerer } from "./lowerer.js";
import { unsupportedDiag } from "../../diagnostics/diagnostic.js";
import { findMethodOn, type ClassInfo } from "./lower-classes.js";
import { classPropertiesHelper } from "./class-dynamic-dispatch.js";
import { observedPrototypeMethod } from "./class-prototypes.js";
@@ -162,6 +164,7 @@ export function classCallbackCall(
fallback: IrExpr,
loc: SrcLoc,
calleeName = name,
original?: { originalId: () => string | undefined; directCall: () => IrExpr },
): IrExpr {
const bag = lowerer.declareHiddenLocal("%callbackBag", DYN);
const present = lowerer.declareHiddenLocal("%callbackPresent", BOOL);
@@ -186,27 +189,91 @@ export function classCallbackCall(
loc,
};
const observed = observedSlot(lowerer, receiver, name, loc);
const result: IrExpr = observed
? call
: {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: call,
else_:
fallback.type.kind === "void"
? {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: fallback, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
}
: lowerer.coerceToExpected(fallback, DYN),
type: DYN,
loc,
};
const answer =
fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
const boxed =
observed || fallback.type.kind === "void" ? null : lowerer.coerceToExpected(fallback, DYN);
let answer: IrExpr;
if (boxed !== null && boxed.type.kind !== "dyn") {
// A result with no checked-dynamic form (an Iterable<T>, whose
// generator arm does not box) keeps its native type: the unchanged
// method's result stays as it is, and a replacement's dynamic result
// takes the checked conversion into that type.
const converted = lowerer.coerceToExpected(call, fallback.type);
if (!typeEquals(converted.type, fallback.type)) {
lowerer.pushDiag(
unsupportedDiag(
"SC1090",
loc,
`calling replaceable method '${calleeName}' with a '${lowerer.fmt(fallback.type)}' result`,
),
);
throw new PoisonError();
}
answer = {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: converted,
else_: fallback,
type: fallback.type,
loc,
};
} else {
const result: IrExpr = observed
? call
: {
kind: "ternary",
cond: varRef(present.id, BOOL, loc),
then: call,
else_: boxed ?? {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: fallback, loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
type: DYN,
loc,
};
answer =
fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type);
}
// Read after observedSlot built the prototype data and its originals.
const originalId = observed ? original?.originalId() : undefined;
if (original && originalId !== undefined) {
// The slot still holds the method's original value: calling it is
// calling the method, so the call stays native (no boxed arguments,
// dynamic call or checked result). A replacement takes the dynamic call.
const unchanged: IrExpr = {
kind: "libCall",
fn: "dyn.sameValue",
args: [varRef(callback.id, DYN, loc), varRef(originalId, DYN, loc)],
type: BOOL,
loc,
};
answer =
fallback.type.kind === "void"
? {
kind: "ternary",
cond: unchanged,
then: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: original.directCall(), loc }],
result: dynUndefinedExpr(loc),
type: DYN,
loc,
},
else_: call,
type: DYN,
loc,
}
: {
kind: "ternary",
cond: unchanged,
then: original.directCall(),
else_: answer,
type: answer.type,
loc,
};
}
return {
kind: "seqExpr",
stmts: [
@@ -458,9 +458,11 @@ test("normalizes base capsules before dispatching each derived member once", ()
});
expect(dispatch.body.filter((statement) => statement.kind === "if")).toHaveLength(2);
const normalize = liftedFns.find((helper) => helper.name === "%dyn.class.normalize")!;
// The capsule branch unboxes its receiver once, then tests each subclass.
expect(normalize.body[0]).toMatchObject({
kind: "if",
then: [
{ kind: "varDecl", init: { kind: "dynCheck", type: { kind: "object", className: "Base" } } },
{
kind: "if",
then: [
@@ -558,3 +560,53 @@ test("class branches read their exact class's bag, also for classes with subclas
then: [{ kind: "return", value: { kind: "dynObjLit", fields: [] } }],
});
});
test("records a class's bag initializer only when all its class-table branches agree", () => {
const { lowerer } = context();
const classBagInitializers = new Map<string, IrExpr>();
Object.assign(lowerer, { classBagInitializers });
for (const name of ["Material", "Texture"])
lowerer.classes.set(name, { def: { name, tracksOwnFields: true } } as unknown as ClassInfo);
const initializer = (
className: string,
prototype: string,
start: number,
): Extract<IrStmt, { kind: "fieldSet" }> => {
const at = { ...loc, start };
return {
kind: "fieldSet",
obj: {
kind: "dynCheck",
value: { kind: "varRef", localId: "p.0", type: DYN, loc: at },
type: { kind: "object", className },
loc: at,
},
className,
field: "%bag",
value: {
kind: "libCall",
fn: "dyn.objCreate",
args: [{ kind: "call", callee: prototype, args: [], type: DYN, loc: at }],
type: DYN,
loc: at,
},
loc: at,
};
};
const dispatch = new ClassDynamicDispatch();
const tables = (dispatch as unknown as { bagInitializers: Map<string, unknown[]> })
.bagInitializers;
// A base class whose own instances get its prototype and whose subclass
// instances get theirs through a per-instance dispatch has no single bag.
tables.set("Material", [
initializer("Material", "%prototype.data.Material", 1),
initializer("Material", "%class.bagPrototype:Material", 2),
]);
// Branches that differ only in source location agree.
tables.set("Texture", [
initializer("Texture", "%prototype.data.Texture", 3),
initializer("Texture", "%prototype.data.Texture", 4),
]);
dispatch.finalize(lowerer);
expect([...classBagInitializers.keys()]).toEqual(["Texture"]);
});
@@ -350,7 +350,7 @@ export function classInstanceOf(
localId: "candidate",
value: {
kind: "libCall",
fn: "dyn.getPrototype",
fn: "dyn.getPrototypeForIdentity",
args: [candidate],
type: DYN,
loc,
@@ -394,6 +394,40 @@ export function classInstanceOf(
};
}
/** `value` is the parameter itself, possibly through js.fromDyn. */
function returnsParam(value: IrExpr, param: string): boolean {
if (value.kind === "varRef") return value.localId === param;
return (
value.kind === "libCall" &&
value.fn === "js.fromDyn" &&
value.args.length === 1 &&
value.args[0]!.kind === "varRef" &&
value.args[0]!.localId === param
);
}
/** The kind a class-table branch requires of `param`: a capsule-key test
* (alone or guarding a conjunction) needs a capsule, a class-key test a
* function value; null for any other condition. */
function branchKind(cond: IrExpr, param: string): "classCapsule" | "funcKind" | null {
if (
cond.kind === "libCall" &&
(cond.fn === "dyn.typedRefIs" ||
cond.fn === "dyn.typedRefKeyIs" ||
cond.fn === "dyn.classIs") &&
cond.args[0]?.kind === "varRef" &&
cond.args[0].localId === param
) {
if (cond.fn === "dyn.classIs") return "funcKind";
const key = cond.args[1];
return key?.kind === "strLit" && key.value.startsWith("object:") ? "classCapsule" : null;
}
if (cond.kind === "ternary" && cond.else_.kind === "boolLit" && cond.else_.value === false)
return branchKind(cond.cond, param);
if (cond.kind === "logical" && cond.op === "&&") return branchKind(cond.left, param);
return null;
}
const READ_PROPERTIES = "%dyn.class.readProperties";
const READ_PROPERTIES_OR_EMPTY = "%dyn.class.readPropertiesOrEmpty";
@@ -685,16 +719,154 @@ export function trackClassFieldCreation(lowerer: Lowerer, functions: IrFunction[
if (![...lowerer.classes.values()].some((info) => info.def.tracksOwnFields)) return;
const loc = functions[0]!.loc;
const name = "%dyn.class.fieldCreated";
const value = varRef("value", DYN, loc),
key = varRef("key", DYN, loc);
const bag = varRef("bag", DYN, loc);
const created = (receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrStmt => ({
const key = varRef("key", DYN, loc);
// One helper per class: the instance's bag is its own hidden field, so a
// creation reads it directly instead of boxing the instance and testing
// it against every class's capsule (%dyn.class.properties), which runs
// only to create the bag, once per instance.
const perClass = new Map<string, string>();
const helperFor = (className: string): string => {
const known = perClass.get(className);
if (known !== undefined) return known;
const helperName = `${name}:${className}`;
perClass.set(className, helperName);
const self = varRef("this", { kind: "object", className }, loc);
const bagRef = varRef("bag", DYN, loc);
functions.push({
name: helperName,
params: [
{ localId: "this", name: "this", type: { kind: "object", className } },
{ localId: "key", name: "key", type: DYN },
],
returnType: VOID,
locals: [
{ id: "this", name: "this", type: { kind: "object", className }, mutable: false },
{ id: "key", name: "key", type: DYN, mutable: false },
{ id: "bag", name: "bag", type: DYN, mutable: true },
],
body: [
{
kind: "varDecl",
localId: "bag",
init: { kind: "fieldGet", obj: self, className, field: PROPERTY_BAG, type: DYN, loc },
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "undefined", value: bagRef, type: BOOL, loc },
then: initializeBag(lowerer, className, self, loc),
else_: null,
loc,
},
...defineIfAbsent(lowerer, bagRef, key, loc),
],
loc,
});
return helperName;
};
// String-keyed fields take the runtime's direct insert for an ordinary
// bag; the general path stays for any other bag and for symbol keys.
const stringHelperFor = (className: string): string => {
const known = perClass.get(`str:${className}`);
if (known !== undefined) return known;
const general = helperFor(className);
const helperName = `${name}.string:${className}`;
perClass.set(`str:${className}`, helperName);
const type: IrType = { kind: "object", className };
const self = varRef("this", type, loc);
const bagRef = varRef("bag", DYN, loc);
const field = varRef("field", STRING, loc);
functions.push({
name: helperName,
params: [
{ localId: "this", name: "this", type },
{ localId: "field", name: "field", type: STRING },
],
returnType: VOID,
locals: [
{ id: "this", name: "this", type, mutable: false },
{ id: "field", name: "field", type: STRING, mutable: false },
{ id: "bag", name: "bag", type: DYN, mutable: true },
],
body: [
{
kind: "varDecl",
localId: "bag",
init: { kind: "fieldGet", obj: self, className, field: PROPERTY_BAG, type: DYN, loc },
loc,
},
{
kind: "if",
cond: { kind: "dynTest", test: "undefined", value: bagRef, type: BOOL, loc },
then: initializeBag(lowerer, className, self, loc),
else_: null,
loc,
},
{
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.bagCreateField",
args: [bagRef, field],
type: BOOL,
loc,
},
then: [{ kind: "return", value: null, loc }],
else_: null,
loc,
},
{
kind: "exprStmt",
expr: {
kind: "call",
callee: general,
args: [self, lowerer.coerceToExpected(field, DYN)],
type: VOID,
loc,
},
loc,
},
],
loc,
});
return helperName;
};
const created = (receiver: IrExpr, className: string, field: string, loc: SrcLoc): IrStmt => {
const symbolKey = symbolMemberKey(lowerer, lowerer.classes.get(className)!, field, loc);
if (symbolKey === null) {
return {
kind: "exprStmt",
expr: {
kind: "call",
callee: stringHelperFor(className),
args: [
receiver.type.kind === "object" && receiver.type.className !== className
? { kind: "upcast", value: receiver, type: { kind: "object", className }, loc }
: receiver,
{ kind: "strLit", value: field, type: STRING, loc },
],
type: VOID,
loc,
},
loc,
};
}
return createdGeneral(receiver, className, field, loc);
};
const createdGeneral = (
receiver: IrExpr,
className: string,
field: string,
loc: SrcLoc,
): IrStmt => ({
kind: "exprStmt",
expr: {
kind: "call",
callee: name,
callee: helperFor(className),
args: [
lowerer.coerceToExpected(receiver, DYN),
receiver.type.kind === "object" && receiver.type.className !== className
? { kind: "upcast", value: receiver, type: { kind: "object", className }, loc }
: receiver,
symbolMemberKey(lowerer, lowerer.classes.get(className)!, field, loc) ??
lowerer.coerceToExpected({ kind: "strLit", value: field, type: STRING, loc }, DYN),
],
@@ -769,80 +941,108 @@ export function trackClassFieldCreation(lowerer: Lowerer, functions: IrFunction[
},
});
}
functions.push({
name,
params: [
{ localId: "value", name: "value", type: DYN },
{ localId: "key", name: "key", type: DYN },
],
returnType: VOID,
locals: [
{ id: "value", name: "value", type: DYN, mutable: false },
{ id: "key", name: "key", type: DYN, mutable: false },
{ id: "bag", name: "bag", type: DYN, mutable: false },
],
body: [
}
/** `bag = <the class's new bag>`, stored in the instance's hidden field:
* the class's own initializer when recorded, else the class-table
* dispatch, which creates it. */
function initializeBag(lowerer: Lowerer, className: string, self: IrExpr, loc: SrcLoc): IrStmt[] {
const init = lowerer.classBagInitializers?.get(className);
if (init === undefined) {
return [
{
kind: "varDecl",
kind: "assign",
localId: "bag",
init: {
value: {
kind: "call",
callee: classPropertiesHelper(lowerer, loc).name,
args: [value],
args: [lowerer.coerceToExpected(self, DYN)],
type: DYN,
loc,
},
loc,
},
{
kind: "if",
cond: {
kind: "unary",
op: "!",
operand: { kind: "libCall", fn: "dyn.hasOwnComputed", args: [bag, key], type: BOOL, loc },
type: BOOL,
loc,
},
then: [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.defineProperty",
args: [
bag,
lowerer.coerceToExpected(key, DYN),
{
kind: "dynObjLit",
fields: [
{
key: { kind: "strLit", value: "value", type: STRING, loc },
value: dynUndefinedExpr(loc),
},
...["writable", "enumerable", "configurable"].map((key) => ({
key: { kind: "strLit" as const, value: key, type: STRING, loc },
value: lowerer.coerceToExpected(
{ kind: "boolLit", value: true, type: BOOL, loc },
DYN,
),
})),
],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
],
else_: null,
];
}
return [
{
kind: "fieldSet",
obj: self,
className,
field: PROPERTY_BAG,
value: structuredClone(init),
loc,
},
{
kind: "assign",
localId: "bag",
value: { kind: "fieldGet", obj: self, className, field: PROPERTY_BAG, type: DYN, loc },
loc,
},
];
}
/** `value` as JSON without its source locations. */
function withoutLocations(value: IrExpr): string {
return JSON.stringify(value, (key, field: unknown) => (key === "loc" ? undefined : field));
}
/** Whether `value` reads local `id`. */
function mentionsLocal(value: IrExpr, id: string): boolean {
return JSON.stringify(value).includes(`"localId":${JSON.stringify(id)}`);
}
/** `if (!hasOwn(bag, key)) defineProperty(bag, key, <undefined data field>)`:
* a field's creation in its instance's property bag. */
function defineIfAbsent(lowerer: Lowerer, bag: IrExpr, key: IrExpr, loc: SrcLoc): IrStmt[] {
return [
{
kind: "if",
cond: {
kind: "unary",
op: "!",
operand: { kind: "libCall", fn: "dyn.hasOwnComputed", args: [bag, key], type: BOOL, loc },
type: BOOL,
loc,
},
],
loc,
});
then: [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.defineProperty",
args: [
bag,
lowerer.coerceToExpected(key, DYN),
{
kind: "dynObjLit",
fields: [
{
key: { kind: "strLit", value: "value", type: STRING, loc },
value: dynUndefinedExpr(loc),
},
...["writable", "enumerable", "configurable"].map((key) => ({
key: { kind: "strLit" as const, value: key, type: STRING, loc },
value: lowerer.coerceToExpected(
{ kind: "boolLit", value: true, type: BOOL, loc },
DYN,
),
})),
],
type: DYN,
loc,
},
],
type: DYN,
loc,
},
loc,
},
],
else_: null,
loc,
},
];
}
/** Calls on native class capsules keep the instance's compiled methods. The
@@ -862,6 +1062,9 @@ export class ClassDynamicDispatch {
private deletedNames: Set<string> | null | undefined;
private readonly boxed = new Set<string>();
private readonly boxedConstructors = new Set<string>();
/** Every class a boxed value was statically typed as, runtime error
* classes included: the value's capsule can be any of its subclasses. */
private readonly boxedStatic = new Set<string>();
private readonly dispatches = new Map<string, Dispatch>();
private readonly properties = new Map<string, PropertyDispatch>();
private readonly computed = new Map<
@@ -906,8 +1109,15 @@ export class ClassDynamicDispatch {
private descriptorHelper: IrFunction | null = null;
private prototypeHelper: IrFunction | null = null;
private readonly prototypeClasses = new Set<string>();
private prototypeIdentityHelper: IrFunction | null = null;
private readonly prototypeIdentityClasses = new Set<string>();
/** The stand-in prototype object of each class without prototype data
* (prototypeStandIn), by class name. */
private readonly prototypeStandIns = new Set<string>();
/** The prototype reads of identity walks, by libCall, and the classes
* each one answers for boxed instances. */
private readonly prototypeIdentityHelpers = new Map<
"dyn.getPrototype" | "dyn.getPrototypeForIdentity",
{ fn: IrFunction; classes: Set<string> }
>();
/** `value instanceof Error` through Error.prototype: boxed instances of
* classes with a heritage evaluated at run time test their prototype
* data's chain. */
@@ -961,6 +1171,7 @@ export class ClassDynamicDispatch {
if (seenTypes.has(key)) return;
seenTypes.add(key);
if (type.kind === "object") {
this.boxedStatic.add(type.className);
if (isDynTypedRefType(type)) this.boxed.add(type.className);
} else if (type.kind === "classval") this.boxedConstructors.add(type.className);
else if (type.kind === "array") discover(type.elem);
@@ -1092,33 +1303,6 @@ export class ClassDynamicDispatch {
});
changed = true;
}
if (this.prototypeIdentityHelper && !this.prototypeIdentityClasses.has(className)) {
this.prototypeIdentityClasses.add(className);
const loc = this.prototypeIdentityHelper.loc;
const type: IrType = { kind: "object", className };
const value = varRef("value", DYN, loc);
const prototype = classPrototypeData(lowerer, info, loc, {
kind: "dynCheck",
value,
type,
loc,
});
if (prototype)
this.prototypeIdentityHelper.body.unshift({
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.typedRefIs",
args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }],
type: BOOL,
loc,
},
then: [{ kind: "return", value: prototype, loc }],
else_: null,
loc,
});
changed = true;
}
if (
this.errorPrototypeHelper &&
!this.errorPrototypeClasses.has(className) &&
@@ -1229,6 +1413,101 @@ export class ClassDynamicDispatch {
}
}
}
// The runtime boxes a class instance as its exact class, so a value
// boxed at a base (or Error) static type reaches the identity views as
// any subclass's capsule.
for (const helper of this.prototypeIdentityHelpers.values())
for (const [className, info] of lowerer.classes) {
if (helper.classes.has(className)) continue;
if (info.builtinEmitter || info.builtinStream || info.builtinError) continue;
if (!isDynTypedRefType({ kind: "object", className })) continue;
if (
!this.boxed.has(className) &&
![...this.boxedStatic].some((base) => lowerer.isSubclassOf(className, base))
)
continue;
helper.classes.add(className);
const loc = helper.fn.loc;
const type: IrType = { kind: "object", className };
const value = varRef("value", DYN, loc);
// A class with no prototype data in its chain (one extending
// Error, or with no methods) has no prototype a program can name:
// an object of the class's own stands in, so instances of one class
// share it (`Object.getPrototypeOf(a) === Object.getPrototypeOf(b)`),
// other prototypes differ from it, and an instanceof walk (through
// Object.prototype to null) answers false instead of reaching the
// reflection refusal. A function-local class is a new class at
// each evaluation of its declaration, so its instances answer with
// the prototype of their own class value instead.
const answer = (name: string): IrStmt[] | null => {
const of = lowerer.classes.get(name)!;
const prototype =
hasClassPrototypeData(of) || of.localClass
? classPrototypeData(lowerer, of, loc, {
kind: "dynCheck",
value,
type: { kind: "object", className: name },
loc,
})
: null;
if (prototype) return [{ kind: "return", value: prototype, loc }];
return hasClassPrototypeData(of) ? null : this.prototypeStandIn(lowerer, name, loc);
};
const own = answer(className);
if (!own) {
changed = true;
continue;
}
// A capsule boxed at this class can hold a subclass instance: the
// most derived subclass the instance belongs to answers.
const depth = (name: string): number => {
let n = 0;
for (let at = lowerer.classes.get(name)?.base; at; at = at.base) n++;
return n;
};
const subclasses = [...lowerer.classes.keys()]
.filter(
(name) =>
name !== className &&
lowerer.isSubclassOf(name, className) &&
isDynTypedRefType({ kind: "object", className: name }),
)
.sort((a, b) => depth(b) - depth(a));
const then: IrStmt[] = [];
for (const name of subclasses) {
const sub = answer(name);
if (!sub) continue;
then.push({
kind: "if",
cond: {
kind: "instanceOf",
value: { kind: "dynCheck", value, type, loc },
className: name,
type: BOOL,
loc,
},
then: sub,
else_: null,
loc,
});
}
then.push(...own);
helper.fn.body.unshift({
kind: "if",
cond: {
kind: "libCall",
fn: "dyn.typedRefIs",
args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }],
type: BOOL,
loc,
},
then,
else_: null,
loc,
});
changed = true;
}
let live: Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]> | null = null;
for (const plan of this.propertyReceivers(lowerer, false, false)) {
const info = plan.info;
const className = info.def.name;
@@ -1309,7 +1588,11 @@ export class ClassDynamicDispatch {
);
const prototypeFor = (current: ClassInfo, receiver: IrExpr, loc: SrcLoc): IrExpr | null =>
this.bagPrototype(lowerer, current, receiver, loc);
for (const existingInit of this.bagInitializers.get(className) ?? []) {
live ??= this.liveBagInitializers();
for (const existingInit of [
...(this.bagInitializers.get(className) ?? []),
...(live.get(className) ?? []),
]) {
if (existingInit.value.kind === "dynObjLit" && needsPrototype(info)) {
const prototype = prototypeFor(info, existingInit.obj, existingInit.loc);
if (prototype) {
@@ -1320,6 +1603,11 @@ export class ClassDynamicDispatch {
type: DYN,
loc: existingInit.loc,
};
// The initializer is a statement of the property bag helpers;
// their reused site summaries do not see an edit inside it.
if (this.propertyBag) this.sites.forget(this.propertyBag);
for (const fn of this.typedPropertyBags.values()) this.sites.forget(fn);
for (const fn of this.exactPropertyBags.values()) this.sites.forget(fn);
changed = true;
}
}
@@ -1591,29 +1879,41 @@ export class ClassDynamicDispatch {
}
if (
expr.kind === "libCall" &&
expr.fn === "dyn.getPrototype" &&
(expr.fn === "dyn.getPrototype" || expr.fn === "dyn.getPrototypeForIdentity") &&
expr.args[0]?.kind !== "dynObjLit"
) {
if (expr.prototypeIdentityOnly) {
if (!this.prototypeIdentityHelper) {
let helper = this.prototypeIdentityHelpers.get(expr.fn);
if (!helper) {
const loc = expr.loc;
this.prototypeIdentityHelper = {
name: "%dyn.class.prototypeIdentity",
params: [{ localId: "value", name: "value", type: DYN }],
locals: [{ id: "value", name: "value", type: DYN, mutable: false }],
returnType: DYN,
loc,
body: [
{ kind: "return", value: { ...expr, args: [varRef("value", DYN, loc)] }, loc },
],
helper = {
fn: {
name:
expr.fn === "dyn.getPrototype"
? "%dyn.class.prototypeIdentity"
: "%dyn.class.prototypeChainIdentity",
params: [{ localId: "value", name: "value", type: DYN }],
locals: [{ id: "value", name: "value", type: DYN, mutable: false }],
returnType: DYN,
loc,
body: [
{
kind: "return",
value: { ...expr, args: [varRef("value", DYN, loc)] },
loc,
},
],
},
classes: new Set(),
};
lowerer.liftedFns.push(this.prototypeIdentityHelper);
this.generated.add(this.prototypeIdentityHelper);
this.prototypeIdentityHelpers.set(expr.fn, helper);
lowerer.liftedFns.push(helper.fn);
this.generated.add(helper.fn);
changed = true;
}
return {
kind: "call",
callee: this.prototypeIdentityHelper.name,
callee: helper.fn.name,
args: expr.args,
type: DYN,
loc: expr.loc,
@@ -3561,6 +3861,43 @@ export class ClassDynamicDispatch {
/** The receiver's own dynamic properties for a read: undefined while it has
* none, and never stored, so reads stay valid on published objects. */
/** Statements that return `className`'s stand-in prototype: one empty
* object per class, created on first use and kept in a module global. */
private prototypeStandIn(lowerer: Lowerer, className: string, loc: SrcLoc): IrStmt[] {
const globalId = `%g.%prototype.standIn.${className}`;
const readyId = `${globalId}.ready`;
if (!this.prototypeStandIns.has(className)) {
this.prototypeStandIns.add(className);
lowerer.globalsList.push(
{ id: globalId, name: `${className}.prototype`, type: DYN, mutable: true },
{ id: readyId, name: `${className}.prototype.ready`, type: BOOL, mutable: true },
);
}
return [
{
kind: "if",
cond: { kind: "unary", op: "!", operand: varRef(readyId, BOOL, loc), type: BOOL, loc },
then: [
{
kind: "assign",
localId: globalId,
value: { kind: "dynObjLit", fields: [], type: DYN, loc },
loc,
},
{
kind: "assign",
localId: readyId,
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
},
],
else_: null,
loc,
},
{ kind: "return", value: varRef(globalId, DYN, loc), loc },
];
}
private readPropertyBag(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr {
const helper = classPropertiesHelper(lowerer, loc, true);
this.generated.add(helper);
@@ -4440,6 +4777,27 @@ export class ClassDynamicDispatch {
* A class with constructible subclasses that have prototype data selects
* it through its `%class.bagPrototype` helper; any other class reads its
* own prototype data. */
/** The lazy bag stores as they stand in the generated bodies, by class.
* Worklist passes that transform every function rebuild statement
* objects, so a recorded initializer can be a detached copy; a store that
* gains its prototype must be updated where it runs. */
private liveBagInitializers(): Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]> {
const live = new Map<string, Extract<IrStmt, { kind: "fieldSet" }>[]>();
const add = (stmt: IrStmt | undefined): void => {
if (stmt?.kind !== "if") return;
const store = stmt.then[0];
if (store?.kind !== "fieldSet" || store.field !== PROPERTY_BAG) return;
const list = live.get(store.className);
if (list) list.push(store);
else live.set(store.className, [store]);
};
// The complete dispatch guards each class's lazy store by its receiver.
for (const stmt of this.propertyBag?.body ?? []) if (stmt.kind === "if") add(stmt.then[0]);
for (const helper of this.typedPropertyBags.values()) add(helper.body[0]);
for (const helper of this.exactPropertyBags.values()) add(helper.body[0]);
return live;
}
private bagPrototype(
lowerer: Lowerer,
current: ClassInfo,
@@ -4516,6 +4874,7 @@ export class ClassDynamicDispatch {
finalize(lowerer?: Lowerer): void {
// Each table serves every site that reaches it.
if (lowerer) for (const fn of this.generated) lowerer.shareHelper(fn);
if (lowerer) this.recordBagInitializers(lowerer);
for (const { helper, initialize, orEmpty } of this.readPropertyBags) {
const absent = helper.body[0];
if (isEmptyBag(initialize.value) && !orEmpty) helper.body.shift();
@@ -4536,6 +4895,94 @@ export class ClassDynamicDispatch {
fn.name === READ_PROPERTIES_OR_EMPTY,
);
}
if (lowerer) this.guardClassTables(lowerer);
}
/** Each tracked class's bag initializer over a typed receiver (`this`),
* for the per-class field-creation helpers (trackClassFieldCreation),
* which create an instance's bag without the class-table dispatch. */
private recordBagInitializers(lowerer: Lowerer): void {
for (const [className, initializers] of this.bagInitializers) {
const init = initializers[0];
if (!init || !lowerer.classes.get(className)?.def.tracksOwnFields) continue;
const self = varRef("this", { kind: "object", className }, init.loc);
const value = withTypedReceiver(structuredClone(init.value), self);
if (mentionsLocal(value, "p.0")) continue;
// The class-table branches can give the class's instances different
// bags (a base class's own prototype for its instances, the
// instance's class prototype for subclass instances); only a class
// whose branches all agree has one initializer for every instance.
const shape = withoutLocations(value);
if (
initializers.some(
(other) =>
withoutLocations(withTypedReceiver(structuredClone(other.value), self)) !== shape,
)
)
continue;
// Lowerer stand-ins in unit tests may not carry the table.
lowerer.classBagInitializers?.set(className, value);
}
}
/** Fast exits for the generated class tables: every class branch of
* the property-bag, normalization and static-bag helpers tests the
* value's capsule key (or class key), so a value of another kind (a
* plain object, the common case in untyped code) skips straight to the
* table's fallthrough instead of testing every class in the program. */
private guardClassTables(lowerer: Lowerer): void {
const names = new Set([
"%dyn.class.properties",
READ_PROPERTIES,
READ_PROPERTIES_OR_EMPTY,
"%dyn.class.normalize",
"%dyn.class.staticProperties",
]);
for (const fn of lowerer.liftedFns) {
if (!names.has(fn.name) || fn.body.length < 2) continue;
const param = fn.params[0]?.localId;
if (param === undefined) continue;
const last = fn.body[fn.body.length - 1]!;
if (last.kind !== "return" || last.value === null || !returnsParam(last.value, param))
continue;
let test: "classCapsule" | "funcKind" | null = null;
let ok = true;
for (const stmt of fn.body.slice(0, -1)) {
if (
stmt.kind === "assign" &&
stmt.localId === param &&
stmt.value.kind === "call" &&
stmt.value.callee === "%dyn.class.normalize"
)
continue;
const kind =
stmt.kind === "if" && stmt.else_ === null ? branchKind(stmt.cond, param) : null;
if (kind === null || (test !== null && kind !== test)) {
ok = false;
break;
}
test = kind;
}
if (!ok || test === null) continue;
const first = fn.body[0]!;
if (first.kind === "if" && first.cond.kind === "dynTest" && first.cond.test === test)
continue;
const loc = fn.loc;
fn.body.unshift({
kind: "if",
cond: {
kind: "dynTest",
test,
negated: true,
value: varRef(param, DYN, loc),
type: BOOL,
loc,
},
then: [structuredClone(last)],
else_: null,
loc,
});
}
}
private normalizedDispatches(): IrFunction[] {
@@ -4609,10 +5056,17 @@ export class ClassDynamicDispatch {
this.normalizationKey = key;
const loc = this.normalization.loc;
const value = varRef("value", DYN, loc);
this.normalization.body = entries.map(({ capsule, children }): IrStmt => {
// The checked receiver is unboxed once per capsule branch, not once
// per subclass test.
this.normalization.locals = this.normalization.locals.filter(
(local) => !local.id.startsWith("%normalized."),
);
this.normalization.body = entries.map(({ capsule, children }, index): IrStmt => {
const type: IrType = { kind: "object", className: capsule.def.name };
const receiver: IrExpr = { kind: "dynCheck", value, type, loc };
return {
const id = `%normalized.${index}`;
this.normalization!.locals.push({ id, name: id, type, mutable: false });
const receiver: IrExpr = varRef(id, type, loc);
const branch: IrStmt = {
kind: "if",
cond: {
kind: "libCall",
@@ -4653,6 +5107,15 @@ export class ClassDynamicDispatch {
else_: null,
loc,
};
// The capsule test stays the branch condition; its body unboxes.
if (branch.kind === "if")
branch.then.unshift({
kind: "varDecl",
localId: id,
init: { kind: "dynCheck", value, type, loc },
loc,
});
return branch;
});
this.normalization.body.push({ kind: "return", value, loc });
}
@@ -207,12 +207,21 @@ export function classPrototypeData(
// promise-returning signature) gets no data slot.
const boxedValue = lowerer.coerceToExpected(compiled, DYN);
if (boxedValue.type.kind !== "dyn") continue;
const originalId = `%g.${info.def.prototypeDataHelper}.original.${method}`;
lowerer.globalsList.push({
id: originalId,
name: `${info.def.jsName ?? info.def.name}.prototype.${method}.original`,
type: DYN,
mutable: true,
});
info.prototypeOriginals ??= new Map();
info.prototypeOriginals.set(method, originalId);
const descriptor: IrExpr = {
kind: "dynObjLit",
fields: [
{
key: { kind: "strLit", value: "value", type: STRING, loc },
value: boxedValue,
value: varRef(originalId, DYN, loc),
},
{
key: { kind: "strLit", value: "writable", type: STRING, loc },
@@ -227,6 +236,8 @@ export function classPrototypeData(
loc,
};
const init = helper.body[0]!;
if (init.kind === "if")
init.then.push({ kind: "assign", localId: originalId, value: boxedValue, loc });
if (init.kind === "if")
init.then.push({
kind: "exprStmt",
@@ -278,6 +289,16 @@ export function hasClassPrototypeData(info: ClassInfo): boolean {
);
}
/** Methods and accessors that live on the class's prototype. */
function declaresPrototypeMembers(decl: ts.ClassLikeDeclaration): boolean {
return decl.members.nodes.some(
(member) =>
(ts.isMethodDeclaration(member) || ts.isAccessor(member)) &&
!ts.isPrivateIdentifier(member.name) &&
!ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword),
);
}
/** Populate the shared prototype view before exposing it to reflection.
* Methods and accessors are non-enumerable and retain declaration identity. */
export function reflectClassPrototype(
@@ -307,7 +328,10 @@ export function reflectClassPrototype(
// Local JavaScript factories initialize captured prototype members on
// their constructor value, so the shared runtime view is already complete.
if (info.localClass && info.runtimePrototypeMembers) return prototype;
if (info.localClass)
// A function-local class declaring no prototype members (zod 4's
// `class Definition extends Parent {}`) has nothing to install: its
// shared runtime view is complete once its base's is.
if (info.localClass && declaresPrototypeMembers(info.decl))
return refuse("prototype reflection on a function-local class is not supported yet");
if (info.base && !info.base.builtinError) {
reflectClassPrototype(
@@ -533,6 +557,15 @@ export function lowerClassPrototypeData(
if (name === "constructor") {
return reflectClassPrototype(lowerer, info, locOf(expr), classValueRef(lowerer, info, expr));
}
// The whole prototype of a function-local class: the shared view
// Object.getPrototypeOf answers for its instances.
if (name === null && info.localClass) {
const receiver = lowerer.lowerExprExpecting(expr.expression, {
kind: "classval",
className: info.def.name,
});
return reflectClassPrototype(lowerer, info, locOf(expr), receiver);
}
if (name === null || (isCompiledPrototypeMember(lowerer, info, name) && !method)) {
lowerer.unsupported(
"SC1090",
@@ -288,6 +288,14 @@ export function applyWidthLift(
case "dynIn": {
if (dst.kind !== "dyn")
throw new InternalCompilerError("lowerer bug: dynIn lift against a non-dyn slot");
// The declaration-typed boundary keeps user values' identity when
// they cross into package code (decl-projection.ts).
if (
lowerer.declBoundary &&
!/[\\/]node_modules[\\/]/.test(loc.file) &&
liveBoxable(lowerer, value.type)
)
return { kind: "dynFrom", value, liveRef: true, type: DYN, loc };
return { kind: "dynFrom", value, type: DYN, loc };
}
case "dynOut":
@@ -775,3 +783,18 @@ function buildRecordProjection(
loc,
};
}
/** Records and arrays (and unions holding them), whose identity a live
* capsule keeps. */
function liveBoxable(lowerer: Lowerer, t: IrType, seen = new Set<string>()): boolean {
if (t.kind === "record") {
const shape = lowerer.shapes.get(t.shapeId);
return shape !== undefined && !shape.tuple;
}
if (t.kind === "array") return true;
if (t.kind !== "union" || seen.has(t.unionId)) return false;
seen.add(t.unionId);
return (
lowerer.unions.get(t.unionId)?.arms.some((arm) => liveBoxable(lowerer, arm, seen)) ?? false
);
}
@@ -16,6 +16,7 @@ import {
type IrExpr,
type IrStmt,
type IrType,
REGEX,
STRING,
type SrcLoc,
UNDEFINED_T,
@@ -920,12 +921,15 @@ export function lowerRegexMethodCall(
})();
if ((receiverKind === "string" || nullableStringRecv) && name === "match") {
const arg0 = call.arguments.nodes[0];
if (!arg0 || !isRegexPatternArgument(lowerer, arg0)) return null; // string-pattern match: the SC2020 fence
if (call.arguments.nodes.length !== 1) return null;
if (!arg0 || call.arguments.nodes.length !== 1) return null;
const checkedPattern = isCheckedPatternArgument(lowerer, arg0);
if (!checkedPattern && !isRegexPatternArgument(lowerer, arg0)) return null; // string-pattern match: the SC2020 fence
const receiver = nullableStringRecv
? lowerMethodReceiver(lowerer, access.expression, STRING, access.name.text)
: lowerReceiver();
const re = lowerRegexPatternArgument(lowerer, arg0);
const re = checkedPattern
? lowerCheckedPatternArgument(lowerer, arg0)
: lowerRegexPatternArgument(lowerer, arg0);
// RegExpMatchArray | null maps to the string[] | null union by
// itself; intern it directly when the checker's spelling doesn't —
// or when it maps to something WIDER (an optional-chain call node
@@ -983,10 +987,13 @@ export function lowerRegexMethodCall(
// 0, exactly Node. Never throws.
if (receiverKind === "string" && name === "search") {
const arg0 = call.arguments.nodes[0];
if (!arg0 || !isRegexPatternArgument(lowerer, arg0)) return null; // string-pattern form: the SC2020 fence
if (call.arguments.nodes.length !== 1) return null;
if (!arg0 || call.arguments.nodes.length !== 1) return null;
const checkedPattern = isCheckedPatternArgument(lowerer, arg0);
if (!checkedPattern && !isRegexPatternArgument(lowerer, arg0)) return null; // string-pattern form: the SC2020 fence
const receiver = lowerReceiver();
const re = lowerRegexPatternArgument(lowerer, arg0);
const re = checkedPattern
? lowerCheckedPatternArgument(lowerer, arg0)
: lowerRegexPatternArgument(lowerer, arg0);
return { kind: "regexIntrinsic", method: "search", receiver, args: [re], type: F64, loc };
}
if (receiverKind === "string" && (name === "replace" || name === "replaceAll")) {
@@ -1936,6 +1943,46 @@ function isRegexPatternArgument(lowerer: Lowerer, node: ts.Expression): boolean
);
}
/** True when a `match` or `search` pattern is checker-untyped (`any` or
* `unknown`), so its runtime value decides the protocol. */
function isCheckedPatternArgument(lowerer: Lowerer, node: ts.Expression): boolean {
if (ts.isSpreadElement(node)) return false;
return (lowerer.typeOf(node).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
}
/** An untyped `match` or `search` pattern as a RegExp, chosen at run time as
* Node does: a RegExp is used as is (its lastIndex state included), and any
* other value builds `new RegExp(value)` (undefined is the empty pattern,
* other values convert with ToString). */
function lowerCheckedPatternArgument(lowerer: Lowerer, node: ts.Expression): IrExpr {
const loc = locOf(node);
const value = lowerer.lowerExpr(node);
if (value.type.kind === "regex") return value;
const checked = lowerer.coerceInto(node, value, DYN);
const local = lowerer.declareHiddenLocal("%matchPattern", DYN);
const ref = varRef(local.id, DYN, loc);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: local.id, init: checked, loc }],
result: {
kind: "ternary",
cond: { kind: "libCall", fn: "dyn.nativeRegexIs", args: [ref], type: BOOL, loc },
then: lowerer.coerceInto(node, ref, REGEX),
else_: {
kind: "libCall",
fn: "regex.newChecked",
args: [ref, dynUndefinedExpr(loc)],
type: REGEX,
loc,
},
type: REGEX,
loc,
},
type: REGEX,
loc,
};
}
/** The pattern of `match` or `search` as a RegExp: an undefined pattern is
* `new RegExp(undefined)`, the empty pattern, as in Node. */
function lowerRegexPatternArgument(lowerer: Lowerer, node: ts.Expression): IrExpr {
@@ -2,6 +2,7 @@ import { keyedHelperName } from "../stable-names.js";
import * as ts from "../../ts7/adapter.js";
import {
BOOL,
DYN,
F64,
REGEX,
STRING,
@@ -14,7 +15,7 @@ import {
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { numLit, strLit, varRef } from "../../../ir/build.js";
import { dynUndefinedExpr, numLit, strLit, varRef } from "../../../ir/build.js";
import { InternalCompilerError } from "../../../errors.js";
import { locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
@@ -65,14 +66,14 @@ export function lowerStringReplacement(
const search = argument(args[0], false);
const replacement = argument(args[1], true);
if (search.type.kind === "dyn" || replacement.type.kind === "dyn") {
if (replacement.type.kind === "func") {
lowerer.noLowering(`string .${method} with checked search values and callbacks`, call);
}
// Evaluate all inputs once before choosing the search protocol. Regexes
// retain their native pattern; plain values use the string-search helper.
// A typed callback receives the regex match's arguments or the plain
// search's (match, offset, subject).
const values = [receiver, search, replacement];
const locals = values.map((value) => lowerer.declareHiddenLocal("%replaceArg", value.type));
const refs = locals.map((local) => varRef(local.id, local.type, loc));
const callback = replacement.type.kind === "func";
const checkedReplacement =
replacement.type.kind === "dyn"
? lowerer.coerceInto(args[1] ?? call, refs[2]!, STRING)
@@ -91,14 +92,27 @@ export function lowerStringReplacement(
? {
kind: "ternary",
cond: { kind: "libCall", fn: "dyn.nativeRegexIs", args: [refs[1]!], type: BOOL, loc },
then: {
kind: "regexIntrinsic",
method,
receiver: refs[0]!,
args: [lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX), checkedReplacement],
type: STRING,
loc,
},
then: callback
? {
kind: "libCall",
fn: "regex.replaceCallback",
args: [
refs[0]!,
lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX),
lowerer.coerceToExpected(refs[2]!, DYN),
{ kind: "boolLit", value: method === "replaceAll", type: BOOL, loc },
],
type: STRING,
loc,
}
: {
kind: "regexIntrinsic",
method,
receiver: refs[0]!,
args: [lowerer.coerceInto(args[0] ?? call, refs[1]!, REGEX), checkedReplacement],
type: STRING,
loc,
},
else_: plain,
type: STRING,
loc,
@@ -148,11 +162,17 @@ function lowerStringReplacementValues(
if (callback.kind !== "func") {
throw new InternalCompilerError(`string .${method} replacement of type ${callback.kind}`);
}
// A checked parameter receives the boxed argument (undefined after the
// subject), and a checked, number or boolean result converts with
// ToString.
const full = [STRING, F64, STRING];
const ret = callback.ret.kind;
if (
callback.ret.kind !== "string" ||
callback.params.length > full.length ||
!callback.params.every((type, index) => typeEquals(type, full[index]!))
(ret !== "string" && ret !== "dyn" && ret !== "f64" && ret !== "bool") ||
!callback.params.every(
(type, index) =>
type.kind === "dyn" || (index < full.length && typeEquals(type, full[index]!)),
)
) {
lowerer.noLowering(
`string .${method} callback signature`,
@@ -175,13 +195,21 @@ function lowerStringReplacementValues(
const chunksType = arrayOf(STRING);
const chunks = varRef("chunks.0", chunksType, loc);
const builder = stringBuilder(loc, chunks);
const value: IrExpr = {
const result: IrExpr = {
kind: "callValue",
callee: replace,
args: [needle, match, subject].slice(0, callback.params.length),
type: STRING,
args: callback.params.map((type, index) => {
const arg = [needle, match, subject][index];
if (arg === undefined) return dynUndefinedExpr(loc);
return type.kind === "dyn" ? lowerer.coerceToExpected(arg, DYN) : arg;
}),
type: callback.ret,
loc,
};
const value: IrExpr =
ret === "dyn"
? { kind: "libCall", fn: "dyn.toStringCoerce", args: [result], type: STRING, loc }
: lowerer.ensureString(result, call);
const stop: IrStmt = { kind: "break", loc };
const body: IrStmt[] = [
builder.declare("chunks.0", { kind: "arrayLit", elems: [], type: chunksType, loc }),
@@ -81,6 +81,7 @@ const DISPATCH_LIB_CALLS = new Set<string>([
"dyn.propertyIsEnumerableComputed",
"dyn.defineProperty",
"dyn.getPrototype",
"dyn.getPrototypeForIdentity",
"dyn.inheritsErrorPrototype",
"dyn.reflectGet",
"dyn.reflectSet",
@@ -727,6 +727,11 @@ function keyOrderFinding(
written: readonly string[],
assignedNames: ReadonlySet<string>,
): { native: string; node: string; detail: "reordered" | "added-later" } | null {
// Values of a shape with orderVariants enumerate in the order their
// literal wrote (key-order-witness.ts).
const mapped = safely(() => lowerer.mapTypeOf(contextual));
if (mapped?.kind === "record" && lowerer.shapes.get(mapped.shapeId)?.orderVariants !== undefined)
return null;
// A shape whose table the witness pass re-chose (key-order-witness.ts)
// enumerates that table, not the checker's declaration order.
const table = witnessTable(lowerer, contextual);
@@ -94,12 +94,15 @@ export function refreshErrorPropertyDispatch(
lowerer.isSubclassOf(info.def.name, "%Error"),
);
const properties: ("name" | "message")[] = ["name", "message"];
// Test lowerers built from partial objects leave the location undefined.
const accessors = Boolean(lowerer.errorAccessorReflection);
for (const property of properties) {
if (
overrides.length &&
(overrides.length || accessors) &&
!lowerer.liftedFns.some((fn) => fn.name === `%error.${property}.read`)
) {
errorPropertyRead(lowerer, varRef("error.0", errorType, overrides[0]!.def.loc), property);
const loc = overrides[0]?.def.loc ?? lowerer.errorAccessorReflection!;
errorPropertyRead(lowerer, varRef("error.0", errorType, loc), property);
return true;
}
}
@@ -115,6 +118,7 @@ export function refreshErrorPropertyDispatch(
const revision = JSON.stringify([
readers.map((fn) => fn.name),
overrides.map((info) => [info.def.name, hasClassPrototypeData(info)]),
accessors,
]);
if (lowerer.errorPropertyDispatchRevision === revision) return false;
lowerer.errorPropertyDispatchRevision = revision;
@@ -183,6 +187,50 @@ export function refreshErrorPropertyDispatch(
},
];
const body: IrStmt[] = [{ kind: "if", cond: own, then: fallback, else_: null, loc }];
// An own accessor a reflective definition installed (the slot reads as
// absent then): the read calls its getter, the write its setter.
if (accessors)
body.push({
kind: "if",
cond: { kind: "libCall", fn: "error.ownAccessor", args: [receiver, key], type: BOOL, loc },
then: write
? [
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "error.accessorSet",
args: [
receiver,
key,
lowerer.coerceToExpected(varRef("value", STRING, loc), DYN),
],
type: VOID,
loc,
},
loc,
},
{ kind: "return", value: null, loc },
]
: [
{
kind: "return",
value: lowerer.coerceToExpected(
{
kind: "libCall",
fn: "error.accessorGet",
args: [receiver, key],
type: DYN,
loc,
},
STRING,
),
loc,
},
],
else_: null,
loc,
});
helper.locals = helper.locals.filter((local) => !local.id.startsWith("descriptor."));
for (const info of overrides) {
const member = `${write ? "set" : "get"}:${property}`;
@@ -2841,6 +2841,24 @@ function lowerLogicalPair(lowerer: Lowerer, expr: ts.BinaryExpression, left: IrE
loc,
};
}
// A JavaScript default that tsc types `any` (`e.title || null` in an
// instance of a function with untyped parameters, where `e.title` is a
// string): the checked domain, as for union operands above.
if (
target === null &&
isJsSourceFile(ts.getSourceFileOfNode(expr)) &&
lowerer.dynConvertible(left.type) &&
lowerer.dynConvertible(right.type)
) {
return {
kind: "logical",
op: op === ts.SyntaxKind.AmpersandAmpersandToken ? "&&" : "||",
left: lowerer.coerceInto(expr.left, left, DYN),
right: lowerer.coerceInto(expr.right, right, DYN),
type: DYN,
loc,
};
}
lowerer.unsupported("SC1042", expr);
}
return {
@@ -566,8 +566,8 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
}
}
if (receiverIr?.kind !== "array") {
const receiver = tryLowerExpression(lowerer, expr.expression);
{
const receiver = tryLowerExpression(lowerer, expr.expression);
if (
receiver?.type.kind === "dyn" ||
(receiver &&
@@ -618,7 +618,13 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
}
}
}
if (receiverIr?.kind === "string") {
// A checker-`any` receiver that lowers to a string (an implicit-any
// package parameter specialized for a string argument) indexes the
// string the same way.
if (
receiverIr?.kind === "string" ||
(receiverIr === null && receiver?.type.kind === "string")
) {
// An indexed string property is absent for non-integer and missing
// indexes. charAt has different coercion and out-of-range semantics.
const recv = lowerer.lowerExpr(expr.expression);
@@ -865,7 +865,21 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
// reach here — their hooks claim the property/new forms first).
{
const sym = lowerer.resolveValueSymbol(expr);
const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined;
let classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined;
// A class local to a function lowered once per instantiation
// (`$constructor` called with different argument types) registers per
// instance under the same symbol: a hoisted function of a later
// instance, lowered before its own class statement, must not reach the
// class an earlier instance collected.
const declaration = sym?.valueDeclaration;
if (
classInfo &&
lowerer.localClassInstantiations.length > 0 &&
declaration &&
ts.isClassDeclaration(declaration) &&
lowerer.classNamer(declaration) !== classInfo.def.name
)
classInfo = undefined;
// A local class referenced from a function hoisted above its statement:
// its TDZ box, holding the class as a checked-dynamic constructor.
if (!classInfo && sym && predeclareForwardClass(lowerer, sym)) {
@@ -13,6 +13,7 @@ import {
STRING,
type SrcLoc,
UNDEFINED_T,
VOID,
arrayOf,
canAdaptDynFuncTo,
canDynCheckTo,
@@ -32,6 +33,7 @@ import {
isOptionalProcessVersionsRead,
} from "../builtins/process.js";
import { requestGeneralStreamSurface } from "../general-streams.js";
import { isOptionalReadlineCursorRead } from "../builtins/readline.js";
import { literalValues } from "../../literal-values.js";
import { literalUnionArm } from "../../union-discriminants.js";
import { checkedArmExtraction } from "../coercions/unions.js";
@@ -47,6 +49,7 @@ import {
import { isConstAssertionTypeNode, unitOnlyUnion } from "../../type-mapper.js";
import { assertsGlobalObject, extractionCopiesReference } from "../assertion-identity.js";
import { iterableSlotValue, iterableUnionOf } from "../iterable-values.js";
import { isDeclBoundaryTypeNode } from "../../decl-projection.js";
/** `c ? a : b`. An explicit destination constructs each branch at that
* layout before joining it. Without one, retain the expression's inferred
@@ -615,6 +618,8 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
if (ts.isPropertyAccessExpression(node) && isOptionalProcessStreamProperty(lowerer, node))
return expr;
if (isOptionalProcessVersionsRead(lowerer, node)) return expr;
if (ts.isPropertyAccessExpression(node) && isOptionalReadlineCursorRead(lowerer, node))
return expr;
// Represented fields and index values can include undefined even when
// checker inference or lib.d.ts declares strings (RegExp captures).
@@ -1516,6 +1521,12 @@ export function lowerAsExpression(
const target = lowerer.mapTypeOf(targetTs) ?? dynFallbackType(lowerer, expr.type, targetTs);
if (!target) lowerer.badType(expr.type, targetTs);
if (target.kind === "dyn") return inner; // `as unknown`: erasure
// The declaration-typed boundary (decl-projection.ts): an identity-
// preserving checked conversion of a value package code produced.
if (isDeclBoundaryTypeNode(expr.type)) {
const adopted = declBoundaryCast(lowerer, inner, target, locOf(expr));
if (adopted) return adopted;
}
// An all-`unknown`-fields record target (`err as { code?: unknown }`
// — the errno-probing idiom): there is nothing to validate (every
// field is unknown, exactly what the dyn value already answers) and
@@ -1603,3 +1614,83 @@ export function lowerAsExpression(
}
return { kind: "dynCheck", value: inner, type: target, loc: locOf(expr) };
}
/** True when a type holds a record or an array somewhere (values whose
* identity can be observed). */
function holdsReference(lowerer: Lowerer, t: IrType, seen = new Set<string>()): boolean {
if (t.kind === "record" || t.kind === "array") return true;
if (t.kind !== "union" || seen.has(t.unionId)) return false;
seen.add(t.unionId);
return (
lowerer.unions.get(t.unionId)?.arms.some((arm) => holdsReference(lowerer, arm, seen)) ?? false
);
}
/** `(<package expr> as __scriptc_decl_N)`: check the dynamic value against
* the projected type and build the native value (dynCheck), then adopt:
* every source object or array that something else still references
* becomes a live capsule of the value built from it
* (scr_dyn_decl_adopt), so identity and mutation visibility hold on both
* sides. Scalar targets are ordinary checked casts. */
function declBoundaryCast(
lowerer: Lowerer,
inner: IrExpr,
target: IrType,
loc: SrcLoc,
): IrExpr | null {
if (
!canDynCheckTo(
target,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
) &&
!(target.kind === "union" && lowerer.dynConvertible(target))
)
return null;
if (!holdsReference(lowerer, target)) {
return { kind: "dynCheck", value: inner, type: target, loc };
}
const source = lowerer.declareHiddenLocal("%boundarySource", DYN);
const value = lowerer.declareHiddenLocal("%boundaryValue", target);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: source.id, init: inner, loc },
{
kind: "varDecl",
localId: value.id,
init: {
kind: "dynCheck",
value: varRef(source.id, DYN, loc),
adopt: true,
type: target,
loc,
},
loc,
},
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "dyn.declAdopt",
args: [
varRef(source.id, DYN, loc),
{
kind: "dynFrom",
value: varRef(value.id, target, loc),
liveRef: true,
type: DYN,
loc,
},
],
type: VOID,
loc,
},
loc,
},
],
result: varRef(value.id, target, loc),
type: target,
loc,
};
}
@@ -100,7 +100,9 @@ export function lowerShortCircuitAssignment(lowerer: Lowerer, expr: ts.BinaryExp
write = (value) => eventAccessorWrite(receiver, name, value, lowerer, loc);
} else if (ts.isPropertyAccessExpression(target)) {
const probed = tryLowerExpression(lowerer, target.expression);
if (probed?.type.kind === "dyn") {
// `value.#x ??= v` on a checked-dynamic value names the brand-checked
// field (fieldTarget), never a property called "#x".
if (probed?.type.kind === "dyn" && !ts.isPrivateIdentifier(target.name)) {
const receiver = save(probed, "%shortCircuitReceiver");
const key: IrExpr = { kind: "strLit", value: target.name.text, type: STRING, loc };
storage = DYN;
@@ -2145,7 +2145,12 @@ export function lowerObjectLiteral(
orderSegments.push(
srcLowered.type.kind === "dyn"
? { kind: "opaque" }
: { kind: "spread", shapeId: srcType.shapeId, maybeAbsent: false },
: {
kind: "spread",
shapeId: srcType.shapeId,
maybeAbsent: false,
...(srcLowered.kind === "varRef" ? { value: srcLowered } : {}),
},
);
for (const f of srcShape.fields) {
if (laterNames.has(f.name)) continue;
@@ -1,3 +1,4 @@
import { isNavigatorGlobal } from "../builtins/navigator.js";
import { isSafeToDiscard } from "./evaluation-safety.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { dynUndefinedExpr } from "../../../ir/build.js";
@@ -223,6 +224,16 @@ export function lowerOptionalChain(
// Native handle methods have no first-class value to guard; when the
// method read itself has no lowering, re-dispatch the plain call (which
// reports its own diagnostics) instead of failing on the value read.
// `navigator?.userAgent`: Node's navigator global is never nullish and
// has no value form, so the guarded step is the plain member read.
if (isNavigatorGlobal(lowerer, recvNode)) {
lowerer.chainHandled.add(dotNode);
try {
return lowerer.lowerExpr(expr);
} finally {
lowerer.chainHandled.delete(dotNode);
}
}
let loweredReceiver: IrExpr | null;
if (dotNode === expr && alwaysPresentStdlibMethod(lowerer, recvNode)) {
loweredReceiver = tryLowerExpression(lowerer, recvNode);
@@ -306,10 +306,10 @@ export function lowerPropertyAccessExpr(
};
}
}
if (!expr.questionDotToken) {
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
}
// Node's navigator is never nullish, so `navigator?.userAgent` (zod's
// Cloudflare probe) folds like `navigator.userAgent`.
const navigator = lowerNavigatorMember(lowerer, expr, NODE_COMPAT_VERSION);
if (navigator) return navigator;
// A dgram socket's method read as a value in JavaScript (`emit:
// socket.emit.bind(socket)`): the socket crosses as its checked-dynamic
// handle, whose methods are function values that dispatch on their
@@ -58,6 +58,44 @@ import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors
import { hasRuntimeStatics } from "../class-runtime-statics.js";
import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js";
/** The class value an `instanceof` right-hand side holds. A JavaScript
* object's property holding a program class (`holder.type`) reads as a
* checked-dynamic value that the checker types as the class: it converts
* to the class value. Runtime-provided classes keep their own lowering. */
function classValueOperand(
lowerer: Lowerer,
right: ts.Expression,
type: IrType & { kind: "classval" },
): IrExpr {
if (
!isJsSourceFile(ts.getSourceFileOfNode(right)) ||
ts.isIdentifier(right) ||
lowerer.classes.get(type.className)?.def.runtime
)
return lowerer.lowerExpr(right);
const value = lowerer.lowerExprExpecting(right, DYN);
if (value.type.kind !== "dyn") return value;
return { kind: "dynCheck", value, type, loc: locOf(right) };
}
/** The value kinds that never hold an instance of a program class. */
const NO_CLASS_INSTANCE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"f64",
"bigint",
"date",
"string",
"bool",
"array",
"map",
"set",
"regex",
"bytes",
"symbol",
"promise",
"undefinedT",
"nullT",
]);
/** `x instanceof C` for a program-declared class C. When x's static
* class and C are both in extends-hierarchies the test is dynamic — the
* O(1) preorder-interval check against the vtable (`instanceOf` node).
@@ -400,7 +438,58 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
// (itself — no descendants can flow into the slot), so the answer
// folds statically exactly like the named-target folds below.
const rhsClassval = storedClassValueType(lowerer, expr.right);
if (rhsClassval?.kind === "classval" && !lowerer.caughtLocalOf(expr.left)) {
// A catch binding against a class held by a binding (`var E = class
// extends Error {}`). The binding holds the declared class or one of its
// subclasses. Without subclasses the test is the class's caught test,
// after the right operand's read; otherwise a payload of the declared
// class is tested against the class the binding holds.
const caught = lowerer.caughtLocalOf(expr.left);
const caughtTarget =
rhsClassval?.kind === "classval" && caught
? lowerer.classes.get(rhsClassval.className)
: undefined;
if (caught && caughtTarget && !caughtTarget.localClass && !caughtTarget.def.runtime) {
const classValue = lowerer.lowerExpr(expr.right);
if (caughtTarget.subclasses.length === 0) {
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: classValue, loc }],
result: caughtClassInstanceOf(lowerer, caught.id, caughtTarget, loc),
type: BOOL,
loc,
};
}
if (classValue.type.kind !== "classval")
lowerer.badType(expr.right, lowerer.typeOf(expr.right));
const held = lowerer.declareHiddenLocal("%instanceofClass", classValue.type);
const instance: IrType = { kind: "object", className: caughtTarget.def.name };
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: held.id, init: classValue, loc }],
result: {
kind: "logical",
op: "&&",
left: caughtClassInstanceOf(lowerer, caught.id, caughtTarget, loc),
right: {
kind: "instanceOfValue",
value: {
kind: "caughtNarrow",
value: varRef(caught.id, CAUGHT, loc),
type: instance,
loc,
},
classValue: varRef(held.id, classValue.type, loc),
type: BOOL,
loc,
},
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
if (rhsClassval?.kind === "classval" && !caught) {
const targetInfo = lowerer.classes.get(rhsClassval.className);
if (!targetInfo) {
// The type world names a class the lowering never registered
@@ -432,7 +521,7 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
"'instanceof' through a runtime-provided class value on an untyped value (give the operand a class type first)",
);
}
const classValue = lowerer.lowerExpr(expr.right);
const classValue = classValueOperand(lowerer, expr.right, rhsClassval);
return classInstanceOf(lowerer, left, targetInfo, loc, classValue);
}
// A union of class instances against a runtime class value (`new K()`
@@ -441,6 +530,22 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
const tested = unionClassValueInstanceOf(lowerer, left, expr.right, targetInfo, loc);
if (tested) return tested;
}
// A value whose type holds no class instance (a primitive, a native
// collection, a date, a regex, an array) is never an instance
// of a program class: false, after both operands evaluate.
if (!targetInfo.def.runtime && NO_CLASS_INSTANCE_KINDS.has(left.type.kind)) {
const classValue = classValueOperand(lowerer, expr.right, rhsClassval);
return {
kind: "seqExpr",
stmts: [
{ kind: "exprStmt", expr: left, loc },
{ kind: "exprStmt", expr: classValue, loc },
],
result: { kind: "boolLit", value: false, type: BOOL, loc },
type: BOOL,
loc,
};
}
if (left.type.kind !== "object") {
lowerer.unsupported(
"SC1090",
@@ -455,7 +560,7 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
(lowerer.inHierarchy(targetInfo) && lowerer.inHierarchy(lhsInfo)) ||
(targetInfo.localClass !== undefined && lhsInfo.localClass !== undefined)
) {
const classValue = lowerer.lowerExpr(expr.right);
const classValue = classValueOperand(lowerer, expr.right, rhsClassval);
if (classValue.type.kind !== "classval")
lowerer.badType(expr.right, lowerer.typeOf(expr.right));
const writableTest = writableClassValueTest(
@@ -493,8 +598,11 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
"statically-decided 'instanceof' on computed operands (bind the values to variables first)",
);
}
// A checked-dynamic right-hand side (a constructor read from a table, a
// structural constructor type, a builtin constructor held as `any`):
// the runtime's instanceof over the value, in both source languages.
const constructor = tryLowerExpression(lowerer, expr.right);
if (constructor?.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))) {
if (constructor?.type.kind === "dyn") {
const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN);
return checkedClassInstanceOf(
lowerer,
@@ -551,39 +659,8 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
// narrowing then types the branches; reads bridge through caughtRead.
const caughtLocal = lowerer.caughtLocalOf(expr.left);
if (caughtLocal) {
const caughtInstanceOf = (info: ClassInfo): IrExpr => {
if (!lowerer.inHierarchy(info)) {
const value = varRef(caughtLocal.id, CAUGHT, loc);
return {
kind: "ternary",
cond: {
kind: "caughtTest",
value,
test: "instanceof",
className: info.def.name,
type: BOOL,
loc,
},
then: { kind: "boolLit", value: true, type: BOOL, loc },
else_: classInstanceOf(
lowerer,
{ kind: "caughtToDyn", value, type: DYN, loc },
info,
loc,
),
type: BOOL,
loc,
};
}
return {
kind: "caughtTest",
value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc },
test: "instanceof",
className: info.def.name,
type: BOOL,
loc,
};
};
const caughtInstanceOf = (info: ClassInfo): IrExpr =>
caughtClassInstanceOf(lowerer, caughtLocal.id, info, loc);
let test = caughtInstanceOf(target);
// A thrown checked-dynamic object whose prototype chain reaches
// Error.prototype (an ES5 constructor function inheriting from it).
@@ -978,6 +1055,42 @@ function writableClassValueTest(
};
}
/** A catch binding tested against a program class: the runtime test against
* the class's preorder interval (false for non-hierarchy-object payloads). */
function caughtClassInstanceOf(
lowerer: Lowerer,
caughtId: string,
info: ClassInfo,
loc: SrcLoc,
): IrExpr {
if (!lowerer.inHierarchy(info)) {
const value = varRef(caughtId, CAUGHT, loc);
return {
kind: "ternary",
cond: {
kind: "caughtTest",
value,
test: "instanceof",
className: info.def.name,
type: BOOL,
loc,
},
then: { kind: "boolLit", value: true, type: BOOL, loc },
else_: classInstanceOf(lowerer, { kind: "caughtToDyn", value, type: DYN, loc }, info, loc),
type: BOOL,
loc,
};
}
return {
kind: "caughtTest",
value: { kind: "varRef", localId: caughtId, type: CAUGHT, loc },
test: "instanceof",
className: info.def.name,
type: BOOL,
loc,
};
}
/** A statically decided `instanceof` still reads its class after the left
* operand: a class with an initialization flag throws there before its
* declaration ran. */
@@ -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<string>): Set<string> {
const names = new Set<string>();
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<string>, 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<string> | 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) {
@@ -1,5 +1,12 @@
import { expect, test } from "vitest";
import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js";
import {
F64,
KEY_ORDER_FIELD,
type IrExpr,
type IrFunction,
type IrStmt,
type IrType,
} from "../../ir/ir.js";
import { observeRecordLiteralRebuilds, transformExpr } from "../../ir/traverse.js";
import { ShapeRegistry } from "../type-mapper.js";
import {
@@ -21,7 +28,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[]): void;
run(body: IrStmt[]): IrFunction;
order(type: IrType & { kind: "record" }): string[] | undefined;
}
@@ -35,6 +42,14 @@ function fixture(): Fixture {
classes: new Map(),
armTag: (unionId: string, arm: IrType): number =>
unionId === "u0" && arm.kind === "undefinedT" ? 1 : -1,
recordFieldPresent: (obj: IrExpr, shapeId: string, field: string): IrExpr => ({
kind: "recordHas",
obj,
shapeId,
field,
type: { kind: "bool" },
loc,
}),
} as unknown as Lowerer;
const record = (fields: [string, IrType][], order: string[]): IrType & { kind: "record" } => ({
kind: "record",
@@ -55,15 +70,20 @@ function fixture(): Fixture {
segments ?? keys.map((name) => ({ kind: "key", name, definite: true })),
);
};
const run = (body: IrStmt[]): void => {
const run = (body: IrStmt[]): IrFunction => {
const fn: IrFunction = { name: "main", params: [], returnType: F64, locals: [], body, loc };
applyKeyOrderWitnesses(lowerer, [fn]);
return fn;
};
const order = (type: IrType & { kind: "record" }): string[] | undefined =>
shapes.get(type.shapeId)?.declaredOrder;
return { lowerer, shapes, notes, record, literal, run, order };
}
/** The order index a literal stores (KEY_ORDER_FIELD), when it stores one. */
const orderIndexOf = (lit: IrExpr): IrExpr | undefined =>
lit.kind === "recordLit" ? lit.fields.find((x) => x.name === KEY_ORDER_FIELD)?.value : undefined;
const observe = (value: IrExpr): IrStmt => ({
kind: "exprStmt",
expr: { kind: "jsonStringify", value, type: { kind: "string" }, loc },
@@ -109,7 +129,7 @@ test("literals that agree with the table leave the same array in place", () => {
expect(f.notes.changed.size).toBe(0);
});
test("values built in two orders keep today's table", () => {
test("values built in two orders each record their literal's order", () => {
const f = fixture();
const ab = f.record(
[
@@ -118,8 +138,71 @@ test("values built in two orders keep today's table", () => {
],
["a", "b"],
);
f.run([observe(f.literal(ab, ["b", "a"])), observe(f.literal(ab, ["a", "b"]))]);
const ba = f.literal(ab, ["b", "a"]);
const aOrder = f.literal(ab, ["a", "b"]);
f.run([observe(ba), observe(aOrder)]);
expect(f.order(ab)).toEqual(["a", "b"]);
const shape = f.shapes.get(ab.shapeId)!;
expect(shape.orderVariants).toEqual([
["a", "b"],
["b", "a"],
]);
expect(shape.fields.map((x) => x.name)).toEqual([KEY_ORDER_FIELD, "a", "b"]);
expect(orderIndexOf(ba)).toMatchObject({ kind: "numLit", value: 1 });
expect(orderIndexOf(aOrder)).toMatchObject({ kind: "numLit", value: 0 });
});
test("a copy of a value with orders stores its source's index", () => {
const f = fixture();
const abc = f.record(
[
["a", F64],
["b", F64],
["c", F64],
],
["a", "b", "c"],
);
const ab = f.record(
[
["a", F64],
["b", F64],
],
["a", "b"],
);
const read = (field: string): IrExpr => ({
kind: "recordGet",
obj: ref("x", abc),
shapeId: abc.shapeId,
field,
type: F64,
loc,
});
const rest = f.notes.copy(
{
kind: "recordLit",
fields: [
{ name: "a", value: read("a") },
{ name: "b", value: read("b") },
],
type: ab,
loc,
},
abc.shapeId,
);
f.run([
decl("x", f.literal(abc, ["c", "b", "a"])),
observe(f.literal(abc, ["a", "b", "c"])),
observe(rest),
]);
expect(f.shapes.get(ab.shapeId)!.orderVariants).toEqual([
["a", "b"],
["b", "a"],
]);
expect(orderIndexOf(rest)).toMatchObject({
kind: "recordGet",
obj: { kind: "varRef", localId: "x" },
field: KEY_ORDER_FIELD,
});
});
test("an unobserved shape keeps today's table", () => {
@@ -337,7 +420,7 @@ test("straight-line writes of late-only keys keep program order", () => {
expect(f.order(o)).toEqual(["id", "y", "x"]);
});
test("late-only keys written through different bindings keep today's table", () => {
test("late-only keys written through different bindings move to the end per value", () => {
const f = fixture();
const o = f.record(
[
@@ -354,9 +437,13 @@ test("late-only keys written through different bindings keep today's table", ()
observe(ref("o", o)),
]);
expect(f.order(o)).toEqual(["id", "x", "y"]);
expect(f.shapes.get(o.shapeId)!.orderVariants).toEqual([
["id", "x", "y"],
["id", "y", "x"],
]);
});
test("a late-only key written in a loop over an outer binding keeps today's table", () => {
test("a late-only key written in a loop guards the write with its move", () => {
const f = fixture();
const o = f.record(
[
@@ -366,7 +453,7 @@ test("a late-only key written in a loop over an outer binding keeps today's tabl
],
["id", "x", "y"],
);
f.run([
const body: IrStmt[] = [
decl("o", f.literal(o, ["id"])),
set(ref("o", o), "y"),
{
@@ -376,11 +463,18 @@ test("a late-only key written in a loop over an outer binding keeps today's tabl
loc,
},
observe(ref("o", o)),
]);
];
const fn = f.run(body);
expect(f.order(o)).toEqual(["id", "x", "y"]);
const loop = fn.body.find((st) => st.kind === "while") as IrStmt & { kind: "while" };
const guarded = loop.body[0]!;
expect(guarded.kind).toBe("block");
const [move, write] = (guarded as IrStmt & { kind: "block" }).body;
expect(move).toMatchObject({ kind: "if", cond: { kind: "unary", op: "!" } });
expect(write).toMatchObject({ kind: "recordSet", field: "x" });
});
test("re-adding a key a literal creates earlier keeps today's table", () => {
test("re-adding a key a literal creates earlier moves it to the end of that value", () => {
const f = fixture();
const o = f.record(
[
@@ -399,4 +493,210 @@ test("re-adding a key a literal creates earlier keeps today's table", () => {
};
f.run([decl("o", f.literal(o, ["a", "b"])), del, set(ref("o", o), "a"), observe(ref("o", o))]);
expect(f.order(o)).toEqual(["a", "b"]);
expect(f.shapes.get(o.shapeId)!.orderVariants).toEqual([
["a", "b"],
["b", "a"],
]);
});
test("a moving write evaluates its value before it tests and moves the key", () => {
const f = fixture();
const o = f.record(
[
["id", F64],
["x", OPT],
["y", OPT],
],
["id", "x", "y"],
);
// `o.x = ((o.y = 2), 3)`: y is created first.
const value: IrExpr = {
kind: "seqExpr",
stmts: [set(ref("o", o), "y")],
result: num(3),
type: F64,
loc,
};
const write: IrStmt = {
kind: "recordSet",
obj: ref("o", o),
shapeId: o.shapeId,
field: "x",
value,
loc,
};
const fn = f.run([
decl("o", f.literal(o, ["id"])),
observe(f.literal(o, ["x", "id"])),
write,
observe(ref("o", o)),
]);
const shape = f.shapes.get(o.shapeId)!;
expect(shape.orderVariants).toBeDefined();
const block = fn.body[2] as IrStmt & { kind: "block" };
expect(block.kind).toBe("block");
// The receiver evaluates first, since the value may assign its binding.
const [receiver, first, guard, written] = block.body;
expect(receiver).toMatchObject({ kind: "varDecl", init: { kind: "varRef", localId: "o" } });
const target = (receiver as IrStmt & { kind: "varDecl" }).localId;
expect(first).toMatchObject({ kind: "varDecl", init: { kind: "seqExpr" } });
const local = (first as IrStmt & { kind: "varDecl" }).localId;
expect(guard).toMatchObject({ kind: "if", cond: { kind: "unary", op: "!" } });
expect(written).toMatchObject({
kind: "recordSet",
obj: { kind: "varRef", localId: target },
field: "x",
value: { kind: "varRef", localId: local },
});
// The write inside the value moves y in its own block.
const init = (first as IrStmt & { kind: "varDecl" }).init as IrExpr & { kind: "seqExpr" };
expect(init.stmts[0]).toMatchObject({ kind: "block" });
});
test("a key after a spread that the source may lack picks its order from the source's keys", () => {
const f = fixture();
const s = f.record(
[
["a", F64],
["b", OPT],
["c", OPT],
],
["a", "b", "c"],
);
const t = f.record(
[
["a", F64],
["b", F64],
["c", OPT],
],
["a", "b", "c"],
);
const read = (field: string): IrExpr => ({
kind: "recordGet",
obj: ref("v", s),
shapeId: s.shapeId,
field,
type: OPT,
loc,
});
// `{ ...x, b: 1 }`, which evaluates `x` into `v` first
const merged = f.notes.literal(
{
kind: "recordLit",
fields: [
{ name: "a", value: read("a") },
{ name: "c", value: read("c") },
{ name: "b", value: num(1) },
],
type: t,
loc,
},
[
{
kind: "spread",
shapeId: s.shapeId,
maybeAbsent: false,
value: { kind: "varRef", localId: "v", type: s, loc },
},
{ kind: "key", name: "b", definite: true },
],
);
const prologue = [decl("v", ref("x", s))];
f.run([
decl("x", f.literal(s, ["c", "a"])),
observe(f.literal(s, ["a", "c"])),
observe({ kind: "seqExpr", stmts: prologue, result: merged, type: t, loc }),
]);
// b keeps its place in the source's order when the source has it, and
// follows the source's keys when it does not.
expect(f.shapes.get(t.shapeId)!.orderVariants).toEqual([
["a", "c", "b"],
["c", "a", "b"],
["a", "b", "c"],
]);
expect(prologue[1]).toMatchObject({
kind: "varDecl",
init: {
kind: "ternary",
cond: { kind: "recordHas", obj: { kind: "varRef", localId: "v" }, field: "b" },
},
});
const snapshot = (prologue[1] as IrStmt & { kind: "varDecl" }).localId;
expect(orderIndexOf(merged)).toMatchObject({ kind: "varRef", localId: snapshot });
});
test("a spread reads its source's order right after the literal evaluates the source", () => {
const f = fixture();
const s = f.record(
[
["a", F64],
["b", F64],
],
["a", "b"],
);
const t = f.record(
[
["a", F64],
["b", F64],
["first", F64],
["last", F64],
],
["first", "a", "b", "last"],
);
const read = (localId: string, field: string): IrExpr => ({
kind: "recordGet",
obj: ref(localId, s),
shapeId: s.shapeId,
field,
type: F64,
loc,
});
// `{ first: y.a, ...x, last: y.b }`: the literal evaluates `first`, then
// the source into `v` and its fields, then `last`, which reads another
// value of the source's shape.
const lit = f.notes.literal(
{
kind: "recordLit",
fields: [
{ name: "first", value: ref("p", F64) },
{ name: "a", value: ref("va", F64) },
{ name: "b", value: ref("vb", F64) },
{ name: "last", value: read("y", "b") },
],
type: t,
loc,
},
[
{ kind: "key", name: "first", definite: true },
{
kind: "spread",
shapeId: s.shapeId,
maybeAbsent: false,
value: { kind: "varRef", localId: "v", type: s, loc },
},
{ kind: "key", name: "last", definite: true },
],
);
const prologue: IrStmt[] = [
decl("p", read("y", "a")),
decl("v", ref("x", s)),
decl("va", read("v", "a")),
decl("vb", read("v", "b")),
];
f.run([
decl("x", f.literal(s, ["b", "a"])),
decl("y", f.literal(s, ["a", "b"])),
observe({ kind: "seqExpr", stmts: prologue, result: lit, type: t, loc }),
]);
expect(f.shapes.get(t.shapeId)!.orderVariants).toEqual([
["first", "a", "b", "last"],
["first", "b", "a", "last"],
]);
// The index is the spread binding's, read before `last` evaluates.
expect(prologue[2]).toMatchObject({
kind: "varDecl",
init: { kind: "recordGet", obj: { kind: "varRef", localId: "v" }, field: KEY_ORDER_FIELD },
});
const snapshot = (prologue[2] as IrStmt & { kind: "varDecl" }).localId;
expect(orderIndexOf(lit)).toMatchObject({ kind: "varRef", localId: snapshot });
});
@@ -41,20 +41,66 @@
* value: that shape, and every shape copied from it, keeps today's table
* (and today's SC6002 warnings). Shapes that no order surface observes
* keep today's table too. */
import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType } from "../../ir/ir.js";
import { shapeHasAccessorSlots, typeEquals, UNDEFINED_T } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild, everyTypeChild } from "../../ir/traverse.js";
import type { IrExpr, IrFunction, IrRecordShape, IrStmt, IrType, SrcLoc } from "../../ir/ir.js";
import {
BOOL,
F64,
KEY_ORDER_FIELD,
shapeHasAccessorSlots,
typeEquals,
UNDEFINED_T,
} from "../../ir/ir.js";
import {
everyExprChild,
everyStmtChild,
everyTypeChild,
transformStmtList,
} from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
import { ALWAYS_THROW_LIB_FNS } from "../../ir/builtin-effects.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
* branch per order (walks[i] for order i) on the value's KEY_ORDER_FIELD.
* `walks` follows shapeKeyOrders. */
export function perKeyOrder(
shape: IrRecordShape,
ref: IrExpr,
walks: readonly IrStmt[][],
loc: SrcLoc,
): IrStmt[] {
let tail = walks[0]!;
for (let i = walks.length - 1; i >= 1; i--) {
const index: IrExpr = {
kind: "recordGet",
obj: ref,
shapeId: shape.id,
field: KEY_ORDER_FIELD,
type: F64,
loc,
};
const cond: IrExpr = {
kind: "bin",
op: "===",
left: index,
right: { kind: "numLit", value: i, type: F64, loc },
type: BOOL,
loc,
};
tail = [{ kind: "if", cond, then: walks[i]!, else_: tail, loc }];
}
return tail;
}
/** One property of an object literal, in source order. */
export type KeyOrderSegment =
/** An explicit key (`definite`), or a conditional spread's key, which
* exists only when its condition holds. */
| { kind: "key"; name: string; definite: boolean }
/** `...source` of a record shape; `maybeAbsent` when the whole source
* can be undefined (`...options` of `T | undefined`). */
| { kind: "spread"; shapeId: string; maybeAbsent: boolean }
* can be undefined (`...options` of `T | undefined`). `value` is the
* binding the literal evaluated the source into, when it has one. */
| { kind: "spread"; shapeId: string; maybeAbsent: boolean; value?: IrExpr & { kind: "varRef" } }
/** A spread whose keys are only known at run time. */
| { kind: "opaque" };
@@ -227,6 +273,11 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
const opaqueSeen = new Set<string>();
const runtimeShapes = new Set<string>();
const runtimeSeen = new Set<string>();
/** Shapes the declaration-typed boundary converts into (adopting casts):
* their values come from package code that builds them in the
* declaration's order, the order their table holds. */
const declaredShapes = new Set<string>();
const declaredSeen = new Set<string>();
const producedBy = (type: IrType, opaque: boolean): void => {
if (opaque) shapesIn(type, opaqueShapes, opaqueSeen);
else shapesIn(type, runtimeShapes, runtimeSeen);
@@ -329,10 +380,15 @@ 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":
case "promiseWithResolvers":
producedBy(e.type, true);
return;
case "dynCheck":
if (!orderPreservingClone(e)) producedBy(e.type, false);
if (e.adopt === true) shapesIn(e.type, declaredShapes, declaredSeen);
else if (!orderPreservingClone(e)) producedBy(e.type, false);
return;
case "dynDestrCheck":
case "caughtCheck":
@@ -392,6 +448,7 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
f.opaque = true;
}
for (const id of runtimeShapes) factsOf(id).runtime = true;
for (const id of declaredShapes) if (!runtimeShapes.has(id)) factsOf(id).pinned = true;
// ── scope ───────────────────────────────────────────────────────────
// Only plain declared-field shapes with a table take part.
@@ -713,4 +770,541 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
shape.declaredOrder = [...shape.declaredOrder!.filter(isArrayIndex), ...resolved.order];
notes.changed.add(id);
}
// ── 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
// in (KEY_ORDER_FIELD), when every construction's order is known
// statically or copied from a value that records its own:
// - a literal of explicit keys writes its order;
// - a copy from a shape with one resolved order writes that order;
// - a copy from a value of a shape with orders writes the source's
// order, filtered to the copy's keys (rest bindings, width copies);
// - clones copy the field, and every other construction (synthesized
// literals, checked conversions, runtime producers) leaves 0, today's
// table, as before.
// A write of an optional key a value lacks moves the key to the end of
// the value's order, as in Node: the orders include every order such
// writes reach, and each write site updates the index after it evaluates
// the value and before it writes the key.
const MAX_ORDERS = 16;
const unresolved = (id: string): boolean => result.get(id) === "unknown";
/** `order` over every key of `table`: keys the order lacks follow it. */
const complete = (order: readonly string[], table: readonly string[]): string[] => {
const keys = new Set(table);
const out = order.filter((k) => keys.has(k));
const seen = new Set(out);
return [...out, ...table.filter((k) => !seen.has(k))];
};
const literalOrder = (segments: readonly KeyOrderSegment[]): string[] | null => {
const out: string[] = [];
for (const segment of segments) {
if (segment.kind !== "key" || !segment.definite) return null;
if (!isArrayIndex(segment.name)) out.push(segment.name);
}
return out;
};
/** `{ ...value }` of a value of the literal's own shape: a copy of the
* value's order. */
const selfSpread = (segments: readonly KeyOrderSegment[], id: string): boolean =>
segments.length === 1 &&
segments[0]!.kind === "spread" &&
segments[0]!.shapeId === id &&
!segments[0]!.maybeAbsent;
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.join("\0") === b.join("\0");
const indexOfOrder = (orders: readonly string[][], order: readonly string[]): number =>
orders.findIndex((o) => sameOrder(o, order));
/** The keys writes can add to a value of the shape, or null when a
* clone override can (the clone's order is its source's). */
const movingKeys = (id: string, f: ShapeFacts, table: readonly string[]): string[] | null => {
const keys = new Set(table);
const moving = new Set<string>();
for (const w of f.writes) {
if (w.kind === "delete" || !keys.has(w.field) || !maybeAbsent(id, w.field)) continue;
if (w.kind === "clone") return null;
moving.add(w.field);
}
return [...moving];
};
/** `values[i]` for the order index `current` holds, as a chain of
* tests. */
const indexChain = (current: IrExpr, values: readonly number[], loc: SrcLoc): IrExpr => {
let out: IrExpr = { kind: "numLit", value: values[values.length - 1]!, type: F64, loc };
for (let i = values.length - 2; i >= 0; i--) {
if (out.kind === "numLit" && out.value === values[i]) continue;
out = {
kind: "ternary",
cond: {
kind: "bin",
op: "===",
left: current,
right: { kind: "numLit", value: i, type: F64, loc },
type: BOOL,
loc,
},
then: { kind: "numLit", value: values[i]!, type: F64, loc },
else_: out,
type: F64,
loc,
};
}
return out;
};
const moves = new Map<string, Map<string, number[]>>();
/** Adds to `orders` every order writes of `moving` keys reach from them
* and records each key's transitions; false past MAX_ORDERS. */
const closeUnderMoves = (id: string, orders: string[][], moving: readonly string[]): boolean => {
const transitions = new Map<string, number[]>(moving.map((k) => [k, []]));
for (let i = 0; i < orders.length; i++) {
for (const key of moving) {
const moved = [...orders[i]!.filter((k) => k !== key), key];
let index = indexOfOrder(orders, moved);
if (index < 0) {
if (orders.length === MAX_ORDERS) return false;
index = orders.push(moved) - 1;
}
transitions.get(key)![i] = index;
}
}
if (moving.length > 0) moves.set(id, transitions);
return true;
};
// Observed shapes, and the shapes their values copy their orders from.
const wanted = new Set(observed);
const pending = [...observed];
while (pending.length > 0) {
const f = facts.get(pending.pop()!);
if (f === undefined) continue;
const sources = [
...f.copies.map((c) => c.source),
...f.literals.flatMap((segments) =>
segments.flatMap((s) => (s.kind === "spread" ? [s.shapeId] : [])),
),
];
for (const source of sources) {
if (wanted.has(source)) continue;
wanted.add(source);
pending.push(source);
}
}
const orderable = (
id: string,
f: ShapeFacts,
): { shape: IrRecordShape; moving: string[] } | null => {
const shape = shapes.get(id);
if (!wanted.has(id) || !unresolved(id) || !inScope(shape)) return null;
const moving = movingKeys(id, f, tableOf(shape));
return moving === null ? null : { shape, moving };
};
const variants = new Map<string, string[][]>();
for (const [id, f] of facts) {
const candidate = orderable(id, f);
// A runtime producer builds the shape's values in its fixed layout,
// which has no KEY_ORDER_FIELD.
if (candidate === null || f.literals.length === 0 || f.opaque) continue;
const table = tableOf(candidate.shape);
const orders: string[][] = [table];
let ok = true;
const add = (order: readonly string[]): void => {
const full = complete(order, table);
if (indexOfOrder(orders, full) < 0) orders.push(full);
};
for (const segments of f.literals) {
if (selfSpread(segments, id)) continue;
const order = literalOrder(segments);
if (order === null) ok = false;
else add(order);
}
for (const c of f.copies) {
if (c.source === id) continue;
const source = result.get(c.source);
if (source === undefined || source === "unknown") ok = false;
else add(source.order.filter((k) => c.keys.has(k)));
}
if (!ok || orders.length > MAX_ORDERS || !closeUnderMoves(id, orders, candidate.moving))
continue;
if (orders.length > 1) variants.set(id, orders);
else moves.delete(id);
}
// Shapes built only from values of one shape with orders, all in one
// way: copies (rest bindings, width copies), or literals spreading the
// value between the same explicit keys. Order i is the source's order i
// over the shape's keys, after the keys before the spread and before
// the keys after it. A key after the spread that the source value may
// lack goes where the source has it, or after the source's keys when it
// has not: such a literal's orders are one per source order and set of
// those keys the source has, and `select.index[present][i]` is the order
// for source order i with the keys of bit set `present`.
type Template = {
source: string;
prefix: string[];
suffix: string[];
select?: { keys: string[]; index: number[][] };
};
const templateOf = (segments: readonly KeyOrderSegment[], id: string): Template | null => {
const prefix: string[] = [];
const suffix: string[] = [];
let source: string | null = null;
for (const segment of segments) {
if (segment.kind === "opaque") return null;
if (segment.kind === "spread") {
if (source !== null || segment.maybeAbsent || segment.shapeId === id) return null;
source = segment.shapeId;
continue;
}
if (!segment.definite) return null;
(source === null ? prefix : suffix).push(segment.name);
}
return source === null ? null : { source, prefix, suffix };
};
const templateKey = (t: Template): string => JSON.stringify([t.source, t.prefix, t.suffix]);
const copyTemplate = (source: string): Template => ({ source, prefix: [], suffix: [] });
const mirrors = new Map<string, Template>();
for (const [id, f] of facts) {
if (variants.has(id) || f.pinned || f.runtime || f.opaque) continue;
const candidate = orderable(id, f);
if (candidate === null || f.literals.length + f.copies.length === 0) continue;
const templates: (Template | null)[] = f.literals.map((segments) => templateOf(segments, id));
for (const c of f.copies) templates.push(copyTemplate(c.source));
const first = templates[0];
if (
first === null ||
first === undefined ||
templates.some((t) => t === null || templateKey(t) !== templateKey(first))
)
continue;
const orders = variants.get(first.source);
if (orders === undefined || mirrors.has(first.source)) continue;
const table = tableOf(candidate.shape);
const tested = first.suffix.filter((k) => maybeAbsent(first.source, k));
if (tested.length > 4) continue;
const mirrorOf = (o: readonly string[], lacks: ReadonlySet<string>): string[] => {
const order = [...first.prefix];
for (const k of o) if (!lacks.has(k) && !order.includes(k)) order.push(k);
for (const k of first.suffix) if (!order.includes(k)) order.push(k);
return complete(order, table);
};
let mirrored: string[][];
let template: Template = first;
if (tested.length === 0) mirrored = orders.map((o) => mirrorOf(o, new Set()));
else {
mirrored = [];
const index: number[][] = [];
for (let present = 0; present < 1 << tested.length; present++) {
const lacks = new Set(tested.filter((_, j) => (present & (1 << j)) === 0));
const row: number[] = [];
for (const o of orders) {
const order = mirrorOf(o, lacks);
let i = indexOfOrder(mirrored, order);
if (i < 0) i = mirrored.push(order) - 1;
row.push(i);
}
index.push(row);
}
if (mirrored.length > MAX_ORDERS) continue;
template = {
source: first.source,
prefix: first.prefix,
suffix: first.suffix,
select: { keys: tested, index },
};
}
if (!closeUnderMoves(id, mirrored, candidate.moving)) continue;
if (mirrored.every((o) => sameOrder(o, mirrored[0]!))) {
// Every order of the source agrees over these keys: one table.
moves.delete(id);
const shape = candidate.shape;
const only: string[] = mirrored[0] ?? table;
if (!sameOrder(only, table))
shape.declaredOrder = [...shape.declaredOrder!.filter(isArrayIndex), ...only];
continue;
}
variants.set(id, mirrored);
mirrors.set(id, template);
}
if (variants.size === 0) return;
// Every construction of those shapes, to give each its order index. A
// copy reads its source's index, so its source must be a plain binding.
const sites = new Map<string, (IrExpr & { kind: "recordLit" })[]>();
/** The statements a spread literal's sequence evaluates first: the
* spread value's binding, then its fields. */
const prologues = new Map<IrExpr, readonly IrStmt[]>();
const siteOf = (e: IrExpr): boolean => {
if (e.kind === "seqExpr" && e.result.kind === "recordLit") prologues.set(e.result, e.stmts);
if (e.kind === "recordLit" && e.type.kind === "record" && variants.has(e.type.shapeId)) {
const list = sites.get(e.type.shapeId) ?? [];
list.push(e);
sites.set(e.type.shapeId, list);
}
return everyExprChild(e, siteOf, siteStmt);
};
const siteStmt = (st: IrStmt): boolean => everyStmtChild(st, siteOf, siteStmt);
/** The function each literal with a prologue is built in. */
const owners = new Map<IrExpr, IrFunction>();
for (const fn of fns) {
const before = new Set(prologues.keys());
for (const st of fn.body) siteStmt(st);
for (const lit of prologues.keys()) if (!before.has(lit)) owners.set(lit, fn);
}
/** Where a literal evaluated its spread of `source`: the declaration the
* spread segment names, in the literal's own prologue. */
const spreadBinding = (
lit: IrExpr & { kind: "recordLit" },
source: string,
): { stmts: IrStmt[]; at: number; value: IrExpr } | null => {
const stmts = prologues.get(lit) as IrStmt[] | undefined;
if (stmts === undefined) return null;
for (const segment of notes.literalOf(lit) ?? []) {
if (segment.kind !== "spread" || segment.shapeId !== source || !segment.value) continue;
const localId = segment.value.localId;
const at = stmts.findIndex((st) => st.kind === "varDecl" && st.localId === localId);
const decl = stmts[at];
if (decl?.kind !== "varDecl" || decl.init === null) return null;
return { stmts, at, value: { kind: "varRef", localId, type: decl.init.type, loc: lit.loc } };
}
return null;
};
/** The binding a copy or spread reads its fields from. A literal's spread
* names it; every field of a copy reads it. */
const copySource = (lit: IrExpr & { kind: "recordLit" }, source: string): IrExpr | null => {
if (notes.literalOf(lit) !== undefined) return spreadBinding(lit, source)?.value ?? null;
for (const field of lit.fields) {
const value = field.value;
if (value.kind === "recordGet" && value.shapeId === source && value.obj.kind === "varRef")
return value.obj;
}
for (const st of prologues.get(lit) ?? []) {
if (st.kind !== "varDecl" || st.init === null) continue;
const t = st.init.type;
if (t.kind === "record" && t.shapeId === source)
return { kind: "varRef", localId: st.localId, type: t, loc: lit.loc };
}
return null;
};
const orderIndex = (id: string, lit: IrExpr & { kind: "recordLit" }): IrExpr | number | null => {
const orders = variants.get(id)!;
const segments = notes.literalOf(lit);
if (segments !== undefined && selfSpread(segments, id)) {
const obj = copySource(lit, id);
if (obj === null) return null;
return {
kind: "recordGet",
obj,
shapeId: id,
field: KEY_ORDER_FIELD,
type: F64,
loc: lit.loc,
};
}
const mirror = mirrors.get(id);
if (segments !== undefined && mirror !== undefined) {
const obj = copySource(lit, mirror.source);
if (obj === null) return null;
const sourceIndex: IrExpr = {
kind: "recordGet",
obj,
shapeId: mirror.source,
field: KEY_ORDER_FIELD,
type: F64,
loc: lit.loc,
};
const select = mirror.select;
if (select === undefined) return sourceIndex;
// Tests the source for each key in turn, then picks by its index.
const pick = (j: number, present: number): IrExpr => {
if (j === select.keys.length)
return indexChain(sourceIndex, select.index[present]!, lit.loc);
return {
kind: "ternary",
cond: lowerer.recordFieldPresent(obj, mirror.source, select.keys[j]!, lit.loc),
then: pick(j + 1, present | (1 << j)),
else_: pick(j + 1, present),
type: F64,
loc: lit.loc,
};
};
return pick(0, 0);
}
if (segments !== undefined) {
const order = literalOrder(segments);
return order === null ? null : indexOfOrder(orders, complete(order, orders[0]!));
}
const source = notes.copyOf(lit);
if (source === undefined) return 0;
if (variants.has(source) && (source === id || mirrors.get(id)?.source === source)) {
const obj = copySource(lit, source);
if (obj === null) return null;
return {
kind: "recordGet",
obj,
shapeId: source,
field: KEY_ORDER_FIELD,
type: F64,
loc: lit.loc,
};
}
const resolved = result.get(source);
if (resolved === undefined || resolved === "unknown") return null;
const keys = new Set(
lit.fields.filter((x) => x.value.kind !== "fieldAbsent").map((x) => x.name),
);
return indexOfOrder(
orders,
complete(
resolved.order.filter((k) => keys.has(k)),
orders[0]!,
),
);
};
const plan = new Map<IrExpr & { kind: "recordLit" }, IrExpr | number>();
const dropped = new Set<string>();
for (const [id, list] of sites) {
for (const lit of list) {
const index = orderIndex(id, lit);
if (index === null || (typeof index === "number" && index < 0)) dropped.add(id);
else plan.set(lit, index);
}
}
// Write sites of keys that move: each reads and updates the value's
// index before the write.
const writeSites: { st: IrStmt & { kind: "recordSet" }; fn: IrFunction }[] = [];
let writeFn: IrFunction | null = null;
const writeExpr = (e: IrExpr): boolean => everyExprChild(e, writeExpr, writeOf);
const writeOf = (st: IrStmt): boolean => {
if (
st.kind === "recordSet" &&
st.value.kind !== "fieldAbsent" &&
moves.get(st.shapeId)?.has(st.field)
)
writeSites.push({ st, fn: writeFn! });
return everyStmtChild(st, writeExpr, writeOf);
};
for (const fn of fns) {
writeFn = fn;
for (const st of fn.body) writeOf(st);
}
// A shape whose copies cannot read their source keeps today's table, and
// so do the shapes that mirror it.
for (const [id, mirror] of mirrors) if (dropped.has(mirror.source)) dropped.add(id);
let temporaries = 0;
for (const [lit, index] of plan) {
const id = (lit.type as IrType & { kind: "record" }).shapeId;
if (dropped.has(id)) continue;
let value: IrExpr =
typeof index === "number" ? { kind: "numLit", value: index, type: F64, loc: lit.loc } : index;
// A spread's order is the source's order at the spread: a later
// property may move the source's keys. Read it right after the literal
// evaluates the source.
const source = mirrors.get(id)?.source;
const binding = typeof index === "number" || !source ? null : spreadBinding(lit, source);
const fn = owners.get(lit);
if (binding && fn) {
const localId = `%keyOrderSource.${temporaries++}`;
fn.locals.push({ id: localId, name: "%keyOrderSource", type: F64, mutable: false });
binding.stmts.splice(binding.at + 1, 0, {
kind: "varDecl",
localId,
init: value,
loc: lit.loc,
});
value = { kind: "varRef", localId, type: F64, loc: lit.loc };
}
lit.fields.push({ name: KEY_ORDER_FIELD, value });
}
const replacements = new Map<IrFunction, Map<IrStmt, IrStmt>>();
for (const { st, fn } of writeSites) {
if (dropped.has(st.shapeId)) continue;
const transitions = moves.get(st.shapeId)!.get(st.field)!;
const loc = st.loc;
// A receiver other than a binding evaluates once, into a local. So does
// a binding when the value evaluates first (below): the value may
// assign the binding, and the write still goes to the object the
// binding held before.
const pure =
st.value.kind === "numLit" ||
st.value.kind === "strLit" ||
st.value.kind === "boolLit" ||
st.value.kind === "varRef";
let plain = st.obj;
while (plain.kind === "unionNarrow" || plain.kind === "downcast") plain = plain.value;
const prologue: IrStmt[] = [];
let obj = st.obj;
if (plain.kind !== "varRef" || !pure) {
const localId = `%keyOrderReceiver.${temporaries++}`;
fn.locals.push({ id: localId, name: "%keyOrderReceiver", type: st.obj.type, mutable: false });
prologue.push({ kind: "varDecl", localId, init: st.obj, loc });
obj = { kind: "varRef", localId, type: st.obj.type, loc };
}
const current: IrExpr = {
kind: "recordGet",
obj,
shapeId: st.shapeId,
field: KEY_ORDER_FIELD,
type: F64,
loc,
};
const next = indexChain(current, transitions, loc);
// The value evaluates before the key is tested and moved: it may add
// other keys to the same value, which then come first.
let value = st.value;
if (!pure) {
const localId = `%keyOrderValue.${temporaries++}`;
fn.locals.push({ id: localId, name: "%keyOrderValue", type: value.type, mutable: false });
prologue.push({ kind: "varDecl", localId, init: value, loc });
value = { kind: "varRef", localId, type: value.type, loc };
}
const absent: IrExpr = {
kind: "unary",
op: "!",
operand: lowerer.recordFieldPresent(obj, st.shapeId, st.field, loc),
type: BOOL,
loc,
};
const update: IrStmt = {
kind: "recordSet",
obj,
shapeId: st.shapeId,
field: KEY_ORDER_FIELD,
value: next,
loc,
};
// The write becomes a block that moves the key first.
const write: IrStmt = {
kind: "recordSet",
obj,
shapeId: st.shapeId,
field: st.field,
value,
loc,
};
const guard: IrStmt = { kind: "if", cond: absent, then: [update], else_: null, loc };
const block: IrStmt = { kind: "block", body: [...prologue, guard, write], loc };
let replaced = replacements.get(fn);
if (replaced === undefined) {
replaced = new Map();
replacements.set(fn, replaced);
}
replaced.set(st, block);
}
for (const [fn, replaced] of replacements) {
fn.body = transformStmtList(fn.body, {
expr: (e) => e,
stmt: (st) => replaced.get(st) ?? st,
});
}
for (const [id, orders] of variants) {
if (dropped.has(id)) continue;
const shape = shapes.get(id)!;
const indexKeys: string[] = (shape.declaredOrder ?? []).filter(isArrayIndex);
shapes.addKeyOrderField(id);
const tables: string[][] = [];
for (const order of orders) {
const table: string[] = [...indexKeys];
for (const key of order) table.push(key);
tables.push(table);
}
shape.orderVariants = tables;
}
}
@@ -1,4 +1,5 @@
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
import { isForwardedTupleSpread, lowerForwardingApply } from "./typed-arguments.js";
import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js";
import { lowerDynStreamMemberCall } from "./dyn-streams.js";
@@ -73,6 +74,7 @@ import {
isDynTypedRefType,
isUnitType,
shapeHasAccessorSlots,
shapeKeyOrders,
typeEquals,
RUNTIME_EMITTER_CLASS,
} from "../../ir/ir.js";
@@ -267,8 +269,62 @@ import { codeGenerationCalleeOf, lowerCodeGenerationCall } from "./code-generati
const ZLIB_STREAM_METHODS: ReadonlySet<string> = new Set(["flush", "params", "close", "reset"]);
/** `Object.defineProperty(target, ...)`, `Object.defineProperties`,
* `Reflect.defineProperty`, and `target.__defineGetter__/__defineSetter__`
* over a target that may hold an Error can install an accessor over an
* error's `name` or `message` (error-methods.ts). */
function noteErrorAccessorReflection(lowerer: Lowerer, expr: ts.CallExpression): void {
const callee = expr.expression;
if (!ts.isPropertyAccessExpression(callee)) return;
const member = callee.name.text;
const target =
member === "defineProperty" || member === "defineProperties"
? expr.arguments.nodes[0]
: member === "__defineGetter__" || member === "__defineSetter__"
? callee.expression
: undefined;
if (!target) return;
const error = lowerer.checker.resolveName("Error", ts.SymbolFlags.Type, target, false);
const errorType = error ? lowerer.checker.getDeclaredTypeOfSymbol(error) : null;
if (mayHoldError(lowerer, lowerer.checker.getTypeAtLocation(target), errorType))
lowerer.errorAccessorReflection = locOf(expr);
}
/** An untyped or generic type, a type Error is assignable to (`object`,
* `{}`, `Object`, `{ message?: string }`), or
* an Error-shaped type with both `name` and `message`, alone or in a
* union or intersection. */
function mayHoldError(lowerer: Lowerer, type: ts.Type, errorType: ts.Type | null): boolean {
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Instantiable))
return true;
if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection))
return ts.constituentTypes(type).some((part) => mayHoldError(lowerer, part, errorType));
return (
(lowerer.checker.getPropertyOfType(type, "message") !== undefined &&
lowerer.checker.getPropertyOfType(type, "name") !== undefined) ||
(errorType !== null && lowerer.checker.isTypeAssignableTo(errorType, type))
);
}
/** The `f` of a `(0, f)` callee (a numeric left operand, so dropping it
* changes only the call's receiver), or null. */
function receiverlessCalleeOf(callee: ts.Expression): ts.Expression | null {
let e = callee;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (
!ts.isBinaryExpression(e) ||
e.operatorToken.kind !== ts.SyntaxKind.CommaToken ||
!ts.isNumericLiteral(e.left)
)
return null;
let right = e.right;
while (ts.isParenthesizedExpression(right)) right = right.expression;
return right;
}
export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
const loc = locOf(expr);
if (lowerer.errorAccessorReflection === null) noteErrorAccessorReflection(lowerer, expr);
// Object's checker return type is `any`. Claim known object inputs
// before generic checked-dynamic dispatch tries to load the constructor.
if (
@@ -289,6 +345,17 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
const json = lowerJsonRequireCall(lowerer, expr);
if (json) return json;
// `(0, ns.member)(...)` over a builtin module (tsc's and esbuild's call
// without a receiver): Node's module functions do not read `this`, so it
// is the member call.
{
const member = receiverlessCalleeOf(expr.expression);
if (member !== null && ts.isPropertyAccessExpression(member)) {
const builtin = lowerer.lowerNamespaceBuiltinCall(expr, member);
if (builtin) return builtin;
}
}
// eval(...) and Function(...): Node's EvalError under
// --disallow-code-generation-from-strings (code-generation.ts).
{
@@ -2400,6 +2467,20 @@ function lowerBigIntConstructorValue(lowerer: Lowerer, arg: IrExpr, loc: SrcLoc)
return { kind: "libCall", fn: "bigint.parse", args: [arg], type: BIGINT_T, loc };
if (arg.type.kind === "f64")
return { kind: "libCall", fn: "bigint.fromF64", args: [arg], type: BIGINT_T, loc };
// Byte storage converts through ToPrimitive, which BigInt then parses: a
// typed array's text is its elements joined with "," (one element reads
// as that element, none as 0n, several throw the SyntaxError for the
// joined text), a Buffer's is its UTF-8 decoding and a DataView's is
// "[object DataView]". The runtime value tells them apart, as for String.
if (arg.type.kind === "bytes") {
const text: IrExpr = {
kind: "toString",
operand: { kind: "dynFrom", value: arg, type: DYN, loc },
type: STRING,
loc,
};
return { kind: "libCall", fn: "bigint.parse", args: [text], type: BIGINT_T, loc };
}
if (arg.type.kind === "bool") {
return {
kind: "ternary",
@@ -2440,6 +2521,7 @@ function lowerBigIntConstructorValue(lowerer: Lowerer, arg: IrExpr, loc: SrcLoc)
arm.kind === "string" ||
arm.kind === "f64" ||
arm.kind === "bool" ||
arm.kind === "bytes" ||
isUnitType(arm),
)
)
@@ -3797,16 +3879,19 @@ function lowerDynReceiverMethodCall(
}
if (access.name.text === "flatMap") return lowerDynArrayFlatMapCall(lowerer, call, access, recv);
// String methods claim only names NO other dyn-representable kind's
// prototype declares (Array carries includes/indexOf/slice too): for
// these, "the receiver is a string, or the call throws V8's TypeError"
// IS Node's semantics for every possible dyn value. Shared names would
// need a receiver-kind dispatch — they keep the fence.
// prototype declares (Array carries includes/indexOf/slice too), so no
// other kind inherits them. Shared names would need a receiver-kind
// dispatch — they keep the fence.
if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) {
// An object-typed receiver the member is missing from (a JavaScript
// module object whose members are assigned in a function body,
// `common.repeat(" ", n)`), or an untyped receiver (an object whose
// prototype defines `trim`), holds a string or an object with that
// member: dispatch on the receiver's runtime kind.
// A checked-dynamic object can still carry its OWN member of that
// name: zod's schemas assign `trim`, `toLowerCase` and `startsWith`
// in their constructors (`z.string().trim().min(1)`), an object's
// prototype can define `trim`, and a JavaScript module object can
// assign `repeat` in a function body (`common.repeat(" ", n)`, an
// object-typed receiver the member is missing from). An untyped
// receiver, or a member the checker cannot type, dispatches on the
// receiver's runtime kind: a string runs the string lowering, anything
// else calls its member as Node does.
if (
(checkerUntyped ||
(lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) &&
@@ -7141,8 +7226,12 @@ export function recordKeysArrayCall(
loc,
},
];
const order = current.declaredOrder ?? current.fields.map((f) => f.name);
for (const name of order) {
// One walk per key order (see perKeyOrder).
const orders = shapeKeyOrders(current);
const walks = orders.map((): IrStmt[] => []);
for (const [k, name] of orders.flatMap((order, k) =>
order.map((name) => [k, name] as const),
)) {
const f = current.fields.find((x) => x.name === name)!;
const pushStmt: IrStmt = {
kind: "exprStmt",
@@ -7159,12 +7248,13 @@ export function recordKeysArrayCall(
// Fields that can be absent push behind their presence test (the
// key exists exactly when Object.keys would list it).
const present = lowerer.recordFieldPresent(ref, argIr.shapeId, f.name, loc);
body.push(
walks[k]!.push(
present.kind === "recordHas"
? { kind: "if", cond: present, then: [pushStmt], else_: null, loc }
: pushStmt,
);
}
body.push(...perKeyOrder(current, ref, walks, loc));
body.push({ kind: "return", value: outRef, loc });
fn.body = body;
};
@@ -8867,8 +8957,12 @@ function lowerObjectStaticCall(
loc,
},
];
const order = current.declaredOrder ?? current.fields.map((f) => f.name);
for (const name of order) {
// One walk per key order (see perKeyOrder).
const orders = shapeKeyOrders(current);
const walks = orders.map((): IrStmt[] => []);
for (const [k, name] of orders.flatMap((order, k) =>
order.map((name) => [k, name] as const),
)) {
const f = current.fields.find((x) => x.name === name)!;
const raw: IrExpr = {
kind: "recordGet",
@@ -9030,8 +9124,9 @@ function lowerObjectStaticCall(
loc,
};
}
body.push(stmt);
walks[k]!.push(stmt);
}
body.push(...perKeyOrder(current, ref, walks, loc));
body.push({ kind: "return", value: outRef, loc });
fn.body = body;
};
@@ -10192,6 +10287,23 @@ export function lowerObjectMethodCall(
type: found.sig.ret,
loc,
};
// The declaring class's original value, built with the observed slot.
// An override below (a virtual call) keeps the dynamic call: a slot
// reassigned to an ancestor's method must not reach the override. So do
// methods declared in unchecked JavaScript, whose results are inferred,
// and calls with a spread argument: the dynamic call drains the spread
// through the iterator's current `next`, which may be a replacement.
const declarerFile = found.declarer.decl ? ts.getSourceFileOfNode(found.declarer.decl) : null;
const original =
virtual ||
call.arguments.nodes.some(ts.isSpreadElement) ||
!declarerFile ||
(isJsSourceFile(declarerFile) && !lowerer.checkedJsFile(declarerFile))
? undefined
: {
originalId: (): string | undefined => found.declarer.prototypeOriginals?.get(method),
directCall: (): IrExpr => fallback,
};
const result = classCallbackCall(
lowerer,
value,
@@ -10200,6 +10312,7 @@ export function lowerObjectMethodCall(
fallback,
loc,
ts.getTextOfNode(access),
original,
);
return {
kind: "seqExpr",
@@ -11176,6 +11289,7 @@ const SHIM_INTRINSICS: ReadonlyMap<string, { fn: IrLibFn; args: IrType[]; ret: I
["scriptcUnixDgramConnect", { fn: "dgram.unixConnect", args: [F64, STRING], ret: F64 }],
["scriptcUnixDgramSend", { fn: "dgram.unixSend", args: [F64, BYTES_U8], ret: F64 }],
["scriptcUnixDgramClose", { fn: "dgram.unixClose", args: [F64], ret: F64 }],
["scriptcResponseLight", { fn: "fetch.responseLight", args: [DYN], ret: arrayOf(STRING) }],
]);
function lowerShimIntrinsicCall(
@@ -177,7 +177,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";
@@ -478,6 +483,10 @@ export interface ClassInfo {
prototypeMethodValues?: Map<string, IrExpr>;
prototypeReflectionReady?: true;
prototypeObservedMethodsReady?: true;
/** The globals holding each observed method's original prototype value
* (set when the prototype data is built), by method name. A typed call
* whose slot still holds that value calls the method natively. */
prototypeOriginals?: Map<string, string>;
prototypeReflectionRefusal?: string;
localClass?: {
context: FnCtx | null;
@@ -2734,8 +2743,8 @@ export function collectClassShapeInner(
// state, so an override or shadowing field would split-brain.
// Underscore methods are the SUPPORTED override form — but only
// the ones the class's own base consumes (a `_read` on a
// Transform, or `_writev`/`_construct` anywhere, would be consumed
// by Node machinery that has no lowering here).
// Transform, or `_writev` on a Readable, would be consumed by Node
// machinery that has no lowering here).
if (memberName && ts.isIdentifier(memberName)) {
const streamBase = (() => {
for (let c = base; c; c = c.base) if (c.builtinStream) return c;
@@ -2754,7 +2763,7 @@ export function collectClassShapeInner(
lowerer.unsupported(
"SC1090",
memberName,
`declaring '_writev' on a ${streamBase.def.name.slice(1)} subclass (batched writes are supported on Writable and Duplex subclasses)`,
`declaring '_writev' on a ${streamBase.def.name.slice(1)} subclass (a Readable has no writable side)`,
);
}
const accepted = streamCtorShape(streamBase.def.name).accepted;
@@ -4110,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
@@ -1,4 +1,5 @@
import { keyedHelperName } from "./stable-names.js";
import { perKeyOrder } from "./key-order-witness.js";
import {
dynUndefinedExpr,
boolLit,
@@ -40,6 +41,7 @@ import {
isDynTypedRefType,
recordTextCodecClass,
shapeHasAccessorSlots,
shapeKeyOrders,
typeKey,
} from "../../ir/ir.js";
import type { ClassInfo } from "./lower-classes.js";
@@ -1647,7 +1649,12 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
});
}
body.push(depthGate("[Object]"), ...begin());
for (const fname of order) {
// One walk per key order (see perKeyOrder).
const orders = shapeKeyOrders(current);
const walks = orders.map((): IrStmt[] => []);
for (const [k, fname] of orders.flatMap((order, k) =>
order.map((name) => [k, name] as const),
)) {
const ft = byName.get(fname);
if (!ft) continue;
const show = entry(
@@ -1655,12 +1662,13 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
boolLit(false, loc),
);
const present = lowerer.recordFieldPresent(v(), t.shapeId, fname, loc);
body.push(
walks[k]!.push(
present.kind === "recordHas"
? { kind: "if", cond: present, then: [show], else_: null, loc }
: show,
);
}
body.push(...perKeyOrder(current, v(), walks, loc));
body.push(
ret(end(strLit("", loc), strLit("{", loc), strLit("}", loc), false, boolLit(false, loc))),
);
@@ -7581,7 +7581,7 @@ function lowerHttpReqMethodCall(
loc,
};
}
if (name === "resume" || name === "destroy") {
if (name === "resume" || name === "pause" || name === "destroy") {
requireStatementPosition(lowerer, call, `req.${name}()`);
if (args.length !== 0) {
lowerer.noLowering(
@@ -7591,7 +7591,8 @@ function lowerHttpReqMethodCall(
);
}
const receiver = coerceToHandle(lowerer, access.expression, HTTPREQ_T);
const fn: IrLibFn = name === "resume" ? "http.reqResume" : "http.reqDestroy";
const fn: IrLibFn =
name === "resume" ? "http.reqResume" : name === "pause" ? "http.reqPause" : "http.reqDestroy";
return { kind: "libCall", fn, args: [receiver], type: VOID, loc };
}
if (name === "setEncoding") {
@@ -106,6 +106,61 @@ function pureRead(node: ts.Expression): boolean {
return false;
}
/** A literal, `undefined`, or a function expression: a value that reads
* no binding or property. */
function constantOptionValue(node: ts.Expression): boolean {
let e = node;
while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isTypeAssertion(e))
e = e.expression;
if (ts.isIdentifier(e)) return e.text === "undefined";
if (
ts.isPrefixUnaryExpression(e) &&
(e.operator === ts.SyntaxKind.MinusToken ||
e.operator === ts.SyntaxKind.PlusToken ||
e.operator === ts.SyntaxKind.ExclamationToken)
)
return constantOptionValue(e.operand);
return (
ts.isStringLiteralLike(e) ||
ts.isNumericLiteral(e) ||
ts.isBigIntLiteral(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword ||
ts.isArrowFunction(e) ||
ts.isFunctionExpression(e)
);
}
/** Whether a runtime decodeStrings or defaultEncoding value (`prop`'s
* `value`) may be read before the other option members. Those values are
* read into locals before the constructor evaluates the other members,
* while Node evaluates the members in order, so every earlier member must
* evaluate without effects, and when the value itself has effects, every
* earlier member must also read nothing those effects could change. */
function earlierOptionsPure(
options: ts.ObjectLiteralExpression,
prop: ts.Node,
value: ts.Expression,
): boolean {
const valueHasEffects = !sideEffectFreeOptionValue(value) && !pureRead(value);
for (const member of options.properties.nodes) {
if (member === prop) return true;
if (ts.isPropertyAssignment(member)) {
const init = member.initializer;
if (
valueHasEffects
? !constantOptionValue(init)
: !sideEffectFreeOptionValue(init) && !pureRead(init)
)
return false;
} else if (ts.isShorthandPropertyAssignment(member)) {
if (valueHasEffects) return false;
} else if (ts.isSpreadAssignment(member)) return false;
}
return true;
}
/** The stream sides of a receiver class: the nearest stream-class
* ancestor's, or null off the stream hierarchy. */
export function streamSidesOf(
@@ -179,15 +234,15 @@ export const UNDERSCORE_METHODS: ReadonlyMap<string, string> = new Map([
/** The option callbacks applied after construction rather than through
* the constructor's callback slots (option name → method name): construct
* on every stream class, and writev on the writable classes whose
* machinery hands it batches (Writable and Duplex). */
* on every stream class, and writev on every class with a writable side
* (a Transform's batches reach writev, not the transform, as in Node). */
export const STREAM_EXTRA_CALLBACKS: ReadonlyMap<string, string> = new Map([
["construct", "_construct"],
["writev", "_writev"],
]);
export function streamExtraCallbacks(cls: string): readonly string[] {
return cls === "%Writable" || cls === "%Duplex" ? ["construct", "writev"] : ["construct"];
return cls === "%Readable" ? ["construct"] : ["construct", "writev"];
}
/** The completion callback's parameter position in each underscore
@@ -442,6 +497,13 @@ interface StreamOptions {
* right after construction (Node's constructors call addAbortSignal
* once the state exists), or null when absent. */
signal: IrExpr | null;
/** Non-literal decodeStrings and defaultEncoding values: valueDecls
* evaluates them into locals before construction (the defaultEncoding
* validated there, as the state constructors throw before the stream
* exists), and stream.optionValues applies them to the fresh stream. */
valueDecls: IrStmt[];
decodeStringsValue: IrExpr | null;
defaultEncodingValue: IrExpr | null;
/** The objectMode option's sides (a Duplex's readableObjectMode and
* writableObjectMode set one side): applied with stream.objectMode
* right after construction. */
@@ -732,6 +794,12 @@ function pinAnyDataCompletion(
if (!symbol || lowerer.stageSourceTypes.has(symbol)) return null;
const declared = lowerer.paramShape(param).type;
if (declared.kind !== "func" || declared.rest || declared.params[1]?.kind !== "dyn") return null;
// Only when every use calls the callback with a Buffer, a string or
// nothing as `data`: another value (or a callback that escapes) keeps
// `data` checked-dynamic, so the runtime pushes it as push(data) does
// and reports ERR_INVALID_ARG_TYPE through 'error', as Node does.
if (fnNode.body === undefined || !completionDataIsChunk(lowerer, fnNode.body, symbol, param.name))
return null;
const data = unionOf(lowerer, [BYTES, STRING, { kind: "undefinedT" }]);
lowerer.stageSourceTypes.set(
symbol,
@@ -740,6 +808,59 @@ function pinAnyDataCompletion(
return symbol;
}
/** Whether every reference to the completion callback `symbol` in `body`
* calls it with a `data` argument that is a Buffer, a string, null or
* undefined (or with no data). */
function completionDataIsChunk(
lowerer: Lowerer,
body: ts.Node,
symbol: ts.Symbol,
declaration: ts.Node,
): boolean {
let ok = true;
const visit = (node: ts.Node): void => {
if (!ok) return;
if (
ts.isIdentifier(node) &&
node !== declaration &&
lowerer.checker.getSymbolAtLocation(node) === symbol
) {
const call = node.parent;
if (!call || !ts.isCallExpression(call) || call.expression !== node) {
ok = false;
return;
}
const data = call.arguments.nodes[1];
if (data !== undefined) {
let t: IrType | null;
try {
t = lowerer.mapTypeOf(lowerer.typeOf(data));
} catch {
t = null;
}
const arms =
t?.kind === "union" ? (lowerer.unions.get(t.unionId)?.arms ?? null) : t ? [t] : null;
if (
arms === null ||
!arms.every(
(a) =>
(a.kind === "bytes" && a.elem === "u8") ||
a.kind === "string" ||
a.kind === "undefinedT" ||
a.kind === "nullT",
)
) {
ok = false;
return;
}
}
}
ts.forEachChild(node, visit);
};
visit(body);
return ok;
}
/** Parses the options object literal of a stream constructor. `which`
* names the callbacks this class accepts, in canonical flag order. */
function parseStreamOptions(
@@ -767,6 +888,9 @@ function parseStreamOptions(
badEncoding: null,
pushEncoding: null,
signal: null,
valueDecls: [],
decodeStringsValue: null,
defaultEncodingValue: null,
readableObject: false,
writableObject: false,
decodeStringsOff: false,
@@ -802,6 +926,21 @@ function parseStreamOptions(
optsLit !== undefined &&
ts.isObjectLiteralExpression(optsLit) &&
literalWritesValues(optsLit, ctorName !== "Readable" && ctorName !== "Writable");
// In JavaScript a decodeStrings value known only at run time may let
// string writes through, so the write and transform callbacks' chunk
// parameters read as any (the declarations type it Buffer).
const runtimeDecodeStrings =
optsLit !== undefined &&
isJsSourceFile(ts.getSourceFileOfNode(optsLit)) &&
ts.isObjectLiteralExpression(optsLit) &&
(optsLit.properties?.nodes ?? []).some(
(p) =>
ts.isPropertyAssignment(p) &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
p.name.text === "decodeStrings" &&
p.initializer.kind !== ts.SyntaxKind.TrueKeyword &&
p.initializer.kind !== ts.SyntaxKind.FalseKeyword,
);
if (out.readableObject) out.hwmR = numLit(16, loc);
if (out.writableObject) out.hwmW = numLit(16, loc);
const tuples = cbTuples(lowerer);
@@ -851,6 +990,7 @@ function parseStreamOptions(
}
const full = tuples[name]!.map((t) => (t === "fn" ? null : t));
const pinned = out.readableObject ? null : pinAnyDataCompletion(lowerer, name, fnNode);
let anyChunk: ts.Symbol | undefined;
try {
// The completion-callback tail: typed by the checker contextually;
// accept the user's declared func type as the tuple's entry so the
@@ -867,12 +1007,22 @@ function parseStreamOptions(
}
return declared ?? funcOf([], VOID);
});
const chunkParam =
runtimeDecodeStrings && (name === "write" || name === "transform")
? fnNode.parameters.nodes[0]
: undefined;
anyChunk =
chunkParam !== undefined && ts.isIdentifier(chunkParam.name) && !chunkParam.type
? lowerer.checker.getSymbolAtLocation(chunkParam.name)
: undefined;
if (anyChunk) lowerer.anySymbols.add(anyChunk);
present.set(
name,
lowerStreamCallback(lowerer, name, fnNode, thisType, tuple, writesValues),
);
} finally {
if (pinned) lowerer.stageSourceTypes.delete(pinned);
if (anyChunk) lowerer.anySymbols.delete(anyChunk);
}
continue;
}
@@ -925,11 +1075,25 @@ function parseStreamOptions(
if (writableClass) out.decodeStringsOff = true;
break;
}
lowerer.noLowering(
`${ctorName} with a runtime 'decodeStrings' value`,
prop,
"write 'decodeStrings: true' or 'decodeStrings: false'",
);
// a runtime value: WritableState's `options.decodeStrings !== false`
if (
optsLit !== undefined &&
ts.isObjectLiteralExpression(optsLit) &&
!earlierOptionsPure(optsLit, prop, value)
)
lowerer.noLowering(
`the ${ctorName} option 'decodeStrings' with a runtime value that an earlier option value could observe or change`,
prop,
"compute the option values into consts before the options object (the runtime value is read before the other options run)",
);
const local = lowerer.declareHiddenLocal("%decodeStrings", DYN);
out.valueDecls.push({
kind: "varDecl",
localId: local.id,
init: lowerer.lowerExprExpecting(value, DYN),
loc,
});
if (writableClass) out.decodeStringsValue = varRef(local.id, DYN, loc);
break;
}
case "encoding": {
@@ -973,11 +1137,43 @@ function parseStreamOptions(
break;
}
}
lowerer.noLowering(
`the ${ctorName} option 'defaultEncoding' with a runtime value`,
prop,
"write the encoding as a string literal",
);
{
// a runtime value: null, undefined and utf8 are the default,
// another name Buffer.isEncoding knows is kept as given, and any
// other value throws ERR_UNKNOWN_ENCODING before construction
if (
optsLit !== undefined &&
ts.isObjectLiteralExpression(optsLit) &&
!earlierOptionsPure(optsLit, prop, value)
)
lowerer.noLowering(
`the ${ctorName} option 'defaultEncoding' with a runtime value that an earlier option value could observe or change`,
prop,
"compute the option values into consts before the options object (the runtime value is read before the other options run)",
);
const local = lowerer.declareHiddenLocal("%defaultEncoding", DYN);
const ref = varRef(local.id, DYN, loc);
out.valueDecls.push(
{
kind: "varDecl",
localId: local.id,
init: lowerer.lowerExprExpecting(value, DYN),
loc,
},
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "stream.checkEncodingOption",
args: [ref],
type: VOID,
loc,
},
loc,
},
);
out.defaultEncodingValue = ref;
}
break;
case "signal": {
// `if (options.signal) addAbortSignal(options.signal, this)`:
@@ -1041,9 +1237,7 @@ function parseStreamOptions(
lowerer.noLowering(
`the ${ctorName} option 'writev'`,
prop,
ctorName === "Readable"
? "a Readable has no writable side"
: "batched writes on a Transform would bypass its transform — writev is supported on Writable and Duplex",
"a Readable has no writable side",
);
break;
default:
@@ -1083,6 +1277,9 @@ function streamCtorArgs(
badEncoding: string | null;
pushEncoding: string | null;
signal: IrExpr | null;
valueDecls: IrStmt[];
decodeStringsValue: IrExpr | null;
defaultEncodingValue: IrExpr | null;
readableObject: boolean;
writableObject: boolean;
decodeStringsOff: boolean;
@@ -1117,6 +1314,9 @@ function streamCtorArgs(
badEncoding: o.badEncoding,
pushEncoding: o.pushEncoding,
signal: o.signal,
valueDecls: o.valueDecls,
decodeStringsValue: o.decodeStringsValue,
defaultEncodingValue: o.defaultEncodingValue,
readableObject: o.readableObject,
writableObject: o.writableObject,
decodeStringsOff: o.decodeStringsOff,
@@ -1237,6 +1437,18 @@ export function lowerStreamNew(lowerer: Lowerer, expr: ts.NewExpression, info: C
loc,
};
}
const values = optionValuesArgs(parsed, loc);
if (values) {
// runtime decodeStrings and defaultEncoding values (answers the
// receiver +1)
built = {
kind: "libCall",
fn: "stream.optionValues",
args: [built, ...values],
type: thisType,
loc,
};
}
if (parsed.pushEncoding !== null) {
// { defaultEncoding }: chain the push-side decode default (the
// setEncoding chaining shape — answers the receiver +1).
@@ -1269,8 +1481,11 @@ export function lowerStreamNew(lowerer: Lowerer, expr: ts.NewExpression, info: C
loc,
};
}
if (!parsed.decodeStringsOff && parsed.writeEncoding === null && parsed.extras.length === 0)
return built;
if (!parsed.decodeStringsOff && parsed.writeEncoding === null && parsed.extras.length === 0) {
return parsed.valueDecls.length > 0
? { kind: "seqExpr", stmts: parsed.valueDecls, result: built, type: thisType, loc }
: built;
}
const fresh = lowerer.declareHiddenLocal("%stream", thisType);
const followUps = streamOptionFollowUps(
parsed,
@@ -1280,13 +1495,30 @@ export function lowerStreamNew(lowerer: Lowerer, expr: ts.NewExpression, info: C
);
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: fresh.id, init: built, loc }, ...followUps],
stmts: [
...parsed.valueDecls,
{ kind: "varDecl", localId: fresh.id, init: built, loc },
...followUps,
],
result: varRef(fresh.id, thisType, loc),
type: thisType,
loc,
};
}
/** stream.optionValues' (decodeStrings, defaultEncoding) arguments, or
* null when both options are literals or absent. */
function optionValuesArgs(
parsed: { decodeStringsValue: IrExpr | null; defaultEncodingValue: IrExpr | null },
loc: SrcLoc,
): IrExpr[] | null {
if (parsed.decodeStringsValue === null && parsed.defaultEncodingValue === null) return null;
return [
parsed.decodeStringsValue ?? dynUndefinedExpr(loc),
parsed.defaultEncodingValue ?? dynUndefinedExpr(loc),
];
}
/** True when a value of Node's argument type `passed` widens implicitly
* into the declared parameter type `declared` (one of its union arms, or a
* sub-union of it). */
@@ -1589,6 +1821,7 @@ export function lowerStreamSuperCall(
];
}
const out: IrStmt[] = [
...parsed.valueDecls,
{
kind: "exprStmt",
expr: {
@@ -1614,6 +1847,20 @@ export function lowerStreamSuperCall(
loc,
});
}
const values = optionValuesArgs(parsed, loc);
if (values) {
out.push({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "stream.optionValues",
args: [thisRef(), ...values],
type: thisType,
loc,
},
loc,
});
}
if (parsed.pushEncoding !== null) {
out.push({
kind: "exprStmt",
@@ -1697,16 +1944,15 @@ export function lowerStreamUnderscoreAssign(
const loc = locOf(expr);
const cls = base.def.name;
const clsName = cls.slice(1);
if (methodName === "_construct") {
// Node's constructor reads _construct once; a later assignment has no
// effect there
lowerer.noLowering(
`assigning '${methodName}' on a ${clsName}`,
expr.left,
"declare _construct on the class or pass the construct option",
);
}
const which = methodName === "_writev" ? "writev" : optionByMethod.get(methodName)!;
// `_construct`: Node's constructNT reads it on the construction tick, so
// an assignment before that tick replaces a scheduled hook
// (stream.replaceConstruct) and has no effect after it or without one.
const which =
methodName === "_writev"
? "writev"
: methodName === "_construct"
? "construct"
: optionByMethod.get(methodName)!;
const { accepted } = streamCtorShape(cls);
if (!accepted.includes(which) && !streamExtraCallbacks(cls).includes(which)) {
lowerer.noLowering(
@@ -1745,6 +1991,7 @@ export function lowerStreamUnderscoreAssign(
transform: "stream.setTransform",
flush: "stream.setFlush",
writev: "stream.setWritev",
construct: "stream.replaceConstruct",
};
return {
kind: "exprStmt",
@@ -2699,6 +2946,22 @@ class StreamArgTypeThrow extends Error {
}
}
/** Whether a checked-dynamic pipeline callback can take undefined as its
* error: a callback that is already checked-dynamic, one without
* parameters, and one whose first parameter (or rest element) type holds
* undefined. */
function callbackErrorAdmitsUndefined(lowerer: Lowerer, cb: IrExpr): boolean {
if (cb.kind !== "dynFrom" || cb.value.type.kind !== "func") return true;
const fnT = cb.value.type;
const first = fnT.params[0];
if (first === undefined) return true;
const p0 =
fnT.rest === true && fnT.params.length === 1 && first.kind === "array" ? first.elem : first;
if (p0.kind === "dyn" || p0.kind === "jsval" || p0.kind === "undefinedT") return true;
if (p0.kind !== "union") return false;
return lowerer.unions.get(p0.unionId)?.arms.some((a) => a.kind === "undefinedT") ?? false;
}
/** The finished/pipeline completion callback: an inline function lowers
* through the option-callback machinery (leading `this`, an `Error |
* null` prefix — Node binds the stream and passes the error); any other
@@ -3160,12 +3423,16 @@ export function lowerStreamModuleCall(
// an async generator) reaches the callback through the
// checked-dynamic path.
const cb = lowerer.lowerExprExpecting(args[args.length - 1]!, DYN);
// SCR_PIPE_TYPED_CB: typed code's callback takes null for no error
// SCR_PIPE_TYPED_CB: a typed callback whose error parameter cannot
// hold undefined takes null for no error; others take undefined.
const stagedArgs = [...staged.args];
const flagsArg = stagedArgs[3]!;
if (flagsArg.kind !== "numLit")
throw new InternalCompilerError("pipeline flags not a literal");
if (!isJsSourceFile(ts.getSourceFileOfNode(call)))
if (
!isJsSourceFile(ts.getSourceFileOfNode(call)) &&
!callbackErrorAdmitsUndefined(lowerer, cb)
)
stagedArgs[3] = numLit(flagsArg.value | 128, loc);
return staged.wrap({
kind: "libCall",
@@ -1,3 +1,4 @@
import { isDeclBoundaryFile } from "../decl-projection.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { moduleNamespaceObject } from "./module-namespace-elements.js";
import { keyedHelperName } from "./stable-names.js";
@@ -498,7 +499,7 @@ import { lowerBuiltinExtraProperty } from "./builtins/properties.js";
import { registerPromisifiedBuiltinDecl } from "./builtins/callable-aliases.js";
import { lowerExecFileAsyncCall } from "./builtins/child-process-async.js";
import { lowerStringDecoderMethodCall, lowerTextCodecCall } from "./builtins/text-codecs.js";
import { lowerReadlineMethodCall } from "./builtins/readline.js";
import { isOptionalReadlineCursorRead, lowerReadlineMethodCall } from "./builtins/readline.js";
import {
lowerDiagnosticsChannelMethodCall,
lowerDiagnosticsChannelProperty,
@@ -2424,6 +2425,51 @@ export class Lowerer {
return fallback ?? null;
}
/** A fresh TypeScript data literal (`{ message: "m" }`) passed to a
* checked-dynamic parameter of a callee implemented in JavaScript, whose
* contract lets it reshape the object: a call through an untyped value, an
* untyped parameter of a JavaScript function, or a package-declared
* parameter type (a non-library `.d.ts`) that makes one of the literal's
* properties optional (zod's `normalizeParams` deletes `params.message`).
* No typed alias of a fresh literal exists, so it is built as the plain
* object the callee receives instead of a view whose record layout refuses
* that delete. Standard-library and TypeScript callees, and literals with
* methods or accessors, keep their native storage. */
private reshapeableContextualLiteral(
literal: ts.ObjectLiteralExpression,
contextual: readonly ts.Type[],
): boolean {
const call = literal.parent;
if (
call === undefined ||
!(ts.isCallExpression(call) || ts.isNewExpression(call)) ||
!call.arguments?.nodes.includes(literal) ||
literal.properties.nodes.length === 0 ||
!literal.properties.nodes.every(
(prop) =>
(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) &&
(ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)),
)
)
return false;
const declaration = this.checker.getResolvedSignature(call)?.declaration;
const file = declaration ? ts.getSourceFileOfNode(declaration) : undefined;
const untyped = contextual.some((part) => part.flags & ts.TypeFlags.Any);
if (file === undefined) return untyped;
if (isJsSourceFile(file)) return untyped;
if (!file.isDeclarationFile || this.isStdlibFile(file)) return false;
if (untyped) return true;
const objects = contextual.filter((part) => part.flags & ts.TypeFlags.Object);
if (objects.length === 0) return false;
return literal.properties.nodes.some((prop) => {
const name = (prop.name as ts.Identifier | ts.StringLiteral).text;
return objects.every((part) => {
const symbol = this.checker.getPropertyOfType(part, name);
return symbol !== undefined && (symbol.flags & ts.SymbolFlags.Optional) !== 0;
});
});
}
/** A TypeScript object literal handed to a JavaScript checked-value
* parameter whose inferred type is open (`constructor({ factory, ...options
* } = {})` — the rest element admits any property): the literal names a
@@ -3218,6 +3264,11 @@ export class Lowerer {
* dispatch revision needs no weak-key identity or process-global state. */
errorMethodDispatchRevision: string | null = null;
errorPropertyDispatchRevision: string | null = null;
/** The program defines properties by reflection on an untyped or
* Error-shaped target, so an own accessor may replace an error's `name`
* or `message`: typed reads and writes of those slots check for one.
* Holds the first such call's location. */
errorAccessorReflection: SrcLoc | null = null;
/** Structural source positions (structural-positions.ts) for names that
* stay the same when unrelated code moves. */
/** Files are named by their path relative to the entry's directory, so
@@ -3600,6 +3651,21 @@ export class Lowerer {
else if (kind === "failed") s.failed += 1;
}
/** The program holds the declaration-typed boundary's projection
* (decl-projection.ts): user-file record and array values crossing into
* dynamic slots (package parameters) box as live capsules, so a value
* that comes back through a boundary cast keeps its identity. */
private declBoundaryMemo: boolean | null = null;
get declBoundary(): boolean {
this.declBoundaryMemo ??= this.program
.getSourceFiles()
.some((sf) => isDeclBoundaryFile(sf.fileName));
return this.declBoundaryMemo;
}
/** Tracked classes' bag initializers over a typed `this`
* (class-dynamic-dispatch.ts recordBagInitializers). */
readonly classBagInitializers = new Map<string, IrExpr>();
constructor(
readonly program: ts.Program,
readonly entry: ts.SourceFile,
@@ -3722,8 +3788,11 @@ export class Lowerer {
classImplementedProtocol: (type) => this.classImplementedProtocol(type),
isStdlibFile: this.isStdlibFile,
isNpmFile: this.isNpmFile,
// The declaration-typed boundary's generated types (decl-projection.ts)
// describe data, like an explicitly mapped external type surface.
isExternalTypeFile: (sf) =>
this.externalTypeSpecifiersByFile.has(tsgoPath(resolve(sf.fileName))),
this.externalTypeSpecifiersByFile.has(tsgoPath(resolve(sf.fileName))) ||
isDeclBoundaryFile(sf.fileName),
typeMemo: this.typeMemo,
...(this.generalNodeStreams ? { generalNodeStreams: true } : {}),
...(this.dynNodeStreams ? { dynNodeStreams: true } : {}),
@@ -5512,6 +5581,7 @@ export class Lowerer {
if (isIndexedRead(e)) return true;
if (ts.isPropertyAccessExpression(e) && isRecordIndexPropertyRead(e)) return true;
if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOptionalReadlineCursorRead(this, e)) return true;
if (isOptionalProcessVersionsRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this))
@@ -7758,6 +7828,7 @@ export class Lowerer {
entry: ENTRY_NAME,
...(this.usesWorkers ? { workers: true } : {}),
...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}),
...(this.declBoundary ? { declBoundary: true as const } : {}),
...(this.threadSlotKeys.size > 0
? { threadSlotKeys: [...this.threadSlotKeys].sort() }
: {}),
@@ -10633,6 +10704,46 @@ export class Lowerer {
return this.coerceInto(node, value, expected);
}
/** The right side of a write to a member of a typed view of the global
* object (`(globalThis as typeof globalThis & { k?: T }).k = value`, also
* through `??=` and `||=`) that is `value` itself or wraps only it in
* parentheses or `satisfies`; null when `value` is not in such a write. */
private globalViewSlot(value: ts.Expression): ts.Expression | null {
let operand: ts.Expression = value;
while (
operand.parent !== undefined &&
(ts.isParenthesizedExpression(operand.parent) || ts.isSatisfiesExpression(operand.parent))
)
operand = operand.parent;
const write = operand.parent;
if (
write === undefined ||
!ts.isBinaryExpression(write) ||
write.right !== operand ||
(write.operatorToken.kind !== ts.SyntaxKind.EqualsToken &&
write.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionEqualsToken &&
write.operatorToken.kind !== ts.SyntaxKind.BarBarEqualsToken)
)
return null;
let target = write.left;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (!ts.isPropertyAccessExpression(target) && !ts.isElementAccessExpression(target))
return null;
const receiver = this.typeOf(target.expression);
const isGlobal = (type: ts.Type): boolean => {
const symbol = type.getSymbol();
return (
symbol !== undefined &&
ts.symbolName(symbol) === "globalThis" &&
symbol.declarations.length === 0
);
};
return isGlobal(receiver) ||
(receiver.isIntersectionType() && ts.constituentTypes(receiver).some(isGlobal))
? operand
: null;
}
/** Lower for a known destination, then apply ordinary value coercion.
* Fresh arrays and records use the destination layout directly. */
lowerExprExpecting(node: ts.Expression, expected: IrType | undefined): IrExpr {
@@ -10700,6 +10811,21 @@ export class Lowerer {
(element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element),
)
) {
// A checked slot that the program declares as a native array (a
// member of a typed view of the global object, `typeof globalThis &
// { cache?: number[] }`) is read back through that declaration. Build
// the native array and store it boxed: every typed read then extracts
// that same array (a dynamic literal would be copied on each read,
// losing writes such as push).
const slot = this.globalViewSlot(x);
const slotType = slot ? this.checker.getContextualType(slot) : undefined;
const declared = slotType && this.checker.getNonNullableType(slotType);
const native =
declared && !(declared.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
? this.mapTypeOf(declared)
: null;
if (native?.kind === "array")
return this.coerceInto(node, this.lowerArrayLiteral(x, native), DYN);
return {
kind: "dynArrLit",
elems: x.elements.nodes.map((element) => this.lowerExprExpecting(element, DYN)),
@@ -10729,6 +10855,7 @@ export class Lowerer {
builtinOption("URLFormatOptions") ||
!this.mapTypeOf(this.typeOf(x)) ||
this.excessJsContextualProperty(x, parts) ||
this.reshapeableContextualLiteral(x, parts) ||
holdsEvolvingArray(this, x)
) {
return lowerDynObjectLiteral(this, x);
@@ -1593,6 +1593,9 @@ export const BUILTIN_MODULE_FNS: Record<
// Prime checks and generation (lowerPrimeCall): checked-dynamic
// arguments, results converted to the call site's type.
checkPrimeSync: { fn: "crypto.checkPrimeSync", params: [], result: DYN },
// Checked-dynamic arguments, the result converted to the call site's
// type (lowerPrimeCall).
getCipherInfo: { fn: "crypto.getCipherInfo", params: [], result: DYN },
checkPrime: { fn: "crypto.checkPrimeCb", params: [], result: VOID },
generatePrimeSync: { fn: "crypto.generatePrimeSync", params: [], result: DYN },
generatePrime: { fn: "crypto.generatePrimeCb", params: [], result: VOID },
@@ -3101,14 +3104,6 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record<
diffieHellman:
"crypto.diffieHellman takes DH, EC or X25519 KeyObjects and has no lowering; " +
"the objects of createDiffieHellman, getDiffieHellman and createECDH compute shared secrets",
...Object.fromEntries(
["getCipherInfo"].map((m) => [
m,
"cipher metadata has no lowering — createCipheriv and createDecipheriv support " +
"aes-128/192/256 in cbc, ctr, gcm, ecb, cfb, cfb8, cfb1, and ofb modes, des-ede3 and " +
"des-ede, chacha20, and chacha20-poly1305",
]),
),
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",
@@ -6,9 +6,11 @@
*
* A binding settles at the first statement of its hoisting scope (the
* function body or the source file) that stores it unconditionally: a
* direct `var x = e`, `x = e;`, or a loop declaring it (`for (var i = 0;
* ...)`, `for (var k in o)`), whose own condition, update and body run
* after the store. A read settles when it runs after that statement:
* direct `var x = e`, `x = e;`, or a `for` loop declaring it (`for (var
* i = 0; ...)`), whose own condition, update and body run after the store.
* A `for (var k in o)` or `for (var v of xs)` loop stores the binding only
* when it iterates, so it settles the reads in its body and no others. A
* read settles when it runs after the settling statement:
* - a read in the scope's own code, in a TypeScript file: tsc's
* definite-assignment analysis rejects any earlier one;
* - a read in the scope's own code, in a JavaScript file: its statement
@@ -194,6 +196,18 @@ function referencesBySymbol(
return out;
}
/** Where a binding is first stored unconditionally: the scope statement's
* index, and for a declarator of a `var` statement or a `for` initializer,
* the list and the declarator's end. */
interface Settle {
index: number;
list: ts.VariableDeclarationList | null;
end: number;
/** The first for-in/of loop declaring the binding before `index`, whose
* body runs after its store, or -1. */
loop: number;
}
class ScopeAnalysis {
private readonly js: boolean;
/** Whether a function declaration's references all settle, by scope
@@ -214,10 +228,13 @@ class ScopeAnalysis {
}
/** The index of the scope statement that first stores `symbol`
* unconditionally, or Infinity. */
settleIndex(scope: Scope, symbol: ts.Symbol): number {
* unconditionally (Infinity when none does), and, when that statement
* declares it in a `var` statement or a `for` initializer, the end of the
* storing declarator: later declarators of the list run after it. */
settleIndex(scope: Scope, symbol: ts.Symbol): Settle {
const statements = statementsOf(scope);
const checker = this.lowerer.checker;
let loopIndex = -1;
for (let i = 0; i < statements.length; i++) {
const s = statements[i]!;
const list = ts.isVariableStatement(s)
@@ -233,8 +250,10 @@ class ScopeAnalysis {
if (
(d.initializer !== undefined || loop) &&
boundIdentifiersOf(d.name).some((n) => checker.getSymbolAtLocation(n) === symbol)
)
return i;
) {
if (!loop) return { index: i, list, end: d.end, loop: loopIndex };
if (loopIndex < 0) loopIndex = i;
}
}
} else if (
ts.isExpressionStatement(s) &&
@@ -243,20 +262,21 @@ class ScopeAnalysis {
ts.isIdentifier(s.expression.left) &&
checker.getSymbolAtLocation(s.expression.left) === symbol
) {
return i;
return { index: i, list: null, end: -1, loop: loopIndex };
}
}
return Infinity;
return { index: Infinity, list: null, end: -1, loop: loopIndex };
}
/** Whether `ref` (inside `scope`, or in another module) runs only after
* the statement at index `settle`. `read`: `ref` reads the binding
* itself, so tsc vouches for one in the scope's own code. */
settles(scope: Scope, settle: number, ref: ts.Identifier, read = true): boolean {
* the store `settle` describes. `read`: `ref` reads the binding itself,
* so tsc vouches for one in the scope's own code. */
settles(scope: Scope, settle: Settle, ref: ts.Identifier, read = true): boolean {
if (ts.getSourceFileOfNode(ref) !== this.sf) return true;
const statements = statementsOf(scope);
let deferred: ts.Node | null = null;
let top: ts.Node | null = null;
const container: ts.Node | undefined = ts.isSourceFile(scope) ? scope : scope.body;
for (let n: ts.Node = ref; n.parent !== undefined && n !== scope; n = n.parent) {
if (
ts.isFunctionLike(n) ||
@@ -265,18 +285,26 @@ class ScopeAnalysis {
ts.isClassStaticBlockDeclaration(n)
)
deferred = n;
if (n.parent === scope || (!ts.isSourceFile(scope) && n.parent === scope.body)) top = n;
// The scope's own statement, not the function body that holds it.
if (n.parent === container) top = n;
}
if (top === null) return false;
if (deferred === null && read && !this.js) return true;
if (deferred !== null && ts.isFunctionDeclaration(deferred) && deferred.name !== undefined)
return this.functionSettles(scope, settle, deferred);
const index = statements.indexOf(top as ts.Statement);
return index > settle || (index === settle && settlesWithin(top, ref));
return (
index > settle.index ||
(index === settle.index &&
(settlesWithin(top, ref) ||
// A later declarator of the storing list (`var a = {}, b = { ...a }`).
(settle.list !== null && isInside(ref, settle.list) && ref.pos >= settle.end))) ||
(index === settle.loop && settlesWithin(top, ref))
);
}
private functionSettles(scope: Scope, settle: number, fn: ts.FunctionDeclaration): boolean {
const key = `${fn.pos}:${settle}`;
private functionSettles(scope: Scope, settle: Settle, fn: ts.FunctionDeclaration): boolean {
const key = `${fn.pos}:${settle.index}:${settle.end}:${settle.loop}`;
const known = this.functionMemo.get(key);
if (known !== undefined) return known;
this.functionMemo.set(key, true);

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