From 3041df387d772511f0e8dbb04ca9aa4d20dd81f3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 17:43:57 -0700 Subject: [PATCH 1/3] Spread untyped iterables into Math.min and Math.max Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-stdlib.ts | 19 ++++++++++ packages/compiler/src/ir/builtin-effects.ts | 2 ++ .../compiler/src/ir/builtin-signatures.ts | 1 + .../corpus/7488-math-spread-untyped.js.json | 6 ++++ packages/runtime/src/scr_dyn.h | 3 ++ packages/runtime/src/scr_json.c | 35 +++++++++++++++++++ tests/corpus/7488-math-spread-untyped.js | 33 +++++++++++++++++ .../7488-math-spread-untyped.js.json | 1 + 9 files changed, 101 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7488-math-spread-untyped.js.json create mode 100644 tests/corpus/7488-math-spread-untyped.js create mode 100644 tests/harness/corpus-costs/7488-math-spread-untyped.js.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8e7c74cad..488b81d57 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -973,6 +973,7 @@ 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.mathFold": "scr_dyn_math_fold", "dyn.destructureRest": "scr_dyn_destructure_rest", "dyn.iterator": "scr_dyn_iterator", "dyn.asyncIteratorOpen": "scr_dyn_async_iterator_open", diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 0beb24a0e..e671a0a08 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2496,6 +2496,25 @@ export function lowerStdlibMethodCall( // method, drains into the number[] the fold reads. src = classGeneratorIterable(lowerer, src) ?? src; if (src.type.kind === "generator") src = generatorDrain(lowerer, src, F64, loc) ?? src; + // An untyped iterable (`Math.min(...caps.map(f))` in JavaScript): the + // runtime packs its values and folds their ToNumber like Node. V8 names + // a null or undefined source by its text, calls as `f(...)`. + if (src.type.kind === "dyn") { + const what = ts.isCallExpression(spread.expression) + ? `${ts.getTextOfNode(spread.expression.expression)}(...)` + : ts.getTextOfNode(spread.expression); + return { + kind: "libCall", + fn: "dyn.mathFold", + args: [ + src, + { kind: "boolLit", value: name === "max", type: BOOL, loc }, + { kind: "strLit", value: what, type: STRING, loc }, + ], + type: F64, + loc, + }; + } if (src.type.kind !== "array" || src.type.elem.kind !== "f64") { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 3dc6dcebd..d8e78be08 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -671,6 +671,8 @@ 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", + // ToNumber of each element can run valueOf + "dyn.mathFold", "dyn.destructureRest", "dyn.arrayFromIterator", "dyn.iterator", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f5da56818..9dbcc1514 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -219,6 +219,7 @@ 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.mathFold": { argTypes: [DYN, BOOL, STRING], result: F64 }, "dyn.destructureRest": { argTypes: [DYN, STRING], result: DYN }, "dyn.arrayFromIterator": { argTypes: [DYN], result: DYN }, "dyn.iterator": { argTypes: [DYN, STRING], result: DYN }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7488-math-spread-untyped.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7488-math-spread-untyped.js.json new file mode 100644 index 000000000..4d1376a12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7488-math-spread-untyped.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7488-math-spread-untyped.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 28c2accce..100dd2295 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1273,6 +1273,9 @@ void scr_error_set_prop(ScrError *e, const char *key, size_t key_len, ScrDyn *va ScrDyn *scr_errdyn_view_of(const ScrError *e); /* borrowed or NULL */ /* ToBoolean over the dyn kind (JS-exact); borrowed, never throws. */ bool scr_dyn_truthy(const ScrDyn *d); +/* Math.max/Math.min(...src) over an untyped iterable; `what` names the + * source in V8's null/undefined spread TypeError. NaN with it pending. */ +double scr_dyn_math_fold(const ScrDyn *src, bool max, const ScrStr *what); /* JS String() over a dyn value (arrays join, [object Object], the error * encoding renders "name: message"); borrowed, result +1, never throws. */ ScrStr *scr_dyn_display(const ScrDyn *d); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 324233028..9abb7796b 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1557,6 +1557,41 @@ static ScrDyn *scr_dyn_not_iterable(const ScrDyn *src, const ScrStr *msg) { return NULL; } +/* Math.max(...src) / Math.min(...src) over an untyped iterable: every + * value's ToNumber runs in order, then NaN poisons and ±0 orders as in + * Node. A non-iterable source throws V8's call-spread TypeError: `what` + * names the source for null and undefined, every other kind gets the + * Symbol.iterator wording. NaN with the exception pending when packing or + * a coercion throws. */ +double scr_dyn_math_fold(const ScrDyn *src, bool max, const ScrStr *what) { + if (src->kind == SCR_DYN_UNDEF || src->kind == SCR_DYN_NULL) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, what->data, what->len); + scr_jb_puts(&b, src->kind == SCR_DYN_NULL ? " is not iterable (cannot read property null)" + : " is not iterable (cannot read property undefined)"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return NAN; + } + static const char spread_msg[] = "Spread syntax requires ...iterable[Symbol.iterator] to be a function"; + ScrStr *msg = scr_str_new(spread_msg, sizeof spread_msg - 1); + ScrDyn *pack = scr_dyn_iter_pack(src, msg); + scr_str_release(msg); + if (!pack) return NAN; + double best = max ? -INFINITY : INFINITY; + bool nan = false; + for (size_t i = 0; i < pack->v.arr.len; i++) { + double v = scr_dyn_number_coerce(pack->v.arr.items[i]); + if (scr_exc_pending()) break; + if (isnan(v)) nan = true; + else if (max ? (v > best || (v == 0.0 && best == 0.0 && !signbit(v))) + : (v < best || (v == 0.0 && best == 0.0 && signbit(v)))) + best = v; + } + scr_dyn_release(pack); + return nan ? NAN : best; +} + ScrDyn *scr_dyn_iter_pack(const ScrDyn *src, const ScrStr *msg) { if (src->kind == SCR_DYN_HANDLE && scr_dyn_handle_ops_of(src)->iter_pack) return scr_dyn_handle_ops_of(src)->iter_pack(src->v.handle.ptr); diff --git a/tests/corpus/7488-math-spread-untyped.js b/tests/corpus/7488-math-spread-untyped.js new file mode 100644 index 000000000..a30ece320 --- /dev/null +++ b/tests/corpus/7488-math-spread-untyped.js @@ -0,0 +1,33 @@ +// Math.max/Math.min spreading an untyped iterable (zod's +// `Math.min(...caps.map((c) => c[0]))`). +function caps(list) { + return list.map((c) => c[0]); +} +console.log(Math.min(...caps([[5], [2], [9]])), Math.max(...caps([[5], [2], [9]]))); +console.log(Math.min(...caps([])), Math.max(...caps([]))); +console.log(Math.min(...caps([["3"], [true], [null]])), Math.max(...caps([["7"], [undefined]]))); +console.log(Object.is(Math.max(...caps([[-0], [0]])), 0), Object.is(Math.min(...caps([[0], [-0]])), -0)); +const order = []; +const valued = (n) => ({ valueOf() { order.push(n); return n; } }); +console.log(Math.max(...caps([[valued(1)], [NaN], [valued(3)]])), order.join(",")); +try { + Math.min(...caps([[{ valueOf() { throw new Error("boom"); } }]])); +} catch (e) { + console.log(e.message); +} +const sources = { none: undefined, nil: null, num: 5, obj: {}, str: "48" }; +function pick(k) { + return sources[k]; +} +for (const k of Object.keys(sources)) { + try { + console.log(Math.max(...sources[k])); + } catch (e) { + console.log(e.constructor.name, e.message); + } + try { + console.log(Math.min(...pick(k))); + } catch (e) { + console.log(e.constructor.name, e.message); + } +} diff --git a/tests/harness/corpus-costs/7488-math-spread-untyped.js.json b/tests/harness/corpus-costs/7488-math-spread-untyped.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7488-math-spread-untyped.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } From 12166956b8e1d3e6fc752be5585832fb72ab700e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:57:29 -0500 Subject: [PATCH 2/3] Spread protocol iterables into Math.min and Math.max Math.min(...src) and Math.max(...src) over an untyped source now take its values the way a spread does: arrays, strings, bytes and collections pack, a native iterator (a Map's keys()) drains, and other iterables (a generator, an object with a Symbol.iterator method) step their iterator to the end. Before, only the packing kinds were accepted and every other iterable threw the spread TypeError. --- packages/runtime/src/scr_json.c | 11 +++++++- tests/corpus/7488-math-spread-untyped.js | 36 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9abb7796b..c6ff65d59 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1557,6 +1557,8 @@ static ScrDyn *scr_dyn_not_iterable(const ScrDyn *src, const ScrStr *msg) { return NULL; } +static ScrDyn *scr_native_iterator_drain(const ScrDyn *src); + /* Math.max(...src) / Math.min(...src) over an untyped iterable: every * value's ToNumber runs in order, then NaN poisons and ±0 orders as in * Node. A non-iterable source throws V8's call-spread TypeError: `what` @@ -1575,7 +1577,14 @@ double scr_dyn_math_fold(const ScrDyn *src, bool max, const ScrStr *what) { } static const char spread_msg[] = "Spread syntax requires ...iterable[Symbol.iterator] to be a function"; ScrStr *msg = scr_str_new(spread_msg, sizeof spread_msg - 1); - ScrDyn *pack = scr_dyn_iter_pack(src, msg); + /* The spread takes every value before the call: arrays, strings, bytes + * and collections pack, a native iterator (a Map's keys()) drains, and + * other iterables (a generator, an object with a Symbol.iterator method) + * step their iterator to the end. */ + ScrDyn *pack = src->kind == SCR_DYN_HANDLE && (src->v.handle.tag == SCR_DYNH_ITERATOR || + scr_dyn_handle_ops_of(src)->iter_step) + ? scr_native_iterator_drain(src) + : scr_dyn_destructure_rest(src, msg); scr_str_release(msg); if (!pack) return NAN; double best = max ? -INFINITY : INFINITY; diff --git a/tests/corpus/7488-math-spread-untyped.js b/tests/corpus/7488-math-spread-untyped.js index a30ece320..028135d41 100644 --- a/tests/corpus/7488-math-spread-untyped.js +++ b/tests/corpus/7488-math-spread-untyped.js @@ -31,3 +31,39 @@ for (const k of Object.keys(sources)) { console.log(e.constructor.name, e.message); } } + +// Iterables that step through the iterator protocol, reached through an +// untyped lookup: a generator, an object with a Symbol.iterator method, a +// Set, a Map's keys() and an iterator that ends early. +function* generate(values) { + for (const value of values) yield value; +} +const makers = { + generator: () => generate([4, "1", 7]), + custom: () => ({ + *[Symbol.iterator]() { + yield 10; + yield -2; + }, + }), + set: () => new Set([3, 8, 3]), + keys: () => new Map([[2, "a"], [6, "b"]]).keys(), + empty: () => generate([]), +}; +for (const k of Object.keys(makers)) { + console.log(k, Math.min(...makers[k]()), Math.max(...makers[k]())); +} +const stepped = []; +const counting = { + [Symbol.iterator]() { + let n = 0; + return { + next() { + stepped.push(n); + return n < 3 ? { value: n++, done: false } : { value: undefined, done: true }; + }, + }; + }, +}; +const lookup = { counting }; +console.log(Math.max(...lookup.counting), stepped.join(",")); From e3e955eda084a4599f95f69eba34c2d264d1bd1b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:55:26 -0500 Subject: [PATCH 3/3] Register Math.min and Math.max spreads as generator steps dyn.mathFold steps the iterator of an untyped iterable, so the generator dispatch pass now counts it among the runtime entry points that step iterators. A generator whose only consumer is Math.min or Math.max then has its step hooks registered, and the spread folds its values instead of throwing "Reading this native iterator result has no lowering". --- packages/compiler/src/frontend/lowering/dispatch-sites.ts | 5 +++-- .../tests/corpus/7489-math-spread-generator-only.js.json | 6 ++++++ tests/corpus/7489-math-spread-generator-only.js | 8 ++++++++ .../corpus-costs/7489-math-spread-generator-only.js.json | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7489-math-spread-generator-only.js.json create mode 100644 tests/corpus/7489-math-spread-generator-only.js create mode 100644 tests/harness/corpus-costs/7489-math-spread-generator-only.js.json diff --git a/packages/compiler/src/frontend/lowering/dispatch-sites.ts b/packages/compiler/src/frontend/lowering/dispatch-sites.ts index c8a8d6ba6..f31e5a6ae 100644 --- a/packages/compiler/src/frontend/lowering/dispatch-sites.ts +++ b/packages/compiler/src/frontend/lowering/dispatch-sites.ts @@ -12,10 +12,11 @@ import { toPrimitiveHint } from "./class-dynamic-dispatch.js"; import { GENERATOR_STEP_METHODS, HELPER_METHODS } from "./generator-dynamic-dispatch.js"; /** Runtime entry points that step a checked-dynamic iterable's iterator: - * destructuring rest, and the collection constructors' and - * Object.fromEntries' seeds. */ + * destructuring rest, the collection constructors' and Object.fromEntries' + * seeds, and Math.min/Math.max over a spread. */ const RUNTIME_STEP_LIB_FNS: ReadonlySet = new Set([ "dyn.destructureRest", + "dyn.mathFold", "dyn.nativeSetNew", "dyn.mapSeedEntries", "dyn.fromEntries", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7489-math-spread-generator-only.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7489-math-spread-generator-only.js.json new file mode 100644 index 000000000..5277102eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7489-math-spread-generator-only.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7489-math-spread-generator-only.js" + ], + "diags": [] +} diff --git a/tests/corpus/7489-math-spread-generator-only.js b/tests/corpus/7489-math-spread-generator-only.js new file mode 100644 index 000000000..168057aac --- /dev/null +++ b/tests/corpus/7489-math-spread-generator-only.js @@ -0,0 +1,8 @@ +// Math.max and Math.min as the only consumers of an untyped generator: the +// spread steps the generator, whose values fold with ToNumber as in Node. +function* generate(limit) { + for (let i = 1; i <= limit; i++) yield i % 2 === 0 ? String(i * 3) : i; +} +const lookup = { generate }; +console.log(Math.max(...lookup.generate(4)), Math.min(...lookup.generate(4))); +console.log(Math.max(...lookup.generate(0)), Math.min(...lookup.generate(1))); diff --git a/tests/harness/corpus-costs/7489-math-spread-generator-only.js.json b/tests/harness/corpus-costs/7489-math-spread-generator-only.js.json new file mode 100644 index 000000000..2aa0f8038 --- /dev/null +++ b/tests/harness/corpus-costs/7489-math-spread-generator-only.js.json @@ -0,0 +1 @@ +{ "plain": 3384, "san": 4732 }