From ec2f2a341f45a9278a955999f41f8f76dee5f4ca Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 22:04:17 -0500 Subject: [PATCH 1/6] Record new corpus program costs in per-program files Every change that adds a corpus program also added its CI costs to tests/harness/corpus-costs.json. The table is sorted by program name and programs are numbered in sequence, so concurrent changes inserted lines at the same place and conflicted. A change now writes tests/harness/corpus-costs/.json ({ "plain": ms, "san": ms }). The shard loader applies these files over the table, and scripts/ci-corpus-costs.mjs folds them into the table and deletes them when it refreshes the costs from CI logs. With no per-program files the table and the shard assignment are unchanged. --- AGENTS.md | 2 + scripts/ci-corpus-costs.mjs | 19 +++++++++- tests/harness/corpus-costs/README.md | 9 +++++ tests/harness/differential.test.ts | 2 +- tests/harness/shard.test.ts | 33 ++++++++++++++-- tests/harness/shard.ts | 56 ++++++++++++++++++++++++---- 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 tests/harness/corpus-costs/README.md diff --git a/AGENTS.md b/AGENTS.md index b94c90067..45dc2bd57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ SCRIPTC_TEST_WORKERS=4 SCRIPTC_SAN=1 pnpm test # sanitized lane Corpus programs are differential tests against Node: every program runs under Node and as a compiled native binary, and stdout, stderr, and exit codes must match byte-for-byte. A new feature lands with corpus programs that pin its behavior both ways. +Record a new or remeasured program's CI cost (used to balance the test shards) in `tests/harness/corpus-costs/.json`, not in `tests/harness/corpus-costs.json`; see the README there. + Before opening a PR that adds or changes corpus, diagnostic, or frontend fixtures, run `pnpm test:ts7 --baselines-only`. This fast completeness check uses the same fixture discovery as the parity suite; it does not type-check or replace behavioral parity. Generate missing preflight/order baselines, review their diagnostics and module order, and run the affected `order-parity.test.ts` batches before pushing. Keep existing baseline entries unchanged unless the corresponding behavior intentionally changes. The full `pnpm test:ts7` command also checks completeness before starting its sweep. A change that must not cost good code anything, such as a compatibility fix that replaces a refusal, proves it with the zero-cost gate. The gate compiles the benchmark workloads, the self-hosted compiler, and the TypeScript corpus with two built checkouts. It requires byte-identical IR, LLVM, and release executables, and compile time within tolerance. `--allow=` names the change's pattern signature: programs whose sources match may differ, and the PR lists them. Any other difference fails the gate, which names the first differing function. The gate also enforces the two-tier runtime separation: no executable may gain symbols from `DYNAMIC_TIER_PREFIXES` in `scripts/zero-cost-compare.mjs`, and fully typed benchmarks may link none; extend that list as dynamic-tier units land. `--quick` checks only the benchmarks; `--cachegrind` adds per-function instruction counts for executables that differ. Run it in a Sandbox with both checkouts prepared: diff --git a/scripts/ci-corpus-costs.mjs b/scripts/ci-corpus-costs.mjs index e934647c8..9f0e8b085 100644 --- a/scripts/ci-corpus-costs.mjs +++ b/scripts/ci-corpus-costs.mjs @@ -1,16 +1,31 @@ #!/usr/bin/env node // Refreshes tests/harness/corpus-costs.json from the logs of the Linux test shards of green CI runs on main. // +// It first folds the per-program cost files (tests/harness/corpus-costs/.json, which changes that add or +// remeasure a program write instead of editing the table) into the table and deletes them. +// // Usage: node scripts/ci-corpus-costs.mjs ... // // Each log is a downloaded "test (Linux, plain|san, i/10)" job log. vitest prints every case slower than its // threshold with its duration; for each program this takes the median of its differential and of its // llvm-differential case over the logs of its flavor, and stores their sum. Programs without a measurement keep // their current value. -import { readFileSync, writeFileSync } from "node:fs"; +import { globSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; const path = new URL("../tests/harness/corpus-costs.json", import.meta.url); const table = JSON.parse(readFileSync(path, "utf8")); +const filesDir = fileURLToPath(new URL("../tests/harness/corpus-costs", import.meta.url)); +let folded = 0; +for (const rel of globSync("**/*.json", { cwd: filesDir })) { + const key = rel.replaceAll("\\", "/").slice(0, -".json".length); + const entry = JSON.parse(readFileSync(join(filesDir, rel), "utf8")); + for (const flavor of ["plain", "san"]) + if (entry[flavor] !== undefined) table[flavor][key] = entry[flavor]; + rmSync(join(filesDir, rel)); + folded++; +} const samples = { plain: {}, san: {} }; for (const log of process.argv.slice(2)) { // Logs keep the terminal color codes (ESC [ ... m); drop them before matching. @@ -40,4 +55,4 @@ for (const flavor of ["plain", "san"]) Object.entries(table[flavor]).sort(([a], [b]) => a.localeCompare(b)), ); writeFileSync(path, JSON.stringify(table, null, 2) + "\n"); -console.log(`updated ${updated} program costs`); +console.log(`updated ${updated} program costs, folded ${folded} per-program cost files`); diff --git a/tests/harness/corpus-costs/README.md b/tests/harness/corpus-costs/README.md new file mode 100644 index 000000000..e005fa793 --- /dev/null +++ b/tests/harness/corpus-costs/README.md @@ -0,0 +1,9 @@ +# Per-program corpus costs + +A change that adds a corpus program, or changes how long one takes, records the program's CI cost here instead of editing `../corpus-costs.json`: one file per program, named after it, with the milliseconds of its differential plus llvm-differential case per flavor. + +```json +{ "plain": 2700, "san": 3200 } +``` + +`5637-child-max-buffer-sync.ts` goes in `5637-child-max-buffer-sync.ts.json`, and a directory program such as `2946-child-fork-ipc/main.ts` in `2946-child-fork-ipc/main.ts.json`. Either flavor may be left out. These files override the table for shard balancing (`tests/harness/shard.ts`), and `scripts/ci-corpus-costs.mjs` folds them into the table and deletes them when it refreshes the costs from CI logs. Separate files keep changes that add programs from editing the same lines of the table. diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index a2206fd97..d8139299d 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -36,7 +36,7 @@ const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); // SCRIPTC_TEST_SHARD="i/n" (CI's matrix) keeps only this shard's slice of // the corpus — the file is this suite's wall-time monster, so vitest's // file-granular --shard alone cannot split it. Slices are balanced by each -// program's measured cost (corpus-costs.json). Unset = everything. +// program's measured cost (corpus-costs.json and corpus-costs/). Unset = everything. const ENTRY_EXTS = ["ts", "js", "mjs", "cjs"]; const files = corpusSelect( ENTRY_EXTS.flatMap((ext) => [ diff --git a/tests/harness/shard.test.ts b/tests/harness/shard.test.ts index e645f2cac..61eb84083 100644 --- a/tests/harness/shard.test.ts +++ b/tests/harness/shard.test.ts @@ -3,19 +3,20 @@ * depends only on the item's key (list growth never moves an existing item), * and no spec means everything. differential/llvm-differential/npm/server * lean on these properties for CI sharding — see shard.ts. */ -import { globSync } from "node:fs"; +import { globSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { balancedShardSelect, + corpusCostFiles, corpusSelect, + loadCorpusCosts, parseShardSpec, shardOf, shardSelect, shardSuffix, skipUnownedTest, } from "./shard.js"; -import corpusCosts from "./corpus-costs.json"; import fixtureCosts from "./effect4-costs.json"; import effectCosts from "./effect-costs.json"; @@ -268,7 +269,7 @@ describe("corpusSelect", () => { ); test("measured costs cover the corpus and balance the shards", () => { - const costs = corpusCosts.plain as Record; + const costs = loadCorpusCosts().plain; const measured = programs.filter((k) => costs[k] !== undefined).length; expect(measured / programs.length).toBeGreaterThan(0.9); const loads = Array.from({ length: 10 }, (_, i) => @@ -279,4 +280,30 @@ describe("corpusSelect", () => { ); expect(Math.max(...loads) / Math.min(...loads)).toBeLessThan(1.1); }); + + test("every per-program cost file names a corpus program", () => { + const known = new Set(programs); + for (const key of corpusCostFiles().keys()) + expect(known, `corpus-costs/${key}.json`).toContain(key); + }); + + test("per-program cost files override the table, and invalid ones throw", () => { + const dir = join( + import.meta.dirname, + "../../node_modules/.cache/scriptc-tests/corpus-costs-files", + ); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(join(dir, "200-dir"), { recursive: true }); + writeFileSync(join(dir, "100-flat.ts.json"), '{ "plain": 2500, "san": 4000 }\n'); + writeFileSync(join(dir, "200-dir/main.ts.json"), '{ "san": 900 }\n'); + expect([...corpusCostFiles(dir)]).toEqual([ + ["100-flat.ts", { plain: 2500, san: 4000 }], + ["200-dir/main.ts", { san: 900 }], + ]); + for (const bad of ['{ "plian": 1 }', '{ "plain": 0 }', '{ "san": "9" }']) { + writeFileSync(join(dir, "100-flat.ts.json"), bad); + expect(() => corpusCostFiles(dir)).toThrow(/plain|san/); + } + rmSync(dir, { recursive: true, force: true }); + }); }); diff --git a/tests/harness/shard.ts b/tests/harness/shard.ts index 4c3b9c244..a2797acf9 100644 --- a/tests/harness/shard.ts +++ b/tests/harness/shard.ts @@ -16,7 +16,9 @@ * everything: local runs are unchanged. */ import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { globSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Suite, Test, TestContext } from "vitest"; export interface ShardSpec { @@ -94,11 +96,53 @@ export function balancedShardSelect( return items.filter((_, index) => owners[index] === spec.index); } -/** Measured milliseconds per corpus program and flavor (corpus-costs.json): its differential plus - * llvm-differential case on a CI runner. */ -type CorpusCosts = { plain: Record; san: Record }; +/** Measured milliseconds per corpus program and flavor: its differential plus llvm-differential case on a CI + * runner. corpus-costs.json is the table scripts/ci-corpus-costs.mjs refreshes from CI logs. A change that adds + * or remeasures a program records its costs in corpus-costs/.json instead (`{ "plain": ms, "san": ms }`, + * a directory program as corpus-costs//main.ts.json), which overrides the table until the next refresh + * folds it in. One file per program keeps changes that add programs from editing the same lines. */ +export type CorpusCosts = { plain: Record; san: Record }; let corpusCostTable: CorpusCosts | undefined; +/** The per-program cost files: program key → its file's costs. Throws on a field other than plain/san or a + * cost that is not a positive finite number. */ +export function corpusCostFiles( + dir: string = fileURLToPath(new URL("./corpus-costs", import.meta.url)), +): Map>> { + const files = new Map>>(); + for (const rel of globSync("**/*.json", { cwd: dir }) + .map((path) => path.replaceAll("\\", "/")) + .sort()) { + const key = rel.slice(0, -".json".length); + const entry = JSON.parse(readFileSync(join(dir, rel), "utf8")) as Record; + for (const [flavor, cost] of Object.entries(entry)) { + if ( + (flavor !== "plain" && flavor !== "san") || + typeof cost !== "number" || + !Number.isFinite(cost) || + cost <= 0 + ) + throw new Error( + `corpus-costs/${rel}: ${flavor} must be "plain" or "san" with a positive cost`, + ); + } + files.set(key, entry as Partial>); + } + return files; +} + +/** corpus-costs.json with the per-program files applied. */ +export function loadCorpusCosts(): CorpusCosts { + const table = JSON.parse( + readFileSync(new URL("./corpus-costs.json", import.meta.url), "utf8"), + ) as CorpusCosts; + for (const [key, entry] of corpusCostFiles()) { + if (entry.plain !== undefined) table.plain[key] = entry.plain; + if (entry.san !== undefined) table.san[key] = entry.san; + } + return table; +} + /** The corpus programs this shard owns, balanced by measured cost. differential.test.ts and * llvm-differential.test.ts both select with this, so a shard owns the same programs in both lanes and their * compile cache serves both. A program without a measurement weighs as much as the median measured one, so @@ -109,9 +153,7 @@ export function corpusSelect( sanitize: boolean, spec: ShardSpec | undefined = parseShardSpec(), ): T[] { - corpusCostTable ??= JSON.parse( - readFileSync(new URL("./corpus-costs.json", import.meta.url), "utf8"), - ) as CorpusCosts; + corpusCostTable ??= loadCorpusCosts(); const costs = sanitize ? corpusCostTable.san : corpusCostTable.plain; const known = Object.values(costs).sort((a, b) => a - b); const median = known.length === 0 ? 1 : known[Math.floor(known.length / 2)]!; From 6a8732f57a2d52714fb46c4061d91918f3804a88 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 05:52:22 -0500 Subject: [PATCH 2/6] Run iterator helpers and destructuring on generators passed through untyped values A compiled generator that reached an untyped value, as in `function f(it) { return it.map(g) } f(gen())`, refused its iterator helper calls with SC1071 at run time: the runtime's helpers could not step a generator's capsule. Array destructuring of untyped values only handled arrays, strings and bytes, so `const [a, b] = it` threw "is not iterable" for generators, iterators, Maps and Sets. Programs that box a synchronous generator and call a helper method or destructure an untyped value now register, first thing in %main, the checked getters that read a generator capsule's next and return methods (scr_dyn_set_generator_members). With them: - helper calls on a generator capsule run the runtime's helpers, which step and close the generator through its own next() and return(); - reads of a helper name answer the runtime's helper function, so `typeof it.map` is "function" and `it.reduce.call(it, f, init)` works; - destructuring of any other iterable steps it through the iterator protocol (scr_dyn_iter_take, and rest elements through scr_dyn_iter_pack), closing an iterator that has values left. A return() through an untyped value with no argument, or with undefined, now completes with undefined as the typed .return() does, instead of converting undefined to the generator's return type. This also fixes for-of loops over untyped generators with a typed return value that exit early. --- .../builtin-apis/iterator-helpers.mdx | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 90 ++---- .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../lowering/generator-dynamic-dispatch.ts | 259 +++++++++++++----- .../src/frontend/lowering/lower-modules.ts | 1 + .../src/frontend/lowering/lower-stmts.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 4 + packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 3 + .../6077-generator-helpers-untyped.js.json | 6 + ...6078-destructure-untyped-iterables.js.json | 6 + .../corpus/6079-generator-helpers-any.ts.json | 6 + packages/runtime/src/scr_dyn.h | 29 ++ packages/runtime/src/scr_dyn_invoke.c | 4 + packages/runtime/src/scr_json.c | 131 ++++++++- .../corpus/6077-generator-helpers-untyped.js | 81 ++++++ .../6078-destructure-untyped-iterables.js | 60 ++++ tests/corpus/6079-generator-helpers-any.ts | 24 ++ tests/harness/corpus-costs.json | 6 + 19 files changed, 569 insertions(+), 149 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6077-generator-helpers-untyped.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6078-destructure-untyped-iterables.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6079-generator-helpers-any.ts.json create mode 100644 tests/corpus/6077-generator-helpers-untyped.js create mode 100644 tests/corpus/6078-destructure-untyped-iterables.js create mode 100644 tests/corpus/6079-generator-helpers-any.ts diff --git a/docs/content/limitations/builtin-apis/iterator-helpers.mdx b/docs/content/limitations/builtin-apis/iterator-helpers.mdx index b4c4d8200..4ec19d581 100644 --- a/docs/content/limitations/builtin-apis/iterator-helpers.mdx +++ b/docs/content/limitations/builtin-apis/iterator-helpers.mdx @@ -1 +1 @@ -- **Iterator helpers:** `map`, `filter`, `take`, `drop`, `flatMap`, `toArray`, `forEach`, `reduce`, `some`, `every` and `find` compile on generators, on array, typed array, string, Map and Set iterators, and on the objects the helpers return. Helper objects support `.next()`, `.return()`, `for...of`, spreads, `Array.from` and `yield*`, pull their source lazily, and close it (run its `return()`) at the same points as Node: when `take` reaches its count, when a short-circuiting method finds its answer, when a callback throws, and when the loop consuming the helper exits early. In TypeScript, `h.return!()` and `h.return?.()` compile when `h` is a helper call or a `const` initialized with one. `flatMap` callbacks return arrays, generators, Sets or Maps; a string result throws Node's TypeError. `Iterator.from` accepts arrays, strings, typed arrays, Sets, Maps and iterator objects such as generators. In JavaScript, untyped array, string, Map and Set iterators answer the same methods. `console.log`, `util.inspect` and `Object.prototype.toString` print iterator objects as Node does: `Object [Iterator Helper] {}`, `Object [Generator] {}`, `Object [Array Iterator] {}`, and Map and Set iterators with the entries they have left. `Iterator.from` over an object with its own `next` method or over an untyped value, the `Iterator` global as a value, and `String()` and template literals of typed iterator objects are unsupported. +- **Iterator helpers:** `map`, `filter`, `take`, `drop`, `flatMap`, `toArray`, `forEach`, `reduce`, `some`, `every` and `find` compile on generators, on array, typed array, string, Map and Set iterators, and on the objects the helpers return. Helper objects support `.next()`, `.return()`, `for...of`, spreads, `Array.from` and `yield*`, pull their source lazily, and close it (run its `return()`) at the same points as Node: when `take` reaches its count, when a short-circuiting method finds its answer, when a callback throws, and when the loop consuming the helper exits early. In TypeScript, `h.return!()` and `h.return?.()` compile when `h` is a helper call or a `const` initialized with one. `flatMap` callbacks return arrays, generators, Sets or Maps; a string result throws Node's TypeError. `Iterator.from` accepts arrays, strings, typed arrays, Sets, Maps and iterator objects such as generators. In JavaScript, untyped generators and array, string, Map and Set iterators answer the same methods, and array destructuring of untyped values steps generators, iterators, Maps and Sets the way Node does. Helper methods on untyped async generators are unsupported. `console.log`, `util.inspect` and `Object.prototype.toString` print iterator objects as Node does: `Object [Iterator Helper] {}`, `Object [Generator] {}`, `Object [Array Iterator] {}`, and Map and Set iterators with the entries they have left. `Iterator.from` over an object with its own `next` method or over an untyped value, the `Iterator` global as a value, and `String()` and template literals of typed iterator objects are unsupported. diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 154b80c02..cf7578c02 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -4014,14 +4014,9 @@ export class LlDyn { const name = "sc_dyn_iter_n"; this.dynBuilders.set(memoKey, name); const host = this.host; - host.declare(`declare void @scr_jb_init(ptr)`); - host.declare(`declare ptr @scr_jb_finish(ptr)`); - host.declare(`declare void @scr_jb_puts(ptr, ptr)`); - host.declare(`declare void @scr_throw_error(i32, ptr)`); host.declare(`declare ptr @scr_dyn_new_arr()`); host.declare(`declare void @scr_dyn_arr_push(ptr, ptr)`); host.declare(`declare ptr @scr_dyn_new_num(double)`); - host.declare(`declare ptr @scr_f64_to_scrstr(double)`); host.declare(`declare void @scr_str_release(ptr)`); const B = new BlockBuilder(); const kd = this.kindOf(B, "%d"); @@ -4033,6 +4028,21 @@ export class LlDyn { const lNext = B.newLabel("din.nt"); B.condBr(isTyped, lTyped, lNext); B.startBlock(lTyped); + // Compiled generators step through the iterator protocol. + host.declare(`declare i1 @scr_dyn_iterates_by_protocol(ptr)`); + host.declare(`declare ptr @scr_dyn_iter_take(ptr, ${host.sizeType}, ptr)`); + const protocol = B.tmp(); + const lProtocol = B.newLabel("din.pr"); + const lView = B.newLabel("din.vw"); + B.line(`${protocol} = call i1 @scr_dyn_iterates_by_protocol(ptr %d)`); + B.condBr(protocol, lProtocol, lView); + B.startBlock(lProtocol); + const stepped = B.tmp(); + B.line( + `${stepped} = call ptr @scr_dyn_iter_take(ptr %d, ${host.sizeType} %n, ptr %spelling)`, + ); + B.terminate(`ret ptr ${stepped}`); + B.startBlock(lView); host.declare(`declare ptr @scr_dyn_typed_ref_materialize(ptr)`); host.declare(`declare void @scr_dyn_release_v(ptr)`); const materialized = B.tmp(); @@ -4057,71 +4067,13 @@ export class LlDyn { const lGo = B.newLabel("din.g"); const lThrow = B.newLabel("din.t"); B.condBr(ok, lGo, lThrow); - // V8's exact not-iterable TypeError. + // Other iterables step through the iterator protocol; non-iterables + // throw V8's exact not-iterable TypeError. B.startBlock(lThrow); - const buf = "%dib"; - B.entryAllocas.push(`${buf} = alloca %ScrJsonBuf`); - B.line(`call void @scr_jb_init(ptr ${buf})`); - const hasSpelling = B.tmp(); - B.line(`${hasSpelling} = icmp ne ptr %spelling, null`); - const named = B.newLabel("din.named"), - unnamed = B.newLabel("din.unnamed"); - B.condBr(hasSpelling, named, unnamed); - B.startBlock(named); - B.line(`call void @scr_jb_puts(ptr ${buf}, ptr %spelling)`); - const namedMessage = B.tmp(); - B.line(`${namedMessage} = call ptr @scr_jb_finish(ptr ${buf})`); - B.line(`call void @scr_throw_error(i32 1, ptr ${namedMessage})`); - B.terminate(`ret ptr null`); - B.startBlock(unnamed); - const lU = B.newLabel("din.u"); - const lN = B.newLabel("din.n"); - const lB2 = B.newLabel("din.b"); - const lNum = B.newLabel("din.m"); - const lF = B.newLabel("din.f"); - const lDef = B.newLabel("din.d"); - const lTail = B.newLabel("din.e"); - B.terminate( - `switch i32 ${kd}, label %${lDef} [ i32 ${DYN_KIND.UNDEF}, label %${lU} i32 ${DYN_KIND.NULL}, label %${lN} i32 ${DYN_KIND.BOOL}, label %${lB2} i32 ${DYN_KIND.NUM}, label %${lNum} i32 ${DYN_KIND.FUNC}, label %${lF} ]`, - ); - B.startBlock(lU); - this.puts(B, buf, "undefined"); - B.br(lTail); - B.startBlock(lN); - this.puts(B, buf, "object null"); - B.br(lTail); - B.startBlock(lB2); - { - const bv = this.boolOf(B, "%d"); - const s = B.tmp(); - B.line( - `${s} = select i1 ${bv}, ptr ${host.cstr("boolean true")}, ptr ${host.cstr("boolean false")}`, - ); - B.line(`call void @scr_jb_puts(ptr ${buf}, ptr ${s})`); - B.br(lTail); - } - B.startBlock(lNum); - { - this.puts(B, buf, "number "); - const x = this.payloadOf(B, "%d", "double"); - const s = B.tmp(); - B.line(`${s} = call ptr @scr_f64_to_scrstr(double ${x})`); - this.putScrStr(B, buf, s); - B.line(`call void @scr_str_release(ptr ${s})`); - B.br(lTail); - } - B.startBlock(lF); - this.puts(B, buf, "function"); - B.br(lTail); - B.startBlock(lDef); - this.puts(B, buf, "object"); - B.br(lTail); - B.startBlock(lTail); - this.puts(B, buf, " is not iterable (cannot read property Symbol(Symbol.iterator))"); - const msg = B.tmp(); - B.line(`${msg} = call ptr @scr_jb_finish(ptr ${buf})`); - B.line(`call void @scr_throw_error(i32 1, ptr ${msg}) ; SCR_ERR_TYPE`); - B.terminate(`ret ptr null`); + host.declare(`declare ptr @scr_dyn_iter_take(ptr, ${host.sizeType}, ptr)`); + const taken = B.tmp(); + B.line(`${taken} = call ptr @scr_dyn_iter_take(ptr %d, ${host.sizeType} %n, ptr %spelling)`); + B.terminate(`ret ptr ${taken}`); B.startBlock(lGo); const out = B.tmp(); B.line(`${out} = call ptr @scr_dyn_new_arr()`); diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index e4c12058f..be67e1b67 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -886,12 +886,15 @@ export const LIB_FN_SYMS: Record = { "dyn.typedRefIs": "scr_dyn_typed_ref_is_key", "dyn.classIs": "scr_dyn_class_is_key", "dyn.iterPack": "scr_dyn_iter_pack", + "dyn.destructureRest": "scr_dyn_destructure_rest", "dyn.iterator": "scr_dyn_iterator", "dyn.arrayFromIterator": "scr_dyn_array_from_iterator", "dyn.iteratorResult": "scr_dyn_iterator_result", "dyn.iteratorCanStep": "scr_dyn_iterator_can_step", "dyn.iteratorStep": "scr_dyn_iterator_step", "dyn.iteratorStepDone": "scr_dyn_iterator_step_done", + "dyn.setGeneratorMembers": "scr_dyn_set_generator_members", + "dyn.iteratorHelperMethod": "scr_dyn_iterator_helper_method", "dyn.collectionIteratorIndex": "scr_dyn_collection_iterator_index", "dyn.mapSeedEntries": "scr_dyn_map_seed_entries", "dyn.mapSeedEntry": "scr_dyn_map_seed_entry", diff --git a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts index 10505e063..ca6a5ecdc 100644 --- a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts @@ -7,6 +7,7 @@ import { funcOf, typeEquals, typeKey, + VOID, type IrExpr, type IrFunction, type IrStmt, @@ -18,6 +19,22 @@ import type { Lowerer } from "./lowerer.js"; type GeneratorType = Extract; type Invoke = Extract; +type KeyGet = Extract; + +/** The iterator helper methods the runtime answers on checked iterators. */ +const HELPER_METHODS = new Set([ + "map", + "filter", + "take", + "drop", + "flatMap", + "reduce", + "toArray", + "forEach", + "some", + "every", + "find", +]); /** An untyped consumer resumes the original native fiber. The capsule's * exact type key selects the channel conversions and result layout. */ @@ -29,6 +46,54 @@ export class GeneratorDynamicDispatch { >(); private readonly generated = new Set(); private readonly properties = new Map }>(); + /** Whether %main registers the next/return getters with the runtime. */ + private membersRegistered = false; + + /** The getter for a generator capsule's `method` (next, return, throw or + * an iterator helper): a checked function over the capsule's type keys + * that answers the method as a function value. `read` is the dyn read + * whose fallback it keeps for other receivers. */ + private ensureProperty(lowerer: Lowerer, method: string, read: KeyGet): IrFunction { + const existing = this.properties.get(method); + if (existing) return existing.fn; + const loc = read.loc; + const fn: IrFunction = { + name: `%dyn.generator.get.${method}`, + params: [{ localId: "value", name: "value", type: DYN }], + returnType: DYN, + locals: [{ id: "value", name: "value", type: DYN, mutable: false }], + body: [{ kind: "return", value: { ...read, value: varRef("value", DYN, loc) }, loc }], + loc, + }; + this.properties.set(method, { fn, types: new Set() }); + this.generated.add(fn); + lowerer.liftedFns.push(fn); + // The runtime's own helper functions answer the helper methods. + if (HELPER_METHODS.has(method)) return fn; + lowerer.liftedFns.push({ + name: `%dyn.generator.method.${method}`, + params: [{ localId: "arg", name: "arg", type: DYN }], + returnType: DYN, + locals: [{ id: "arg", name: "arg", type: DYN, mutable: false }], + body: [ + { + kind: "return", + value: { + kind: "dynInvoke", + method, + calleeName: `Generator.${method}`, + recv: { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }, + args: [varRef("arg", DYN, loc)], + type: DYN, + loc, + }, + loc, + }, + ], + loc, + }); + return fn; + } process( lowerer: Lowerer, @@ -37,6 +102,7 @@ export class GeneratorDynamicDispatch { ): boolean { const seen = new Set(); const rewrite = new Set(); + let runtimeSteps = false; const discover = (type: IrType): void => { const key = typeKey(type); if (seen.has(key)) return; @@ -56,10 +122,22 @@ export class GeneratorDynamicDispatch { if (expr.kind === "dynFrom") discover(expr.value.type); if (expr.kind === "dynInvoke" && ["next", "return", "throw"].includes(expr.method)) rewrite.add(fn); + // The runtime steps the values iterator helpers and destructuring + // receive. + if ( + (expr.kind === "dynInvoke" && HELPER_METHODS.has(expr.method)) || + (expr.kind === "dynKeyGet" && + expr.key.kind === "strLit" && + HELPER_METHODS.has(expr.key.value)) || + expr.kind === "dynIterN" || + (expr.kind === "libCall" && expr.fn === "dyn.destructureRest") + ) + runtimeSteps = true; if ( expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && - ["next", "return", "throw"].includes(expr.key.value) + (["next", "return", "throw"].includes(expr.key.value) || + HELPER_METHODS.has(expr.key.value)) ) rewrite.add(fn); return true; @@ -74,56 +152,20 @@ export class GeneratorDynamicDispatch { fn.body = transformStmtList(fn.body, { stmt: (stmt) => stmt, expr: (expr) => { + // A helper method read reaches the runtime's helpers through the + // same getters once generators are boxed. if ( expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && - ["next", "return", "throw"].includes(expr.key.value) + (["next", "return", "throw"].includes(expr.key.value) || + (HELPER_METHODS.has(expr.key.value) && this.generators.size > 0)) ) { const method = expr.key.value; - let property = this.properties.get(method); - if (!property) { - const loc = expr.loc; - const fn: IrFunction = { - name: `%dyn.generator.get.${method}`, - params: [{ localId: "value", name: "value", type: DYN }], - returnType: DYN, - locals: [{ id: "value", name: "value", type: DYN, mutable: false }], - body: [ - { kind: "return", value: { ...expr, value: varRef("value", DYN, loc) }, loc }, - ], - loc, - }; - property = { fn, types: new Set() }; - this.properties.set(method, property); - this.generated.add(fn); - lowerer.liftedFns.push(fn); - lowerer.liftedFns.push({ - name: `%dyn.generator.method.${method}`, - params: [{ localId: "arg", name: "arg", type: DYN }], - returnType: DYN, - locals: [{ id: "arg", name: "arg", type: DYN, mutable: false }], - body: [ - { - kind: "return", - value: { - kind: "dynInvoke", - method, - calleeName: `Generator.${method}`, - recv: { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }, - args: [varRef("arg", DYN, loc)], - type: DYN, - loc, - }, - loc, - }, - ], - loc, - }); - changed = true; - } + if (!this.properties.has(method)) changed = true; + const getter = this.ensureProperty(lowerer, method, expr); return { kind: "call", - callee: property.fn.name, + callee: getter.name, args: [expr.value], type: DYN, loc: expr.loc, @@ -179,6 +221,50 @@ export class GeneratorDynamicDispatch { }, }); } + // Iterator helpers called on untyped values may reach a compiled + // generator's capsule; the runtime drives it through these getters, + // which %main registers first. + const firstFn = functions[0]; + if ( + runtimeSteps && + !this.membersRegistered && + firstFn !== undefined && + [...this.generators.values()].some((t) => t.async !== true) + ) { + const loc = firstFn.loc; + const read = (method: string): KeyGet => ({ + kind: "dynKeyGet", + value: varRef("value", DYN, loc), + key: { kind: "strLit", value: method, type: STRING, loc }, + type: DYN, + loc, + }); + const getterValue = (method: string): IrExpr => ({ + kind: "dynFrom", + value: { + kind: "closure", + fnName: this.ensureProperty(lowerer, method, read(method)).name, + captures: [], + type: funcOf([DYN], DYN), + loc, + }, + type: DYN, + loc, + }); + lowerer.mainPrelude.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.setGeneratorMembers", + args: [getterValue("next"), getterValue("return")], + type: VOID, + loc, + }, + loc, + }); + this.membersRegistered = true; + changed = true; + } for (const [method, property] of this.properties) for (const [key] of this.generators) { if (property.types.has(key)) continue; @@ -196,18 +282,26 @@ export class GeneratorDynamicDispatch { then: [ { kind: "return", - value: { - kind: "dynFrom", - value: { - kind: "closure", - fnName: `%dyn.generator.method.${method}`, - captures: [], - type: funcOf([DYN], DYN), - loc, - }, - type: DYN, - loc, - }, + value: HELPER_METHODS.has(method) + ? { + kind: "libCall", + fn: "dyn.iteratorHelperMethod", + args: [{ kind: "strLit", value: method, type: STRING, loc }], + type: DYN, + loc, + } + : { + kind: "dynFrom", + value: { + kind: "closure", + fnName: `%dyn.generator.method.${method}`, + captures: [], + type: funcOf([DYN], DYN), + loc, + }, + type: DYN, + loc, + }, loc, }, ], @@ -228,10 +322,14 @@ export class GeneratorDynamicDispatch { const supplied = dispatch.source.args.length > 0; const incoming = supplied ? varRef("p.1", DYN, loc) : dynUndefinedExpr(loc); const channel = mode === "next" ? type.nextT : type.retT; + // return() with no value completes with undefined, as the typed + // .return() does, whatever the return channel's type. const arg = mode === "throw" ? incoming - : channel.kind === "undefinedT" || channel.kind === "void" + : channel.kind === "undefinedT" || + channel.kind === "void" || + (mode === "return" && !supplied) ? null : lowerer.coerceToExpected(incoming, channel); const convertible = canConvertToDyn( @@ -263,25 +361,36 @@ export class GeneratorDynamicDispatch { else_: null, loc, }); + const resume = (sent: IrExpr | null): IrStmt => ({ + kind: "return", + value: { + kind: "dynFrom", + value: { + kind: "genResume", + mode, + gen: { kind: "dynCheck", value: receiver, type, loc }, + arg: sent, + type: resultType, + loc, + }, + type: DYN, + loc, + }, + loc, + }); + const valid = + convertible && (arg === null || mode === "throw" || typeEquals(arg.type, channel)); + if (valid && mode === "return" && arg !== null) + body.push({ + kind: "if", + cond: { kind: "dynTest", test: "undefined", value: incoming, type: BOOL, loc }, + then: [resume(null)], + else_: null, + loc, + }); body.push( - convertible && (arg === null || mode === "throw" || typeEquals(arg.type, channel)) - ? { - kind: "return", - value: { - kind: "dynFrom", - value: { - kind: "genResume", - mode, - gen: { kind: "dynCheck", value: receiver, type, loc }, - arg, - type: resultType, - loc, - }, - type: DYN, - loc, - }, - loc, - } + valid + ? resume(arg) : { kind: "runtimeFence", code: "SC1071", diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 128652bd2..3ec1d6c09 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -3132,6 +3132,7 @@ function rememberEvaluationError( export function buildMain(lowerer: Lowerer): IrFunction { const loc: SrcLoc = { file: lowerer.entry.fileName, start: 0, end: 0 }; const body: IrStmt[] = [ + ...lowerer.mainPrelude, ...cjsModuleRegistryPrelude(lowerer, loc), ...handleEventReaderPrelude(lowerer, loc), ]; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 3a0958ae6..43efebd16 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2396,7 +2396,7 @@ export function lowerBindingPattern( ) ? { kind: "libCall", - fn: "dyn.iterPack", + fn: "dyn.destructureRest", args: [srcRef(), { kind: "strLit", value: dynSpell ?? "", type: STRING, loc }], type: DYN, loc, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 1873ac361..be71498da 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -10604,6 +10604,10 @@ export class Lowerer { return lowerDefaultExport(this, stmt); } + /** Statements %main runs before any module initializer: runtime + * registrations the lowering passes add. */ + readonly mainPrelude: IrStmt[] = []; + buildMain(): IrFunction { return buildMain(this); } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index fc4c383ff..f20d0945e 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -569,6 +569,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.propertyIsEnumerableComputed", // the destructuring pack throws V8's TypeError on non-iterable dyn kinds "dyn.iterPack", + "dyn.destructureRest", "dyn.arrayFromIterator", "dyn.iterator", "dyn.iteratorResult", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 034d76917..df4c3209f 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -176,12 +176,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * not iterable (cannot read property Symbol(Symbol.iterator))"). In the * may-throw seed set. */ "dyn.iterPack": { argTypes: [DYN, STRING], result: DYN }, + "dyn.destructureRest": { argTypes: [DYN, STRING], result: DYN }, "dyn.arrayFromIterator": { argTypes: [DYN], result: DYN }, "dyn.iterator": { argTypes: [DYN, STRING], result: DYN }, "dyn.iteratorResult": { argTypes: [DYN], result: DYN }, "dyn.iteratorCanStep": { argTypes: [DYN, DYN], result: BOOL }, "dyn.iteratorStep": { argTypes: [DYN], result: DYN }, "dyn.iteratorStepDone": { argTypes: [DYN], result: BOOL }, + "dyn.setGeneratorMembers": { argTypes: [DYN, DYN], result: VOID }, + "dyn.iteratorHelperMethod": { argTypes: [STRING], result: DYN }, "dyn.collectionIteratorIndex": { argTypes: [DYN], result: F64 }, "dyn.mapSeedEntries": { argTypes: [DYN], result: DYN }, "dyn.mapSeedEntry": { argTypes: [DYN], result: DYN }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6077-generator-helpers-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6077-generator-helpers-untyped.js.json new file mode 100644 index 000000000..3d3c69f19 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6077-generator-helpers-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6077-generator-helpers-untyped.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6078-destructure-untyped-iterables.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6078-destructure-untyped-iterables.js.json new file mode 100644 index 000000000..6feb69d8e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6078-destructure-untyped-iterables.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6078-destructure-untyped-iterables.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6079-generator-helpers-any.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6079-generator-helpers-any.ts.json new file mode 100644 index 000000000..3d94dc336 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6079-generator-helpers-any.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6079-generator-helpers-any.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 1ab857e57..17b68712e 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -713,6 +713,22 @@ double scr_dyn_collection_iterator_index(const ScrDyn *iterator); * than 2 and 3) and the index its next step reads from. */ void scr_dyn_native_iterator_state(const ScrDyn *iterator, unsigned *kind, unsigned *selection, ScrMap **collection, double *index); +/* A compiled program that boxes generators and calls iterator helpers on + * untyped values registers the checked functions that read a generator + * capsule's next and return methods (each takes the capsule). The + * iterator helpers below then drive compiled generators. */ +void scr_dyn_set_generator_members(ScrDyn *next_getter, ScrDyn *return_getter); +/* An iterator helper method called on a synchronous generator capsule: + * true with *out set (+1, or NULL with the exception pending) when + * `method` is one. NULL until the program registers its generator + * members. */ +extern SCR_TL bool (*scr_dyn_generator_helper_hook)(ScrDyn *recv, const char *method, + ScrDyn *const *args, size_t argc, + ScrDyn **out); +/* The Iterator.prototype helper method `name` as a function value (+1), + * the value a compiled generator's helper property reads; undefined for + * other names. */ +ScrDyn *scr_dyn_iterator_helper_method(const ScrStr *name); /* The ordinary object snapshot plus a retained typed source. source_access * releases that source when materialize=false and returns a fresh snapshot * when true. */ @@ -857,6 +873,19 @@ void scr_dyn_arr_push_spread(ScrDyn *arr, const ScrDyn *src, const char *what); * compile-time source spelling), else the runtime kind wording. Borrows * both; NULL with the exception pending on the throw. */ ScrDyn *scr_dyn_iter_pack(const ScrDyn *src, const ScrStr *msg); +/* A rest element's pack (`const [a, ...rest] = d`): scr_dyn_iter_pack's + * kinds pack as there, and other iterables step through the iterator + * protocol to the end. +1, or NULL with the error pending. */ +ScrDyn *scr_dyn_destructure_rest(const ScrDyn *src, const ScrStr *msg); +/* Destructuring's first `n` values (`const [a, b] = d`) of a dyn source + * other than an array, string or bytes (the emitted walk packs those), + * through the iterator protocol: an iterator with values left closes. + * `spell` is the compile-time not-iterable wording, or NULL. +1, or NULL + * with the error pending. */ +ScrDyn *scr_dyn_iter_take(const ScrDyn *src, size_t n, const char *spell); +/* True for typed capsules that iterate through the protocol (a compiled + * synchronous generator) rather than through their materialized view. */ +bool scr_dyn_iterates_by_protocol(const ScrDyn *value); /* Map constructor seeds: retain array inputs for live iteration, accept * nullish inputs as empty, and validate each entry before reading 0/1. */ ScrDyn *scr_dyn_map_seed_entries(const ScrDyn *src); diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index a3085399f..752c19805 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -815,6 +815,10 @@ static ScrDyn *scr_dyn_invoke_impl( bool handled = false; ScrDyn *fast = dyn_live_array_method(recv, method, args, argc, what, &handled); if (handled) return fast; + /* Iterator helpers on a compiled generator. */ + if (scr_dyn_generator_helper_hook && + scr_dyn_generator_helper_hook(recv, method, args, argc, &fast)) + return fast; if (scr_event_ref_hooks && scr_event_ref_hooks->invoke(recv, method, args, argc, what, &fast)) return fast; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 525b6a192..6174913c0 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4704,6 +4704,19 @@ static int scr_ih_method_index(const char *name, size_t len); static ScrDyn *scr_iterator_helper_method(ScrDyn *self, int index, ScrDyn *const *args, size_t argc); static ScrDyn *scr_ih_method_function(int index); +static bool scr_dyn_sync_generator(const ScrDyn *value); +extern SCR_TL ScrDyn *(*scr_generator_member_hook)(const ScrDyn *, const char *, size_t); + +/* scr_iterator_read of an iterator's own next or return method: a + * compiled generator answers through the program's registered getters. */ +static ScrDyn *scr_iterator_member(const ScrDyn *iterator, const char *key, size_t len) { + if (scr_generator_member_hook && scr_dyn_sync_generator(iterator)) { + ScrDyn *member = scr_generator_member_hook(iterator, key, len); + if (member) return member; + } + return scr_iterator_read(iterator, key, len); +} + static ScrDyn *scr_native_iterator_get(void *ptr, const char *key, size_t len) { if (len != 4 || memcmp(key, "next", 4)) { /* The Iterator.prototype helper methods. */ @@ -4893,7 +4906,7 @@ static bool scr_ih_budget(const ScrDyn *limit, double *out) { * Node's TypeError. Answers false when an exception is pending after. */ static bool scr_ih_close(ScrDyn *iterator, bool abrupt) { ScrCaught *first = abrupt ? scr_exc_take() : NULL; - ScrDyn *method = scr_iterator_read(iterator, "return", 6); + ScrDyn *method = scr_iterator_member(iterator, "return", 6); bool ok = method != NULL; if (ok && method->kind != SCR_DYN_UNDEF && method->kind != SCR_DYN_NULL) { ScrDyn *result = scr_iterator_call(method, iterator, "return"); @@ -4960,7 +4973,7 @@ static ScrDyn *scr_ih_direct(const ScrDyn *self, const char *method) { scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); return NULL; } - return scr_iterator_read(self, "next", 4); + return scr_iterator_member(self, "next", 4); } static ScrDyn *scr_ih_result(ScrDyn *value, bool done) { @@ -5017,7 +5030,7 @@ static ScrDyn *scr_ih_flattenable(ScrDyn *value, ScrDyn **next_out) { } scr_dyn_release(method); if (!iterator) return NULL; - ScrDyn *next = scr_iterator_read(iterator, "next", 4); + ScrDyn *next = scr_iterator_member(iterator, "next", 4); if (!next) { scr_dyn_release(iterator); return NULL; @@ -13967,3 +13980,115 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c) { return scr_dyn_new_obj(); } } + +/* ── compiled generators through untyped values ─────────────────────── */ + +static SCR_TL ScrDyn *scr_generator_next_getter; +static SCR_TL ScrDyn *scr_generator_return_getter; +/* Set with the getters, so only programs that register them link the + * iterator helpers through these paths. External, so the compiler keeps + * the calls through them indirect. */ +SCR_TL ScrDyn *(*scr_generator_member_hook)(const ScrDyn *, const char *, size_t) = NULL; +SCR_TL bool (*scr_dyn_generator_helper_hook)(ScrDyn *, const char *, ScrDyn *const *, size_t, + ScrDyn **) = NULL; +static ScrDyn *scr_generator_member(const ScrDyn *capsule, const char *key, size_t len); +static bool scr_generator_helper_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, + size_t argc, ScrDyn **out); + +static void scr_generator_members_cleanup(void) { + scr_dyn_release(scr_generator_next_getter); + scr_dyn_release(scr_generator_return_getter); + scr_generator_next_getter = NULL; + scr_generator_return_getter = NULL; + scr_generator_member_hook = NULL; + scr_dyn_generator_helper_hook = NULL; +} + +void scr_dyn_set_generator_members(ScrDyn *next_getter, ScrDyn *return_getter) { + if (!scr_generator_next_getter) scr_atexit(scr_generator_members_cleanup); + scr_dyn_release(scr_generator_next_getter); + scr_dyn_release(scr_generator_return_getter); + scr_generator_next_getter = scr_dyn_retain(next_getter); + scr_generator_return_getter = scr_dyn_retain(return_getter); + scr_generator_member_hook = &scr_generator_member; + scr_dyn_generator_helper_hook = &scr_generator_helper_invoke; +} + +static bool scr_dyn_sync_generator(const ScrDyn *value) { + return scr_dyn_generator(value) && value->v.typed_ref.type_key[0] == 'g'; +} + +/* A synchronous generator capsule's next or return method (+1), read + * through the program's registered getters. NULL for other keys. */ +static ScrDyn *scr_generator_member(const ScrDyn *capsule, const char *key, size_t len) { + ScrDyn *getter = len == 4 && !memcmp(key, "next", 4) ? scr_generator_next_getter + : len == 6 && !memcmp(key, "return", 6) ? scr_generator_return_getter : NULL; + if (!getter) return NULL; + ScrDyn *args[1] = { (ScrDyn *)capsule }; + return scr_dyn_call(getter, args, 1, "next"); +} + +static bool scr_generator_helper_invoke(ScrDyn *recv, const char *method, ScrDyn *const *args, + size_t argc, ScrDyn **out) { + if (!scr_dyn_sync_generator(recv)) return false; + int index = scr_ih_method_index(method, strlen(method)); + if (index < 0) return false; + *out = scr_iterator_helper_method(recv, index, args, argc); + return true; +} + +ScrDyn *scr_dyn_iterator_helper_method(const ScrStr *name) { + int index = scr_ih_method_index(name->data, name->len); + return index >= 0 ? scr_ih_method_function(index) : scr_dyn_retain(scr_dyn_undefined()); +} + +/* Destructuring over an iterable the pack walk cannot list (a compiled + * generator, an iterator or iterator helper, a Map or Set): GetIterator, + * then the first `n` values through the iterator protocol, or every value + * when `all`, into a fresh array. Positions past the end read undefined, + * and an iterator with values left closes. `spell` is the compile-time + * not-iterable wording, or NULL. +1, or NULL with the error pending. */ +static ScrDyn *scr_dyn_iter_steps(const ScrDyn *src, const ScrStr *spell, size_t n, bool all) { + ScrDyn *iterator = scr_dyn_iterator(src, spell); + if (!iterator) return NULL; + ScrDyn *next = scr_iterator_member(iterator, "next", 4); + if (!next) { + scr_dyn_release(iterator); + return NULL; + } + ScrDyn *out = scr_dyn_new_arr(); + bool done = false; + for (size_t i = 0; all ? !done : i < n; i++) { + ScrDyn *value = done ? scr_dyn_retain(scr_dyn_undefined()) : scr_ih_step(iterator, next, &done); + if (!value) goto fail; + if (all && done) scr_dyn_release(value); + else scr_dyn_arr_push(out, value); + } + if (!done && !scr_ih_close(iterator, false)) goto fail; + scr_dyn_release(next); + scr_dyn_release(iterator); + return out; +fail: + scr_dyn_release(out); + scr_dyn_release(next); + scr_dyn_release(iterator); + return NULL; +} + +ScrDyn *scr_dyn_destructure_rest(const ScrDyn *src, const ScrStr *msg) { + bool packs = src->kind == SCR_DYN_ARR || src->kind == SCR_DYN_STR || src->kind == SCR_DYN_BYTES || + (src->kind == SCR_DYN_HANDLE && scr_dyn_handle_ops_of(src)->iter_pack) || + (src->kind == SCR_DYN_TYPED_REF && !scr_dyn_iterates_by_protocol(src)); + return packs ? scr_dyn_iter_pack(src, msg) : scr_dyn_iter_steps(src, msg, 0, true); +} + +ScrDyn *scr_dyn_iter_take(const ScrDyn *src, size_t n, const char *spell) { + ScrStr *spelling = spell && *spell ? scr_str_new(spell, strlen(spell)) : NULL; + ScrDyn *out = scr_dyn_iter_steps(src, spelling, n, false); + scr_str_release(spelling); + return out; +} + +bool scr_dyn_iterates_by_protocol(const ScrDyn *value) { + return scr_dyn_sync_generator(value); +} diff --git a/tests/corpus/6077-generator-helpers-untyped.js b/tests/corpus/6077-generator-helpers-untyped.js new file mode 100644 index 000000000..381595d9d --- /dev/null +++ b/tests/corpus/6077-generator-helpers-untyped.js @@ -0,0 +1,81 @@ +// Iterator helpers on compiled generators that reach untyped parameters: +// the runtime's helpers step the generator through its own next() and +// close it through return(). +const log = []; +const flush = () => log.splice(0).join(" "); +function* count(n) { + try { + for (let i = 1; i <= n; i++) { + log.push("y" + i); + yield i; + } + return "done"; + } finally { + log.push("closed"); + } +} +function twice(it) { return it.map((x) => x * 2).toArray(); } +console.log(twice(count(3)), flush()); +function firstOver(it, k) { return it.find((x) => x > k); } +console.log(firstOver(count(9), 3), flush()); +function stepper(it) { + const h = it.filter((x) => x % 2 === 1); + return [h.next(), h.next(), h.return(), h.next()]; +} +console.log(stepper(count(9)), flush()); +function sum(it) { return it.reduce((a, b) => a + b, 0); } +console.log(sum(count(4)), flush()); +function product(it) { return it.reduce((a, b) => a * b); } +console.log(product(count(4)), flush()); +function lazy(it) { return it.drop(1).take(2); } +console.log([...lazy(count(9))], flush()); +function each(it) { const out = []; it.forEach((x, i) => out.push(x + ":" + i)); return out; } +console.log(each(count(3)), flush()); +function some(it) { return it.some((x) => x === 2); } +console.log(some(count(5)), flush()); +function every(it) { return it.every((x) => x < 3); } +console.log(every(count(5)), flush()); +function flat(it) { return it.flatMap((x) => [x, -x]).toArray(); } +console.log(flat(count(2)), flush()); +function chain(it) { return it.map((x) => x + 1).filter((x) => x % 2 === 0).map(String).toArray(); } +console.log(chain(count(6)), flush()); +function loop(it) { + const out = []; + for (const x of it.map((x) => x * 10)) { + out.push(x); + if (x >= 20) break; + } + return out; +} +console.log(loop(count(5)), flush()); +function from(it) { return Array.from(it.take(2)); } +console.log(from(count(5)), flush()); +function zeroTake(it) { return it.take(0).toArray(); } +console.log(zeroTake(count(5)), flush()); +function after(it) { const a = it.take(1).toArray(); return [a, it.next()]; } +console.log(after(count(3)), flush()); +function failing(it) { + try { + it.map((x) => { if (x === 2) throw new Error("boom " + x); return x; }).toArray(); + } catch (e) { + return e.message; + } +} +console.log(failing(count(5)), flush()); +function errorOf(run) { try { run(); } catch (e) { return e.name + ": " + e.message; } } +function check(it) { + return [ + errorOf(() => it.map(42)), + errorOf(() => it.take(-1)), + errorOf(() => it.reduce((a, b) => a + b)), + ]; +} +console.log(check(count(0)), flush()); +function kinds(it) { return [typeof it.map, typeof it.toArray, typeof it.next]; } +console.log(kinds(count(1))); +function readNext(it) { const next = it.next; return [next.call(it), next.call(it)]; } +console.log(readNext(count(5)), flush()); +function first(it) { for (const x of it) return x; } +console.log(first(count(3)), flush()); +function closeEarly(it) { it.next(); return it.return(); } +console.log(closeEarly(count(3)), flush()); diff --git a/tests/corpus/6078-destructure-untyped-iterables.js b/tests/corpus/6078-destructure-untyped-iterables.js new file mode 100644 index 000000000..cf8435cba --- /dev/null +++ b/tests/corpus/6078-destructure-untyped-iterables.js @@ -0,0 +1,60 @@ +// Array destructuring of untyped iterables: Map and Set objects, their +// iterators, compiled generators and objects with a Symbol.iterator method +// step through the iterator protocol, and an iterator with values left +// closes. +const m = new Map([["a", 1], ["b", 2], ["c", 3]]); +function two(it) { const [x, y] = it; return [x, y]; } +console.log(two(m), two(new Set([7, 8, 9])), two(m.keys()), two("hey"), two([1])); +function rest(it) { const [x, ...more] = it; return [x, more]; } +console.log(rest(m.values()), rest(new Set([1, 2, 3])), rest(m.entries())); +function* g() { + try { + yield 1; + yield 2; + yield 3; + } finally { + console.log("finally"); + } +} +console.log(two(g()), rest(g())); +function holes(it) { const [, b, , d] = it; return [b, d]; } +console.log(holes(g())); +function none(it) { const [] = it; return "none"; } +console.log(none(g())); +function* short() { yield "only"; } +console.log(two(short())); +const custom = { + [Symbol.iterator]() { + let i = 0; + return { + next: () => ({ value: i++, done: i > 5 }), + return() { + console.log("return called"); + return {}; + }, + }; + }, +}; +console.log(two(custom)); +const helper = [1, 2, 3, 4].values().map((x) => x * 3); +console.log(two(helper), [...helper]); +for (const bad of [1, null, undefined, {}, true]) { + try { + two(bad); + } catch (e) { + console.log(e.name, e.message); + } +} +const throwing = { + [Symbol.iterator]() { + return { + next() { throw new Error("next failed"); }, + return() { console.log("not called"); return {}; }, + }; + }, +}; +try { + two(throwing); +} catch (e) { + console.log(e.message); +} diff --git a/tests/corpus/6079-generator-helpers-any.ts b/tests/corpus/6079-generator-helpers-any.ts new file mode 100644 index 000000000..f72fd55ef --- /dev/null +++ b/tests/corpus/6079-generator-helpers-any.ts @@ -0,0 +1,24 @@ +// Iterator helpers on generators passed through `any` and `unknown`. +function* letters(): Generator { + yield "a"; + yield "b"; + yield "c"; +} +function upper(it: any): string[] { + return it.map((s: string) => s.toUpperCase()).toArray(); +} +console.log(upper(letters())); +const boxed: unknown = letters(); +console.log((boxed as any).filter((s: string) => s !== "b").toArray()); +const held: any[] = [letters(), letters()]; +console.log(held.map((h) => h.drop(1).toArray())); +function* numbered(): Generator { + yield 1; + yield 2; + return "end"; +} +function firstTwo(it: any): number[] { + return it.take(2).toArray(); +} +const later: any = numbered(); +console.log(firstTwo(numbered()), later.some((n: number) => n > 1)); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 62703f6a4..1cb2f1a14 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2252,6 +2252,9 @@ "6074-stored-collection-helpers-js.js": 4100, "6075-print-iterators.ts": 3800, "6076-print-iterators-js.js": 3500, + "6077-generator-helpers-untyped.js": 2550, + "6078-destructure-untyped-iterables.js": 2500, + "6079-generator-helpers-any.ts": 2550, "6100-record-get-or-create.ts": 3771, "6101-record-get-or-create-unchecked/main.ts": 2107, "6103-typeof-builtin-bindings.cjs": 2202, @@ -5341,6 +5344,9 @@ "6074-stored-collection-helpers-js.js": 3600, "6075-print-iterators.ts": 3450, "6076-print-iterators-js.js": 3300, + "6077-generator-helpers-untyped.js": 3050, + "6078-destructure-untyped-iterables.js": 2550, + "6079-generator-helpers-any.ts": 2700, "6100-record-get-or-create.ts": 3173, "6101-record-get-or-create-unchecked/main.ts": 3228, "6103-typeof-builtin-bindings.cjs": 1796, From 60084fcf4874302af536c723c1aac24f286d7a08 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 06:14:15 -0500 Subject: [PATCH 3/6] Test the common IR node kinds first in child traversals everyExprChild, everyStmtChild, mapExprChildren and mapStmtChildren switch over the node kind: 96 expression and 26 statement string cases, which the engine tests one after another in source order. Every analysis and rewrite walks the IR through them, and some of the most common kinds were near the end: in the three.js nodes module, dynFrom, libCall and dynCheck make up 27% of expression nodes and were the 64th, 94th and 96th cases. The cases for the most common kinds now come first, in the order of how often they occur in the three.js nodes and Effect fixtures. All other cases keep their order, and no case changes. In those fixtures, seven expression kinds make up 89% of expression nodes and four statement kinds make up 88% of statements. A full child walk of the three.js nodes module takes about 108 ns per node instead of 154 ns. Compiling it to LLVM text takes 161 s instead of 180 s (median of five interleaved runs), and the Effect schema fixture 64 s instead of 69 s. The emitted IR is unchanged. --- packages/compiler/src/ir/traverse.ts | 196 ++++++++++++++------------- 1 file changed, 102 insertions(+), 94 deletions(-) diff --git a/packages/compiler/src/ir/traverse.ts b/packages/compiler/src/ir/traverse.ts index 1ef70bdb2..6375eb39b 100644 --- a/packages/compiler/src/ir/traverse.ts +++ b/packages/compiler/src/ir/traverse.ts @@ -127,21 +127,54 @@ export function everyExprChild( expr: (expr: IrExpr) => boolean, stmt: (stmt: IrStmt) => boolean, ): boolean { + // The most common node kinds come first: the engine tests string cases + // one after another, in source order. switch (node.kind) { - case "numLit": + case "varRef": return true; + case "call": + return node.args.every((child) => expr(child)); + case "dynFrom": + return expr(node.value); case "strLit": return true; - case "moduleNsRef": + case "libCall": + return node.args.every((child) => expr(child)); + case "dynCheck": + return expr(node.value); + case "strEq": + return expr(node.left) && expr(node.right); + case "fieldGet": + return expr(node.obj); + case "ternary": + return expr(node.cond) && expr(node.then) && expr(node.else_); + case "closure": return true; case "boolLit": return true; + case "numLit": + return true; case "unitLit": return true; - case "varRef": - return true; + case "seqExpr": + return node.stmts.every((child) => stmt(child)) && expr(node.result); + case "dynCall": + return ( + expr(node.callee) && + (node.receiver === undefined || expr(node.receiver)) && + (node.calleeNameValue === undefined || expr(node.calleeNameValue)) && + node.args.every((child) => expr(child)) + ); + case "dynTest": + return expr(node.value); + case "dynScalarEq": + return expr(node.left) && expr(node.right); + case "upcast": + return expr(node.value); case "bin": return expr(node.left) && expr(node.right); + case "moduleNsRef": + return true; case "unary": return expr(node.operand); case "incDec": @@ -156,14 +189,10 @@ export function everyExprChild( return expr(node.left) && expr(node.right); case "strConcat": return expr(node.left) && expr(node.right); - case "strEq": - return expr(node.left) && expr(node.right); case "strCmp": return expr(node.left) && expr(node.right); case "toString": return expr(node.operand); - case "ternary": - return expr(node.cond) && expr(node.then) && expr(node.else_); case "nullish": return expr(node.left) && expr(node.right); case "orDefault": @@ -206,12 +235,8 @@ export function everyExprChild( return node.seed === undefined || expr(node.seed); case "setIntrinsic": return expr(node.receiver) && node.args.every((child) => expr(child)); - case "call": - return node.args.every((child) => expr(child)); case "ffiCall": return node.args.every((child) => expr(child)); - case "closure": - return true; case "callValue": return ( expr(node.callee) && @@ -240,8 +265,6 @@ export function everyExprChild( return expr(node.callee) && node.args.every((child) => expr(child)); case "instanceOfValue": return expr(node.value) && expr(node.classValue); - case "upcast": - return expr(node.value); case "promiseVoidWiden": return expr(node.value); case "downcast": @@ -250,8 +273,6 @@ export function everyExprChild( return expr(node.value); case "virtualCall": return node.args.every((child) => expr(child)); - case "fieldGet": - return expr(node.obj); case "recordLit": return node.fields.every((child) => expr(child.value)); case "recordClone": @@ -260,15 +281,6 @@ export function everyExprChild( return expr(node.obj); case "recordKeyGet": return expr(node.obj) && expr(node.key); - case "dynFrom": - return expr(node.value); - case "dynCall": - return ( - expr(node.callee) && - (node.receiver === undefined || expr(node.receiver)) && - (node.calleeNameValue === undefined || expr(node.calleeNameValue)) && - node.args.every((child) => expr(child)) - ); case "dynInvoke": return ( expr(node.recv) && @@ -282,16 +294,10 @@ export function everyExprChild( node.fields === undefined || node.fields.every((child) => expr(child.key) && expr(child.value)) ); - case "dynTest": - return expr(node.value); case "dynKeyGet": return expr(node.key) && expr(node.value); case "dynHasKey": return expr(node.value); - case "dynScalarEq": - return expr(node.left) && expr(node.right); - case "seqExpr": - return node.stmts.every((child) => stmt(child)) && expr(node.result); case "dynDestrCheck": return expr(node.value); case "dynIterN": @@ -332,12 +338,8 @@ export function everyExprChild( return expr(node.left) && expr(node.right); case "intrinsic": return node.args.every((child) => expr(child)); - case "libCall": - return node.args.every((child) => expr(child)); case "jsonStringify": return expr(node.value); - case "dynCheck": - return expr(node.value); } node satisfies never; throw new Error("unhandled IR node"); @@ -349,19 +351,27 @@ export function everyStmtChild( expr: (expr: IrExpr) => boolean, stmt: (stmt: IrStmt) => boolean, ): boolean { + // The most common node kinds come first: the engine tests string cases + // one after another, in source order. switch (node.kind) { - case "varDecl": - return node.init === null || expr(node.init); - case "assign": - return expr(node.value); - case "exprStmt": - return expr(node.expr); + case "return": + return node.value === null || expr(node.value); case "if": return ( expr(node.cond) && node.then.every((child) => stmt(child)) && (node.else_ === null || node.else_.every((child) => stmt(child))) ); + case "varDecl": + return node.init === null || expr(node.init); + case "exprStmt": + return expr(node.expr); + case "assign": + return expr(node.value); + case "block": + return node.body.every((child) => stmt(child)); + case "fieldSet": + return expr(node.obj) && expr(node.value); case "while": return expr(node.cond) && node.body.every((child) => stmt(child)); case "doWhile": @@ -393,10 +403,6 @@ export function everyStmtChild( return expr(node.arr) && expr(node.index) && expr(node.value); case "forOf": return expr(node.iterable) && node.body.every((child) => stmt(child)); - case "return": - return node.value === null || expr(node.value); - case "fieldSet": - return expr(node.obj) && expr(node.value); case "recordSet": return expr(node.obj) && expr(node.value); case "recordKeySet": @@ -407,8 +413,6 @@ export function everyStmtChild( return true; case "continue": return true; - case "block": - return node.body.every((child) => stmt(child)); case "throw": return expr(node.value); case "runtimeFence": @@ -432,21 +436,54 @@ export function mapExprChildren( expr: (expr: IrExpr) => IrExpr, stmt: (stmt: IrStmt) => IrStmt, ): IrExpr { + // The most common node kinds come first: the engine tests string cases + // one after another, in source order. switch (node.kind) { - case "numLit": + case "varRef": return node; + case "call": + return { ...node, args: node.args.map((child) => expr(child)) }; + case "dynFrom": + return { ...node, value: expr(node.value) }; case "strLit": return node; - case "moduleNsRef": + case "libCall": + return { ...node, args: node.args.map((child) => expr(child)) }; + case "dynCheck": + return { ...node, value: expr(node.value) }; + case "strEq": + return { ...node, left: expr(node.left), right: expr(node.right) }; + case "fieldGet": + return { ...node, obj: expr(node.obj) }; + case "ternary": + return { ...node, cond: expr(node.cond), then: expr(node.then), else_: expr(node.else_) }; + case "closure": return node; case "boolLit": return node; + case "numLit": + return node; case "unitLit": return node; - case "varRef": - return node; + case "seqExpr": + return { ...node, stmts: node.stmts.map((child) => stmt(child)), result: expr(node.result) }; + case "dynCall": { + const result: Extract = { ...node, callee: expr(node.callee) }; + if (node.receiver !== undefined) result.receiver = expr(node.receiver); + if (node.calleeNameValue !== undefined) result.calleeNameValue = expr(node.calleeNameValue); + result.args = node.args.map((child) => expr(child)); + return result; + } + case "dynTest": + return { ...node, value: expr(node.value) }; + case "dynScalarEq": + return { ...node, left: expr(node.left), right: expr(node.right) }; + case "upcast": + return { ...node, value: expr(node.value) }; case "bin": return { ...node, left: expr(node.left), right: expr(node.right) }; + case "moduleNsRef": + return node; case "unary": return { ...node, operand: expr(node.operand) }; case "incDec": @@ -461,14 +498,10 @@ export function mapExprChildren( return { ...node, left: expr(node.left), right: expr(node.right) }; case "strConcat": return { ...node, left: expr(node.left), right: expr(node.right) }; - case "strEq": - return { ...node, left: expr(node.left), right: expr(node.right) }; case "strCmp": return { ...node, left: expr(node.left), right: expr(node.right) }; case "toString": return { ...node, operand: expr(node.operand) }; - case "ternary": - return { ...node, cond: expr(node.cond), then: expr(node.then), else_: expr(node.else_) }; case "nullish": return { ...node, left: expr(node.left), right: expr(node.right) }; case "orDefault": @@ -542,12 +575,8 @@ export function mapExprChildren( receiver: expr(node.receiver), args: node.args.map((child) => expr(child)), }; - case "call": - return { ...node, args: node.args.map((child) => expr(child)) }; case "ffiCall": return { ...node, args: node.args.map((child) => expr(child)) }; - case "closure": - return node; case "callValue": { const callee = expr(node.callee); if (node.receiver === undefined) @@ -581,8 +610,6 @@ export function mapExprChildren( return { ...node, callee: expr(node.callee), args: node.args.map((child) => expr(child)) }; case "instanceOfValue": return { ...node, value: expr(node.value), classValue: expr(node.classValue) }; - case "upcast": - return { ...node, value: expr(node.value) }; case "promiseVoidWiden": return { ...node, value: expr(node.value) }; case "downcast": @@ -591,8 +618,6 @@ export function mapExprChildren( return { ...node, value: expr(node.value) }; case "virtualCall": return { ...node, args: node.args.map((child) => expr(child)) }; - case "fieldGet": - return { ...node, obj: expr(node.obj) }; case "recordLit": return { ...node, @@ -608,15 +633,6 @@ export function mapExprChildren( return { ...node, obj: expr(node.obj) }; case "recordKeyGet": return { ...node, obj: expr(node.obj), key: expr(node.key) }; - case "dynFrom": - return { ...node, value: expr(node.value) }; - case "dynCall": { - const result: Extract = { ...node, callee: expr(node.callee) }; - if (node.receiver !== undefined) result.receiver = expr(node.receiver); - if (node.calleeNameValue !== undefined) result.calleeNameValue = expr(node.calleeNameValue); - result.args = node.args.map((child) => expr(child)); - return result; - } case "dynInvoke": { const result: Extract = { ...node, recv: expr(node.recv) }; if (node.calleeNameValue !== undefined) result.calleeNameValue = expr(node.calleeNameValue); @@ -636,16 +652,10 @@ export function mapExprChildren( value: expr(child.value), })), }; - case "dynTest": - return { ...node, value: expr(node.value) }; case "dynKeyGet": return { ...node, key: expr(node.key), value: expr(node.value) }; case "dynHasKey": return { ...node, value: expr(node.value) }; - case "dynScalarEq": - return { ...node, left: expr(node.left), right: expr(node.right) }; - case "seqExpr": - return { ...node, stmts: node.stmts.map((child) => stmt(child)), result: expr(node.result) }; case "dynDestrCheck": return { ...node, value: expr(node.value) }; case "dynIterN": @@ -686,12 +696,8 @@ export function mapExprChildren( return { ...node, left: expr(node.left), right: expr(node.right) }; case "intrinsic": return { ...node, args: node.args.map((child) => expr(child)) }; - case "libCall": - return { ...node, args: node.args.map((child) => expr(child)) }; case "jsonStringify": return { ...node, value: expr(node.value) }; - case "dynCheck": - return { ...node, value: expr(node.value) }; } node satisfies never; throw new Error("unhandled IR node"); @@ -703,13 +709,11 @@ export function mapStmtChildren( expr: (expr: IrExpr) => IrExpr, stmt: (stmt: IrStmt) => IrStmt, ): IrStmt { + // The most common node kinds come first: the engine tests string cases + // one after another, in source order. switch (node.kind) { - case "varDecl": - return { ...node, init: node.init === null ? null : expr(node.init) }; - case "assign": - return { ...node, value: expr(node.value) }; - case "exprStmt": - return { ...node, expr: expr(node.expr) }; + case "return": + return { ...node, value: node.value === null ? null : expr(node.value) }; case "if": return { ...node, @@ -717,6 +721,16 @@ export function mapStmtChildren( then: node.then.map((child) => stmt(child)), else_: node.else_ === null ? null : node.else_.map((child) => stmt(child)), }; + case "varDecl": + return { ...node, init: node.init === null ? null : expr(node.init) }; + case "exprStmt": + return { ...node, expr: expr(node.expr) }; + case "assign": + return { ...node, value: expr(node.value) }; + case "block": + return { ...node, body: node.body.map((child) => stmt(child)) }; + case "fieldSet": + return { ...node, obj: expr(node.obj), value: expr(node.value) }; case "while": return { ...node, cond: expr(node.cond), body: node.body.map((child) => stmt(child)) }; case "doWhile": @@ -755,10 +769,6 @@ export function mapStmtChildren( iterable: expr(node.iterable), body: node.body.map((child) => stmt(child)), }; - case "return": - return { ...node, value: node.value === null ? null : expr(node.value) }; - case "fieldSet": - return { ...node, obj: expr(node.obj), value: expr(node.value) }; case "recordSet": return { ...node, obj: expr(node.obj), value: expr(node.value) }; case "recordKeySet": @@ -769,8 +779,6 @@ export function mapStmtChildren( return node; case "continue": return node; - case "block": - return { ...node, body: node.body.map((child) => stmt(child)) }; case "throw": return { ...node, value: expr(node.value) }; case "runtimeFence": From c836f939986f0cf04854b69020b18461a7454d8f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 06:18:17 -0500 Subject: [PATCH 4/6] Copy repeated childless IR nodes with a spread unshareNodes gives every occurrence of a repeated node its own object. Nodes with children are copied by mapExprChildren and mapStmtChildren, which spread the node and keep its type and location objects. Childless nodes were copied by reparseExpr and reparseStmt, which stringify the node and parse the text, copying the type and location objects as well; on the three.js nodes fixture that took about 6 s. Childless nodes are now spread too. Analyses key their facts by node identity, which each copy still has of its own. The type and location objects are shared as they already are in every copied node with children and in every function without a repeated node. The emitted IR is unchanged for every corpus program. unshareNodes takes about 2 s on the three.js nodes fixture instead of 8 s, and compiling it to LLVM text takes 171 s instead of 178 s (median of five interleaved runs). --- packages/compiler/src/ir/unshare.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/ir/unshare.ts b/packages/compiler/src/ir/unshare.ts index 36dd6176f..df697baa9 100644 --- a/packages/compiler/src/ir/unshare.ts +++ b/packages/compiler/src/ir/unshare.ts @@ -1,5 +1,4 @@ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; -import { reparseExpr, reparseStmt } from "./serialize.js"; import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js"; /** Give every occurrence of an executable node its own object. @@ -42,14 +41,15 @@ export function unshareNodes(mod: IrModule): IrModule { } if (affected.size === 0) return mod; // Rebuilding a function copies every node that has children; a repeated - // childless node reads back as a fresh object at each of its sites there. + // childless node gets its own shallow copy at each of its sites there. + // Both kinds of copy keep sharing the type and location objects. const expr = (node: IrExpr): IrExpr => { const copy = mapExprChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : reparseExpr(node); + return copy !== node || !repeated.has(node) ? copy : { ...node }; }; const stmt = (node: IrStmt): IrStmt => { const copy = mapStmtChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : reparseStmt(node); + return copy !== node || !repeated.has(node) ? copy : { ...node }; }; return { ...mod, From c6e16d365b35dfb02f32ea1b22ed68b66ac6e2df Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 07:56:11 -0500 Subject: [PATCH 5/6] Read getTestContext's arguments through their nodes lowerGetTestContext (#1393) read expr.arguments as an array. Since #1394 an AST list's elements are in nodes, so the compiler did not type-check. --- packages/compiler/src/frontend/lowering/lower-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index 5fc333abc..5c6011425 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -685,9 +685,9 @@ export function lowerNodeTestModuleCall( * and code reached only through timer, immediate, nextTick and * queueMicrotask callbacks). */ function lowerGetTestContext(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { - if (expr.arguments.length > 0) { + if (expr.arguments.nodes.length > 0) { lowerer.noLowering( - `getTestContext with ${expr.arguments.length} arguments`, + `getTestContext with ${expr.arguments.nodes.length} arguments`, expr, "the supported form is getTestContext()", ); From 0abf9a639513e0a89b2b50b59bc21d3191658a16 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 19 Oct 2026 08:34:08 -0500 Subject: [PATCH 6/6] Revert "Copy repeated childless IR nodes with a spread" This reverts commit c836f939986f0cf04854b69020b18461a7454d8f. The bootstrap seed build cannot compile the spread copies (SC1090). --- packages/compiler/src/ir/unshare.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/ir/unshare.ts b/packages/compiler/src/ir/unshare.ts index df697baa9..36dd6176f 100644 --- a/packages/compiler/src/ir/unshare.ts +++ b/packages/compiler/src/ir/unshare.ts @@ -1,4 +1,5 @@ import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { reparseExpr, reparseStmt } from "./serialize.js"; import { everyExprChild, everyStmtChild, mapExprChildren, mapStmtChildren } from "./traverse.js"; /** Give every occurrence of an executable node its own object. @@ -41,15 +42,14 @@ export function unshareNodes(mod: IrModule): IrModule { } if (affected.size === 0) return mod; // Rebuilding a function copies every node that has children; a repeated - // childless node gets its own shallow copy at each of its sites there. - // Both kinds of copy keep sharing the type and location objects. + // childless node reads back as a fresh object at each of its sites there. const expr = (node: IrExpr): IrExpr => { const copy = mapExprChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : { ...node }; + return copy !== node || !repeated.has(node) ? copy : reparseExpr(node); }; const stmt = (node: IrStmt): IrStmt => { const copy = mapStmtChildren(node, expr, stmt); - return copy !== node || !repeated.has(node) ? copy : { ...node }; + return copy !== node || !repeated.has(node) ? copy : reparseStmt(node); }; return { ...mod,