Spread untyped iterables into Math.min and Math.max (#1928)

This commit is contained in:
Chris Tate
2026-10-22 23:02:05 -05:00
committed by GitHub
13 changed files with 164 additions and 2 deletions
@@ -973,6 +973,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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",
@@ -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<string> = new Set([
"dyn.destructureRest",
"dyn.mathFold",
"dyn.nativeSetNew",
"dyn.mapSeedEntries",
"dyn.fromEntries",
@@ -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",
@@ -671,6 +671,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = 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",
@@ -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 },
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7488-math-spread-untyped.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7489-math-spread-generator-only.js"
],
"diags": []
}
+3
View File
@@ -1277,6 +1277,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);
int scr_dyn_has_instance_hook(const ScrDyn *value, const ScrDyn *callee);
/* JS String() over a dyn value (arrays join, [object Object], the error
* encoding renders "name: message"); borrowed, result +1, never throws. */
+44
View File
@@ -1570,6 +1570,50 @@ 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`
* 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);
/* 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;
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);
+69
View File
@@ -0,0 +1,69 @@
// 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);
}
}
// 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(","));
@@ -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)));
@@ -0,0 +1 @@
{ "plain": 3500, "san": 3800 }
@@ -0,0 +1 @@
{ "plain": 3384, "san": 4732 }