From 054cb50ea4381566b4438a0e5b44448b426e4c04 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 02:31:20 -0500 Subject: [PATCH 01/39] Array keys and properties: non-index number keys, Object.keys, console.log --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/backend/llvm/reference-writes.ts | 1 + .../compiler/src/contracts/int32-elements.ts | 12 ++- .../src/frontend/lowering/lower-calls.ts | 13 ++- .../src/frontend/lowering/lower-inspect.ts | 14 +++- .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/src/ir/validate.ts | 6 +- .../corpus/7060-array-numeric-keys.cjs.json | 6 ++ .../corpus/7061-array-object-keys.ts.json | 6 ++ .../7062-inspect-array-properties.ts.json | 6 ++ packages/runtime/src/scr_array.c | 46 +++++++---- packages/runtime/src/scr_array.h | 4 + packages/runtime/src/scr_dyn.h | 3 + packages/runtime/src/scr_inspect.c | 35 +++++++- packages/runtime/src/scr_json.c | 79 +++++++++++++++++++ .../6987-array-view-named-properties.ts | 10 --- tests/corpus/7060-array-numeric-keys.cjs | 50 ++++++++++++ tests/corpus/7061-array-object-keys.ts | 28 +++++++ tests/corpus/7062-inspect-array-properties.ts | 54 +++++++++++++ .../7060-array-numeric-keys.cjs.json | 1 + .../7061-array-object-keys.ts.json | 1 + .../7062-inspect-array-properties.ts.json | 1 + tests/harness/errors.test.ts | 22 ++++++ 23 files changed, 367 insertions(+), 36 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7060-array-numeric-keys.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7061-array-object-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7062-inspect-array-properties.ts.json create mode 100644 tests/corpus/7060-array-numeric-keys.cjs create mode 100644 tests/corpus/7061-array-object-keys.ts create mode 100644 tests/corpus/7062-inspect-array-properties.ts create mode 100644 tests/harness/corpus-costs/7060-array-numeric-keys.cjs.json create mode 100644 tests/harness/corpus-costs/7061-array-object-keys.ts.json create mode 100644 tests/harness/corpus-costs/7062-inspect-array-properties.ts.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8bb8bc3aa..80e0b0044 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1522,6 +1522,7 @@ export const LIB_FN_SYMS: Record = { "regex.resultProp": "scr_regex_result_prop", "array.namedKeys": "scr_arr_named_keys", "array.hasNamedKey": "scr_arr_has_named_key", + "array.hasProperties": "scr_arr_has_properties", // queueMicrotask's checked-dynamic form (borrowed dyn; a non-function // throws synchronously), the minted setImmediate value, and the // timers/promises immediate — all mark the loop live. diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index d31f14d12..d040e8c93 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -107,6 +107,7 @@ const INERT_LIB_CALLS = new Set([ "regex.resultProp", "array.namedKeys", "array.hasNamedKey", + "array.hasProperties", "process.cwd", "process.argv", "process.execArgv", diff --git a/packages/compiler/src/contracts/int32-elements.ts b/packages/compiler/src/contracts/int32-elements.ts index 8284d40ab..ca61a8e49 100644 --- a/packages/compiler/src/contracts/int32-elements.ts +++ b/packages/compiler/src/contracts/int32-elements.ts @@ -122,7 +122,8 @@ const COPIES = new Set([ const COPY_IN = new Set(["pushSpread", "unshiftSpread", "spliceInsert"]); /** Library calls that only read array arguments. */ -const READ_ONLY_LIB = /^(console\.|inspect\.|json\.stringify|math\.|insp\.)/; +const READ_ONLY_LIB = + /^(console\.|inspect\.|json\.stringify|math\.|insp\.|array\.(hasProperties|namedKeys|hasNamedKey)$)/; /** Library calls that always throw (the value they stand for never exists). */ const THROWING_LIB = /^error\.\w*Throw$/; @@ -872,6 +873,9 @@ export class Int32ElementContracts { }; const isNumberArray = (t: IrType): boolean => t.kind === "array" && t.elem.kind === "f64"; + // Live views handed straight to a read-only library call (util.inspect + // of an array with properties) are only read through. + const readOnlyViews = new Set(); const expr = (e: IrExpr): void => { switch (e.kind) { case "assignExpr": @@ -1000,6 +1004,7 @@ export class Int32ElementContracts { case "libCall": if (!READ_ONLY_LIB.test(e.fn)) for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`); + else for (const a of e.args) if (a.kind === "dynFrom") readOnlyViews.add(a); break; case "callValue": for (const a of e.args) escape(a, a.loc, "a call through a function value"); @@ -1008,7 +1013,10 @@ export class Int32ElementContracts { case "dynFrom": // Plain arrays and records cross into dynamic code as deep copies; // class instances and live references stay connected. - if (e.liveRef === true || this.containsObject(e.value.type, new Set())) + if ( + !readOnlyViews.has(e) && + (e.liveRef === true || this.containsObject(e.value.type, new Set())) + ) escape(e.value, e.loc, "dynamic (any or unknown) code"); break; default: diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 7102f37ab..c6e3d0c1e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -8354,11 +8354,15 @@ function lowerObjectStaticCall( probed?.type.kind === "union" && isJsSourceFile(ts.getSourceFileOfNode(call)) && lowerer.dynConvertible(probed.type); + // An array walks its live view: the present indices, then its numeric + // and named properties. + const isArray = probed?.type.kind === "array" && lowerer.dynConvertible(probed.type); if ( isDyn || isPrimitive || isFunction || isJsUnion || + isArray || (probed && isDynTypedRefType(probed.type)) ) { const fn = @@ -8368,7 +8372,14 @@ function lowerObjectStaticCall( ? "dyn.objValues" : "dyn.objEntries"; let v = lowerer.lowerExpr(argNode); - if (v.type.kind !== "dyn") v = { kind: "dynFrom", value: v, type: DYN, loc: locOf(call) }; + if (v.type.kind !== "dyn") + v = { + kind: "dynFrom", + value: v, + ...(isArray ? { liveRef: true as const } : {}), + type: DYN, + loc: locOf(call), + }; // In TypeScript the keys come back as a native string[] and the // entries as a native [string, any][]: the checker keeps every later // write to the arrays well typed. JavaScript may store anything into diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 4be5d0b7e..14c67f70a 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -1912,13 +1912,21 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string { // A closed record that kept the undeclared properties of its checked // source renders through its live checked view, which holds them in the - // source's own-key order beside the current field values. + // source's own-key order beside the current field values. An array with + // properties besides its elements does too: the view lists them after + // the elements. let prependSourceGate: (() => void) | null = null; - if (t.kind === "record" && recordMayKeepSource(lowerer, t)) { + const sourceTest: IrExpr | null = + t.kind === "record" && recordMayKeepSource(lowerer, t) + ? recordSourceTest(v(), loc) + : t.kind === "array" && lowerer.dynConvertible(t) + ? { kind: "libCall", fn: "array.hasProperties", args: [v()], type: BOOL, loc } + : null; + if (sourceTest !== null) { prependSourceGate = (): void => { body.unshift({ kind: "if", - cond: recordSourceTest(v(), loc), + cond: sourceTest, then: [ ret({ kind: "libCall", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index fa7876579..054b16201 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3738,6 +3738,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // Whether the array (args[0]) still has the key (args[1]) from its // namedKeys list. Borrowed; never throws. "array.hasNamedKey": { argTypes: [null, STRING], result: BOOL }, + // Whether an array (args[0], any element type) has properties besides its + // elements: noncanonical numeric keys, match metadata or named + // properties. Borrowed; never throws. + "array.hasProperties": { argTypes: [null], result: BOOL }, // node:events EventEmitter: receivers are emitter-hierarchy objects and // the chaining forms (on/off/removeAll/setMax) return the receiver's // own class — program-dependent object types, checked in the libCall diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 88ccbbc33..bdfe75f2e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4262,7 +4262,11 @@ function validateFunction( if (!ok) err(`libCall dyn.objEntryPairs must return a [string, any] tuple array`, e.loc); return true; } - if (e.fn === "array.namedKeys" || e.fn === "array.hasNamedKey") { + if ( + e.fn === "array.namedKeys" || + e.fn === "array.hasNamedKey" || + e.fn === "array.hasProperties" + ) { if (e.args[0]?.type.kind !== "array") { err(`libCall ${e.fn} arg 0: expected an array, got ${e.args[0]?.type.kind}`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7060-array-numeric-keys.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7060-array-numeric-keys.cjs.json new file mode 100644 index 000000000..5fff9651a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7060-array-numeric-keys.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7060-array-numeric-keys.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7061-array-object-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7061-array-object-keys.ts.json new file mode 100644 index 000000000..b2830e983 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7061-array-object-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7061-array-object-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7062-inspect-array-properties.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7062-inspect-array-properties.ts.json new file mode 100644 index 000000000..891c4ffe0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7062-inspect-array-properties.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7062-inspect-array-properties.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index c00d1b4f6..3d7da0c37 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -4825,6 +4825,10 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) { bool scr_arr_numeric_key(const char *key, size_t len, double *out) { char text[64]; if (len == 0 || len >= sizeof text) return false; + /* A number's spelling starts with a digit, '-', "NaN" or "Infinity": + * other names answer without parsing. */ + char first = key[0]; + if (!(first >= '0' && first <= '9') && first != '-' && first != 'N' && first != 'I') return false; memcpy(text, key, len); text[len] = '\0'; char *end; @@ -4838,6 +4842,11 @@ bool scr_arr_numeric_key(const char *key, size_t len, double *out) { return true; } +bool scr_arr_has_properties(const ScrArr *a) { + const ScrArrExt *x = scr_arr_ext_of(a); + return x && (x->prop_len || x->metadata); +} + void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) { const ScrDyn *metadata = SCR_AX(a)->metadata; if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE && @@ -4845,21 +4854,7 @@ void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) { return; metadata = SCR_AX(a)->metadata; if (!metadata) return; - for (size_t i = 0; i < metadata->v.obj.len; i++) { - const ScrDynEntry *entry = &metadata->v.obj.entries[i]; - ScrStr *key = scr_str_new(entry->key, entry->key_len); - if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) { - scr_dyn_key_set(target, key, entry->value); - } else { - /* Properties defined through a view keep their attributes. */ - ScrDyn *name = scr_dyn_new_str(key); - ScrDyn *descriptor = scr_dyn_own_descriptor(metadata, key); - if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor)); - scr_dyn_release(descriptor); - scr_dyn_release(name); - } - scr_str_release(key); - } + for (size_t i = 0; i < metadata->v.obj.len; i++) scr_dyn_copy_entry(target, metadata, &metadata->v.obj.entries[i]); } ScrArr *scr_arr_named_keys(ScrArr *a) { @@ -4917,8 +4912,27 @@ void scr_arr_adopt_view_properties(ScrArr *a, ScrDyn *view, ScrArr *target) { } ScrDyn *named = view && view->kind == SCR_DYN_ARR ? view->v.arr.properties : NULL; if (!named || named->v.obj.len == 0) return; + /* The view's numeric properties are copies of the native ones (every + * write to them goes to the native array), so only the others move. */ + bool numeric = false; + for (size_t i = 0; i < named->v.obj.len && !numeric; i++) { + double number; + numeric = scr_arr_numeric_key(named->v.obj.entries[i].key, named->v.obj.entries[i].key_len, &number); + } + if (numeric) { + ScrDyn *rest = scr_dyn_new_obj(); + for (size_t i = 0; i < named->v.obj.len; i++) { + const ScrDynEntry *entry = &named->v.obj.entries[i]; + double number; + if (!scr_arr_numeric_key(entry->key, entry->key_len, &number)) scr_dyn_copy_entry(rest, named, entry); + } + named = rest; + } else { + scr_dyn_retain(named); + } ScrArrExt *x = scr_arr_ext(a); ScrDyn *old = x->metadata; - x->metadata = scr_dyn_retain(named); + x->metadata = named->v.obj.len ? named : NULL; + if (!x->metadata) scr_dyn_release(named); scr_dyn_release(old); } diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 6c41b3dbd..d08b94c14 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -333,6 +333,10 @@ bool scr_arr_has_named_key(ScrArr *a, ScrStr *key); * noncanonical numeric properties, then the named properties in insertion * order. +1. */ ScrArr *scr_arr_named_keys(ScrArr *a); +/* Whether the array has properties besides its elements (noncanonical + * numeric keys, match metadata, named properties): util.inspect lists + * them after the elements. */ +bool scr_arr_has_properties(const ScrArr *a); /* Installed by scr_regex.c when it records a pending match source; this * unit links without the regex runtime. Writes the pending metadata of * `a` into its dynamic copy or materializes it, answering whether it diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 02c4d828f..8974fc7ce 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -561,6 +561,9 @@ static inline const ScrDynTypedArrayOps *scr_dyn_typed_ref_array_ops(const ScrDy ScrDyn *scr_dyn_obj_get(const ScrDyn *d, const char *key, size_t key_len); /* Own-enumerable check on a SCR_DYN_OBJ. */ bool scr_dyn_obj_enumerable(const ScrDyn *d, const char *key, size_t key_len); +/* Stores own property `entry` of `source` on `target` with its attributes: + * plain data properties by an ordinary write, others by their descriptor. */ +void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry *entry); /* Literal discriminator tests used before selecting a typed record layout. * Missing fields and different primitive kinds never match. Borrowed args. */ bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value); diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 223517e92..7a1685a02 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1499,14 +1499,20 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { return scr_insp_str(d->v.str); case SCR_DYN_ARR: { size_t len = d->v.arr.len; - if (len == 0) return scr_str_new("[]", 2); + /* Named and noncanonical numeric properties follow the elements as + * `key: value` entries (Node's array extras). */ + const ScrDyn *props = d->v.arr.properties; + size_t nprops = 0; + for (size_t i = 0; props && i < props->v.obj.len; i++) nprops += props->v.obj.entries[i].enumerable; + if (len == 0 && nprops == 0) return scr_str_new("[]", 2); if (recurse > depth) return scr_str_new("[Array]", 7); scr_insp_begin(recurse + 1); /* Node's maxArrayLength (100) counts output entries; a run of holes * is one `` entry (formatSpecialArray). */ const unsigned char *presence = d->v.arr.presence; size_t i = 0; - for (size_t shown = 0; i < len && shown < 100; shown++) { + size_t shown = 0; + for (; i < len && shown < 100; shown++) { if (presence && !presence[i]) { size_t next = i + 1; while (next < len && !presence[next]) next++; @@ -1524,15 +1530,38 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { scr_str_release(s); } bool more = i < len; + size_t entries = shown + (more ? 1 : 0); if (more) { ScrStr *m = scr_insp_more_items((double)(len - i)); scr_insp_entry(m, d->v.arr.items[i]->kind == SCR_DYN_NUM); scr_str_release(m); } + for (size_t k = 0; props && k < props->v.obj.len; k++) { + const ScrDynEntry *ent = &props->v.obj.entries[k]; + if (!ent->enumerable) continue; + ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); + InspBuf eb = {0}; + insp_key_into(&eb, ent->key, ent->key_len); + ib_cstr(&eb, ": "); + ib_bytes(&eb, val->data, val->len); + scr_str_release(val); + ScrStr *entry = ib_take(&eb); + /* Node's grouping pads by the array element at the entry's output + * position (value[i] in groupArrayElements), not by the entry. */ + bool number = entries < len && (!presence || presence[entries]) && + d->v.arr.items[entries]->kind == SCR_DYN_NUM; + scr_insp_entry(entry, number); + scr_str_release(entry); + entries++; + } + /* Node's grouping leaves out the last entry when there are more + * entries than maxArrayLength (the `... more items` entry when the + * array has no properties). */ + bool last_out = entries > 100; ScrStr *base = scr_str_new("", 0); ScrStr *b0 = scr_str_new("[", 1); ScrStr *b1 = scr_str_new("]", 1); - ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, more); + ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, last_out); scr_str_release(base); scr_str_release(b0); scr_str_release(b1); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index b8f16630b..e8b75e5fa 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3241,6 +3241,43 @@ ScrDyn *scr_dyn_class_view_unavailable(void *ptr) { return scr_dyn_retain(scr_dyn_undefined()); } +void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry *entry) { + ScrStr *key = scr_str_new(entry->key, entry->key_len); + if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) { + scr_dyn_key_set(target, key, entry->value); + } else { + /* Properties defined through a view keep their attributes. */ + ScrDyn *name = scr_dyn_new_str(key); + ScrDyn *descriptor = scr_dyn_own_descriptor(source, key); + if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor)); + scr_dyn_release(descriptor); + scr_dyn_release(name); + } + scr_str_release(key); +} + +static ScrDyn *scr_typed_ref_numeric_property(const ScrDyn *d, double key); + +/* A live array's view lists the native numeric properties first, then the + * named properties the snapshot copied from the array's metadata. */ +static void scr_typed_ref_view_numeric_properties(const ScrDyn *capsule, ScrDyn *view) { + const ScrDynTypedArrayOps *ops = scr_dyn_typed_ref_array_ops(capsule); + ScrArr *arr = capsule->v.typed_ref.ptr; + const ScrArrExt *x = ops ? scr_arr_ext_of(arr) : NULL; + if (!x || x->prop_len == 0 || view->kind != SCR_DYN_ARR) return; + ScrDyn *named = view->v.arr.properties; + view->v.arr.properties = NULL; + for (size_t i = 0; i < x->prop_len; i++) { + double number; + if (!scr_arr_numeric_key(x->props[i].key, x->props[i].key_len, &number)) continue; + ScrDyn *value = scr_typed_ref_numeric_property(capsule, number); + if (value) scr_dyn_arr_named_put(view, x->props[i].key, x->props[i].key_len, value); + } + if (!named) return; + for (size_t i = 0; i < named->v.obj.len; i++) scr_dyn_copy_entry(view, named, &named->v.obj.entries[i]); + scr_dyn_release(named); +} + ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d) { ScrDyn *capsule = (ScrDyn *)d; ScrDyn *fresh = capsule->v.typed_ref.materialize(capsule->v.typed_ref.ptr); @@ -3248,6 +3285,7 @@ ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d) { scr_dyn_release(fresh); return scr_dyn_retain(scr_dyn_undefined()); } + scr_typed_ref_view_numeric_properties(capsule, fresh); if (!capsule->v.typed_ref.materialized) { capsule->v.typed_ref.materialized = fresh; } else { @@ -3467,6 +3505,16 @@ ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) { return out; } +/* A numeric property of a live array ("-1", "1.5"): +1, or NULL when the + * native array has none. */ +static ScrDyn *scr_typed_ref_numeric_property(const ScrDyn *d, double key) { + ScrArr *arr = d->v.typed_ref.ptr; + double state = scr_arr_state(arr, key); + if (state == (double)SCR_ARR_VALUE) return scr_dyn_typed_ref_array_ops(d)->get(arr, key); + if (state == (double)SCR_ARR_UNDEFINED) return scr_dyn_retain(scr_dyn_undefined()); + return NULL; +} + ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) { if (d->v.typed_ref.record) return scr_typed_ref_record_get(d, key); if (scr_event_ref_hooks) { @@ -3475,6 +3523,8 @@ ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) { } if (!scr_dyn_typed_ref_array_ops(d)) return NULL; if (scr_typed_ref_is_length(key)) return scr_dyn_new_num((double)((ScrArr *)d->v.typed_ref.ptr)->len); + double number; + if (scr_arr_numeric_key(key->data, key->len, &number)) return scr_typed_ref_numeric_property(d, number); size_t index; if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return NULL; return scr_dyn_typed_ref_element(d, index); @@ -3484,6 +3534,9 @@ ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) { static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) { if (!scr_dyn_typed_ref_array_ops(d)) return false; if (scr_typed_ref_is_length(key)) return true; + double number; + if (scr_arr_numeric_key(key->data, key->len, &number)) + return scr_arr_state(d->v.typed_ref.ptr, number) != (double)SCR_ARR_HOLE; size_t index; if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false; ScrArr *arr = d->v.typed_ref.ptr; @@ -3496,6 +3549,22 @@ static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) { static bool scr_typed_ref_array_set(ScrDyn *d, const ScrStr *key, const ScrDyn *value) { const ScrDynTypedArrayOps *ops = scr_dyn_typed_ref_array_ops(d); if (!ops || !ops->set) return false; + /* Numeric properties live in the native array's property table, which + * holds values of the element type. */ + double number; + if (scr_arr_numeric_key(key->data, key->len, &number)) { + if (ops->set(d->v.typed_ref.ptr, number, value)) return true; + ScrStr *kind = scr_dyn_typeof(value); + ScrJsonBuf message; + scr_jb_init(&message); + scr_jb_puts(&message, "expected the array's element type at $["); + scr_jb_write(&message, key->data, key->len); + scr_jb_puts(&message, "], got "); + scr_jb_write(&message, kind->data, kind->len); + scr_str_release(kind); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return true; + } size_t index; if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false; ScrArr *arr = d->v.typed_ref.ptr; @@ -6341,6 +6410,16 @@ static bool scr_dyn_fn_property_fence(const ScrStr *key) { } ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) { + if (target->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(target) && key->kind == SCR_DYN_STR) { + /* A native array's numeric properties ("-1", "1.5") live in its + * property table, which holds values without attributes. */ + double number; + if (scr_arr_numeric_key(key->v.str->data, key->v.str->len, &number)) { + static const char message[] = "Object.defineProperty on a numeric property of a native array is not supported yet"; + scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); + return NULL; + } + } if (target->kind == SCR_DYN_TYPED_REF) { ScrDyn *view = scr_dyn_typed_ref_materialize(target); ScrDyn *defined = scr_exc_pending() ? NULL : scr_dyn_define_property(view, key, descriptor); diff --git a/tests/corpus/6987-array-view-named-properties.ts b/tests/corpus/6987-array-view-named-properties.ts index ee357d072..faba7dc90 100644 --- a/tests/corpus/6987-array-view-named-properties.ts +++ b/tests/corpus/6987-array-view-named-properties.ts @@ -102,13 +102,3 @@ for (const key in visited) { console.log("visited key", key); if (key === "first") delete (visited as unknown as Bag).second; } - -// A non-configurable numeric-looking property refuses the delete and stays. -const fixed: string[] = ["a"]; -Object.defineProperty(fixed as unknown as Bag, "-3", { value: "kept", enumerable: true }); -try { - delete (fixed as unknown as Bag)["-3"]; -} catch (e) { - console.log((e as Error).name); -} -console.log((fixed as unknown as Bag)["-3"], "-3" in (fixed as unknown as Bag)); diff --git a/tests/corpus/7060-array-numeric-keys.cjs b/tests/corpus/7060-array-numeric-keys.cjs new file mode 100644 index 000000000..5a1978cd9 --- /dev/null +++ b/tests/corpus/7060-array-numeric-keys.cjs @@ -0,0 +1,50 @@ +// Number keys that are not array indices ("-1", "1.5", "NaN", "Infinity") +// are ordinary properties of an array. Writes and reads through an untyped +// function, through a string key, and through the array itself all reach +// the same property, and key walks list them after the indices. + +function put(target, key, value) { + target[key] = value; +} +function get(target, key) { + return target[key]; +} +function has(target, key) { + return key in target; +} +function drop(target, key) { + delete target[key]; +} + +const empty = []; +empty[-1] = "x"; +empty[1.5] = "y"; +empty[NaN] = "z"; +console.log(empty[-1], empty[1.5], empty[NaN], empty.length); +console.log(empty["-1"], empty["1.5"], empty["NaN"]); + +const words = ["p", "q"]; +put(words, -1, "x"); +put(words, 1.5, "y"); +put(words, NaN, "z"); +put(words, Infinity, "inf"); +console.log(get(words, -1), get(words, 1.5), get(words, NaN), get(words, Infinity)); +console.log(words[-1], words[1.5], words[NaN], words[Infinity], words["-1"], words.length); +console.log(get(words, "-1"), get(words, "1.50"), get(words, "-0"), has(words, "1.5")); +console.log(Object.keys(words), JSON.stringify(words)); +for (const key in words) console.log("key", key); + +words[-1] = "again"; +console.log(get(words, -1), words["-1"]); +put(words, "label", "named"); +console.log(get(words, "label"), get(words, -1)); +drop(words, -1); +console.log(words[-1], has(words, -1), get(words, 1.5)); +console.log(Object.keys(words)); + +const numbers = [1, 2]; +put(numbers, -1, 10); +put(numbers, 2.5, 20); +console.log(numbers[-1], get(numbers, -1), numbers[2.5], numbers.length); +numbers.push(3); +console.log(get(numbers, -1), numbers.join(",")); diff --git a/tests/corpus/7061-array-object-keys.ts b/tests/corpus/7061-array-object-keys.ts new file mode 100644 index 000000000..93691a78f --- /dev/null +++ b/tests/corpus/7061-array-object-keys.ts @@ -0,0 +1,28 @@ +// Object.keys, Object.values and Object.entries of a typed array list the +// present indices, then its numeric properties ("-1", "1.5"), then its +// named properties, as in Node. Holes are skipped. + +type Bag = Record; + +const words: string[] = ["p", "q"]; +words[-1] = "x"; +(words as unknown as Bag)["1.5"] = "y"; +(words as unknown as Bag).label = "named"; +console.log(Object.keys(words), Object.values(words), Object.entries(words)); +console.log((words as unknown as Bag)["-1"], words[1.5], "1.5" in (words as unknown as Bag)); +for (const key of Object.keys(words)) console.log(key, key.length); + +const holes: (number | undefined)[] = [1, , 3, undefined]; +console.log(Object.keys(holes), Object.values(holes), Object.entries(holes)); + +const empty: number[] = []; +console.log(Object.keys(empty).length, Object.entries(empty)); + +interface Point { + x: number; +} +const points: Point[] = [{ x: 1 }, { x: 2 }]; +console.log(Object.keys(points), Object.values(points)); +const keys: string[] = Object.keys(points); +keys.push("extra"); +console.log(keys.join("|"), Object.keys(points).length); diff --git a/tests/corpus/7062-inspect-array-properties.ts b/tests/corpus/7062-inspect-array-properties.ts new file mode 100644 index 000000000..aa18cfca9 --- /dev/null +++ b/tests/corpus/7062-inspect-array-properties.ts @@ -0,0 +1,54 @@ +// console.log of an array lists its named and numeric properties after the +// elements, as Node's util.inspect does: typed arrays written through an +// unknown view, regex match results, arrays from JSON.parse, holes, Node's +// column grouping and the 100-entry limit. + +type Bag = Record; + +const named: number[] = [1, 2, 3]; +(named as unknown as Bag).extra = "named"; +console.log(named); + +const words: string[] = ["a"]; +(words as unknown as Bag)["my-key"] = { deep: [1, { x: 1 }] }; +console.log(words, [words]); + +const empty: number[] = []; +(empty as unknown as Bag).x = 1; +console.log(empty); + +const numeric: number[] = [1, 2]; +numeric[-1] = 5; +numeric[1.5] = 6; +console.log(numeric); + +const holes: (number | undefined)[] = [1, , 3]; +(holes as unknown as Bag).tail = true; +console.log(holes); + +const grid: number[] = []; +for (let i = 0; i < 30; i++) grid.push(i * 7); +(grid as unknown as Bag).label = "grid"; +console.log(grid); + +const long: number[] = []; +for (let i = 0; i < 120; i++) long.push(i); +(long as unknown as Bag).label = "long"; +console.log(long); + +const full: number[] = []; +for (let i = 0; i < 100; i++) full.push(i); +(full as unknown as Bag).label = "full"; +console.log(full); + +const match = /b(?c)?/.exec("abc"); +console.log(match); +const plainMatch = /x/.exec("axb"); +console.log(plainMatch); + +const parsed = JSON.parse("[1,2,3,4,5,6,7,8]") as unknown as Bag; +parsed.extra = "named"; +console.log(parsed); + +const plain: number[] = [1, 2, 3]; +console.log(plain, [] as number[]); diff --git a/tests/harness/corpus-costs/7060-array-numeric-keys.cjs.json b/tests/harness/corpus-costs/7060-array-numeric-keys.cjs.json new file mode 100644 index 000000000..184d2ea9f --- /dev/null +++ b/tests/harness/corpus-costs/7060-array-numeric-keys.cjs.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 6000 } diff --git a/tests/harness/corpus-costs/7061-array-object-keys.ts.json b/tests/harness/corpus-costs/7061-array-object-keys.ts.json new file mode 100644 index 000000000..b39c9d973 --- /dev/null +++ b/tests/harness/corpus-costs/7061-array-object-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 5400 } diff --git a/tests/harness/corpus-costs/7062-inspect-array-properties.ts.json b/tests/harness/corpus-costs/7062-inspect-array-properties.ts.json new file mode 100644 index 000000000..dcd73aea7 --- /dev/null +++ b/tests/harness/corpus-costs/7062-inspect-array-properties.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 6000 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 8af6998ca..38749c168 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1777,3 +1777,25 @@ console.log("unreachable"); ); }); }); + +describe("numeric properties of native arrays", () => { + // A native array keeps its numeric properties ("-1", "1.5") in a property + // table that holds values without attributes, so defining one with a + // descriptor refuses instead of dropping the attributes. + test("Object.defineProperty on a numeric key is a named fence", async () => { + const r = await compileAndRun( + "array-numeric-define-property", + `const words: string[] = ["a"]; +words[-1] = "x"; +console.log(words[-1]); +Object.defineProperty(words as unknown as Record, "-3", { value: "kept" }); +console.log("unreachable"); +`, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("x\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: Object\.defineProperty on a numeric property of a native array is not supported yet\n$/, + ); + }); +}); From 44efd4b55bf1fb0b4500699aa5134fd829ef1c18 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 05:49:11 -0500 Subject: [PATCH 02/39] Reuse the cached build after whitespace and comment edits --- .../compiler/src/frontend/input-tracker.ts | 52 +-- .../src/frontend/token-equivalence.test.ts | 49 +++ .../src/frontend/token-equivalence.ts | 370 ++++++++++++++++++ packages/compiler/src/native/prepare.test.ts | 60 ++- packages/compiler/src/native/prepare.ts | 80 +++- 5 files changed, 564 insertions(+), 47 deletions(-) create mode 100644 packages/compiler/src/frontend/token-equivalence.test.ts create mode 100644 packages/compiler/src/frontend/token-equivalence.ts diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index fa2e92568..ed2f7b176 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -410,36 +410,38 @@ export function frontendInputsSemanticallyMatch( exclusions: FrontendInputExclusions = {}, ): FrontendSemanticMatch | null { if (!validFrontendInputSnapshot(snapshot)) return null; - const sourceProbes = new Map( - snapshot.probes - .filter((probe): probe is Extract => probe.op === "file") - .map((probe) => [probe.path, probe]), - ); + const sourceProbes = new Map>(); + for (const probe of snapshot.probes) if (probe.op === "file") sourceProbes.set(probe.path, probe); for (const [path, previous] of previousSources) { if (sourceProbes.get(path)?.digest !== frontendSourceDigest(previous)) return null; } const changed: FrontendSemanticChange[] = []; const currentSources = new Map(); - const adjusted: FrontendInputSnapshot = { - ...snapshot, - probes: snapshot.probes.map((probe) => { - if (probe.op !== "file") return probe; - const previous = previousSources.get(probe.path); - if (previous === undefined || frontendSourceDigest(previous) !== probe.digest) return probe; - let current: string; - try { - current = readFileSync(probe.path, "utf8"); - } catch { - return probe; - } - const currentDigest = frontendSourceDigest(current); - currentSources.set(probe.path, current); - if (currentDigest === probe.digest) return probe; - if (!isSemanticEquivalent(probe.path, previous, current)) return probe; - changed.push({ path: probe.path, previous, current }); - return { ...probe, digest: currentDigest }; - }), - }; + const probes: FrontendInputProbe[] = []; + for (const probe of snapshot.probes) { + const previous = probe.op === "file" ? previousSources.get(probe.path) : undefined; + if (probe.op !== "file" || previous === undefined) { + probes.push(probe); + continue; + } + let current: string; + try { + current = readFileSync(probe.path, "utf8"); + } catch { + return null; + } + const currentDigest = frontendSourceDigest(current); + currentSources.set(probe.path, current); + if (currentDigest === probe.digest) { + probes.push(probe); + continue; + } + // One edit that is not equivalent already rules out the match. + if (!isSemanticEquivalent(probe.path, previous, current)) return null; + changed.push({ path: probe.path, previous, current }); + probes.push({ ...probe, digest: currentDigest }); + } + const adjusted: FrontendInputSnapshot = { ...snapshot, probes }; if (!frontendInputsStillMatch(adjusted, exclusions)) return null; for (const [path, previous] of previousSources) { if (!currentSources.has(path)) { diff --git a/packages/compiler/src/frontend/token-equivalence.test.ts b/packages/compiler/src/frontend/token-equivalence.test.ts new file mode 100644 index 000000000..2c7b8ea0a --- /dev/null +++ b/packages/compiler/src/frontend/token-equivalence.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "vitest"; +import { tokenEquivalentSource } from "./token-equivalence.js"; + +const equivalent = (previous: string, current: string, path = "/src/a.ts") => + tokenEquivalentSource(path, previous, current); + +test("whitespace and plain comments are trivia", () => { + expect(equivalent("const a = 1;\n", "const a = 1; // one\n")).toBe(true); + expect(equivalent("f(a,b);\n", "f( a, /* first */ b );\n")).toBe(true); + expect(equivalent("const s = `x${a}y`;\n", "const s = `x${ a /* the value */ }y`;\n")).toBe(true); +}); + +test("tokens, literals and line breaks that change semicolon insertion are not", () => { + expect(equivalent("const a = 1;\n", "const a = 2;\n")).toBe(false); + expect(equivalent('const s = "a // b";\n', 'const s = "a // c";\n')).toBe(false); + expect(equivalent("const s = `a // ${b} /* c */`;\n", "const s = `a // ${b} /* d */`;\n")).toBe( + false, + ); + // `return` followed by a line break returns undefined. + expect(equivalent("return a;\n", "return\na;\n")).toBe(false); + expect(equivalent("return a;\n", "return /*\n*/ a;\n")).toBe(false); +}); + +test("comment-like text inside regular expressions is code", () => { + expect(equivalent("const r = /[//]/; x = 1;\n", "const r = /[//]/; x = 2;\n")).toBe(false); + expect(equivalent("if (a) /\\/*/.test(b); c = 1;\n", "if (a) /\\/*/.test(b); c = 2;\n")).toBe( + false, + ); + expect(equivalent("const q = a / b; // ratio\n", "const q = a / b; // the ratio\n")).toBe(true); + expect(equivalent("return /a/; // one\n", "return /a/; // two\n")).toBe(true); +}); + +test("comments that can affect compilation are kept", () => { + expect(equivalent("/** @type {number} */ let a;\n", "/** @type {string} */ let a;\n")).toBe( + false, + ); + expect(equivalent("// @ts-ignore\nf();\n", "f();\n")).toBe(false); + expect(equivalent('/// \n', "\n")).toBe(false); + // Every comment is kept in JavaScript, whose types come from JSDoc. + expect(equivalent("let a; // one\n", "let a; // two\n", "/src/a.js")).toBe(false); +}); + +test("text the scanner cannot classify is never equivalent", () => { + expect(equivalent("x = {} / 2; // a\n", "x = {} / 2; // b\n")).toBe(false); + expect(equivalent("++/a/.lastIndex; // a\n", "++/a/.lastIndex; // b\n")).toBe(false); + expect(equivalent("const \\u0061 = 1; // a\n", "const \\u0061 = 1; // b\n")).toBe(false); + expect(equivalent("const a = ; // a\n", "const a = ; // b\n", "/src/a.tsx")).toBe(false); + expect(equivalent('const s = "open\n', 'const s = "open // \n')).toBe(false); +}); diff --git a/packages/compiler/src/frontend/token-equivalence.ts b/packages/compiler/src/frontend/token-equivalence.ts new file mode 100644 index 000000000..6387a0e4b --- /dev/null +++ b/packages/compiler/src/frontend/token-equivalence.ts @@ -0,0 +1,370 @@ +/* Token equivalence of two versions of a source file, for frontend cache + * reuse after edits that touch only comments and whitespace. + * + * Two versions are equivalent when they have the same tokens, in the same + * order, with line breaks between the same pairs of tokens (automatic + * semicolon insertion depends on them), and differ only in whitespace and + * in comments that cannot affect compilation. The scanner answers "not + * equivalent" whenever it cannot classify text with certainty, so a wrong + * answer can only cost a cache hit: + * + * - Every comment in a file that is not TypeScript (`.ts`, `.mts`, `.cts`) + * is kept as a token: JavaScript types come from JSDoc. TypeScript + * comments are kept when they start with `///` (references and + * directives) or contain `@` or `#` (checker directives, JSDoc tags, and + * annotations lowering reads). + * - A `/` starts a regular expression or divides according to the token + * before it. After `)` it divides unless the parenthesis closes an `if`, + * `while`, `for`, or `with` header. After `}`, `++` or `--` the scanner + * cannot tell which, so it gives up, as it does for escaped identifiers, + * which can spell keywords. + * - Template literals track their `${` substitutions on a brace stack. + * - JSX files, unterminated strings, comments, templates and regular + * expressions, and any line terminator outside ASCII give up. + * + * Plain character scans keep the scanner free of Unicode property escapes: + * any non-ASCII code unit outside strings, templates, regular expressions + * and comments belongs to the token around it, so an edit there compares + * literally. */ + +interface Token { + text: string; + comment: boolean; + lineBreakBefore: boolean; +} + +/** Keywords after which `/` starts a regular expression. */ +const EXPRESSION_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "case", + "do", + "else", + "yield", + "await", + "default", + "extends", +]); + +/** Keywords whose parenthesized header is followed by a statement. */ +const HEADER_KEYWORDS = new Set(["if", "while", "for", "with"]); + +const PUNCTUATORS = [ + ">>>=", + "...", + "===", + "!==", + "**=", + "<<=", + ">>=", + ">>>", + "&&=", + "||=", + "??=", + "=>", + "==", + "!=", + "<=", + ">=", + "&&", + "||", + "??", + "?.", + "++", + "--", + "+=", + "-=", + "*=", + "%=", + "&=", + "|=", + "^=", + "**", + "<<", + ">>", +]; + +function isTypeScript(path: string): boolean { + return path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts"); +} + +function isIdentifierCode(code: number): boolean { + return ( + (code >= 97 && code <= 122) || + (code >= 65 && code <= 90) || + (code >= 48 && code <= 57) || + code === 36 || + code === 95 || + code === 92 || + code > 127 + ); +} + +function isLineBreak(code: number): boolean { + return code === 10 || code === 13; +} + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 11 || code === 12 || isLineBreak(code); +} + +/** The tokens of `source`, or null when the scanner cannot classify it. */ +function scan(path: string, source: string): Token[] | null { + if (path.endsWith(".tsx") || path.endsWith(".jsx")) return null; + const typeScript = isTypeScript(path); + const tokens: Token[] = []; + // Brace kinds: true for a template substitution, false otherwise. + const braces: boolean[] = []; + // Paren kinds: true for a statement header. + const parens: boolean[] = []; + let lineBreakBefore = false; + // The previous token, and whether a `)` closed a statement header. + const last: { token: string | null } = { token: null }; + let closedHeader = false; + const push = (text: string, comment = false): void => { + tokens.push({ text, comment, lineBreakBefore }); + lineBreakBefore = false; + if (!comment) last.token = text; + }; + let index = 0; + const length = source.length; + // A shebang is one token. + if (source.startsWith("#!")) { + while (index < length && !isLineBreak(source.charCodeAt(index))) index++; + push(source.slice(0, index)); + } + /** Scans template text from `index` (after a backtick or a closing + * substitution brace) through the closing backtick or `${`. */ + const template = (start: number): boolean => { + let position = index; + while (position < length) { + const code = source.charCodeAt(position); + if (code === 92) { + position += 2; + continue; + } + if (code === 96) { + position++; + index = position; + push(source.slice(start, position)); + closedHeader = false; + return true; + } + if (code === 36 && source.charCodeAt(position + 1) === 123) { + position += 2; + index = position; + push(source.slice(start, position)); + braces.push(true); + last.token = "${"; + return true; + } + position++; + } + return false; + }; + while (index < length) { + const code = source.charCodeAt(index); + if (isSpace(code)) { + if (isLineBreak(code)) lineBreakBefore = true; + index++; + continue; + } + if (code > 127 && (code === 0x2028 || code === 0x2029 || code === 0xa0 || code === 0xfeff)) + return null; + const next = source.charCodeAt(index + 1); + // Comments. + if (code === 47 && (next === 47 || next === 42)) { + const start = index; + if (next === 47) { + while (index < length && !isLineBreak(source.charCodeAt(index))) index++; + } else { + const end = source.indexOf("*/", index + 2); + if (end < 0) return null; + index = end + 2; + } + const text = source.slice(start, index); + const semantic = + !typeScript || text.startsWith("///") || text.includes("@") || text.includes("#"); + if (semantic) push(text, true); + else if (/[\r\n]/.test(text)) lineBreakBefore = true; + continue; + } + // Strings. + if (code === 34 || code === 39) { + const start = index; + index++; + while (index < length) { + const inner = source.charCodeAt(index); + if (inner === 92) { + index += 2; + continue; + } + if (isLineBreak(inner)) return null; + index++; + if (inner === code) break; + if (index >= length) return null; + } + if (source.charCodeAt(index - 1) !== code || index - start < 2) return null; + push(source.slice(start, index)); + closedHeader = false; + continue; + } + // Templates. + if (code === 96) { + const start = index; + index++; + if (!template(start)) return null; + continue; + } + // Identifiers, keywords and numbers. + if (isIdentifierCode(code) || (code === 46 && next >= 48 && next <= 57)) { + const start = index; + index++; + while (index < length) { + const inner = source.charCodeAt(index); + if (isIdentifierCode(inner) || inner === 46) { + // A dot continues only a number (`1.5`), not member access. + if (inner === 46) { + const first = source.charCodeAt(start); + if (!((first >= 48 && first <= 57) || first === 46)) break; + } + index++; + continue; + } + // Exponent signs (`1e-5`). + if ( + (inner === 43 || inner === 45) && + (source.charCodeAt(index - 1) === 101 || source.charCodeAt(index - 1) === 69) + ) { + const first = source.charCodeAt(start); + if ((first >= 48 && first <= 57) || first === 46) { + index++; + continue; + } + } + break; + } + const word = source.slice(start, index); + // An escaped identifier can spell a keyword (`\u0072eturn`). + if (word.includes("\\")) return null; + push(word); + closedHeader = false; + continue; + } + // Division or a regular expression. + if (code === 47) { + const before = last.token; + let regex: boolean; + if (before === null) regex = true; + else if (before === "}") return null; + else if (before === ")") regex = closedHeader; + // `++/re/.lastIndex` is valid, `x++ / 2` too. + else if (before === "++" || before === "--") return null; + else if (before === "]") regex = false; + else if (/^[A-Za-z0-9_$\\]/.test(before) || before.charCodeAt(0) > 127) + regex = EXPRESSION_KEYWORDS.has(before); + else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`")) + regex = false; + else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false; + else regex = true; + if (regex) { + const start = index; + index++; + let inClass = false; + while (index < length) { + const inner = source.charCodeAt(index); + if (isLineBreak(inner)) return null; + if (inner === 92) { + index += 2; + continue; + } + if (inner === 91) inClass = true; + else if (inner === 93) inClass = false; + else if (inner === 47 && !inClass) break; + index++; + } + if (index >= length) return null; + index++; + while (index < length && isIdentifierCode(source.charCodeAt(index))) index++; + push(source.slice(start, index)); + closedHeader = false; + continue; + } + const text = next === 61 ? "/=" : "/"; + index += text.length; + push(text); + closedHeader = false; + continue; + } + // Brackets. + if (code === 40) { + parens.push(last.token !== null && HEADER_KEYWORDS.has(last.token)); + index++; + push("("); + closedHeader = false; + continue; + } + if (code === 41) { + const header = parens.pop() ?? false; + index++; + push(")"); + closedHeader = header; + continue; + } + if (code === 123) { + braces.push(false); + index++; + push("{"); + closedHeader = false; + continue; + } + if (code === 125) { + if (braces.pop() === true) { + const start = index; + index++; + if (!template(start)) return null; + continue; + } + index++; + push("}"); + closedHeader = false; + continue; + } + // Other punctuators. + let text = source[index]!; + for (const punctuator of PUNCTUATORS) + if (source.startsWith(punctuator, index)) { + text = punctuator; + break; + } + index += text.length; + push(text); + closedHeader = false; + } + if (braces.length !== 0 || parens.length !== 0) return null; + return tokens; +} + +/** Whether `current` differs from `previous` only in whitespace and in + * comments that cannot affect compilation. */ +export function tokenEquivalentSource(path: string, previous: string, current: string): boolean { + if (previous === current) return true; + const left = scan(path, previous); + if (left === null) return false; + const right = scan(path, current); + if (right === null || left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + const a = left[index]!; + const b = right[index]!; + if (a.text !== b.text || a.comment !== b.comment) return false; + if (index > 0 && a.lineBreakBefore !== b.lineBreakBefore) return false; + } + return true; +} diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 7e22450da..35e866d6a 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -16,6 +16,22 @@ afterEach(() => { for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true }); }); +function fixtureToolchain(): NativeToolchain { + const target = nativeCodegenTarget()!; + return { + compilerVersion: "test", + target, + helper: target.helper, + helperExecutable: "unused", + helperPackageRoot: "unused", + runtimePackRoot: "unused", + linker: "unused", + linkerArgs: [], + dsymutil: "unused", + ts7Executable: ts7Executable(), + }; +} + function fixture() { const temporary = mkdtempSync(join(tmpdir(), "scriptc-native-frontend-")); scratch.push(temporary); @@ -32,19 +48,7 @@ function fixture() { }), ); writeFileSync(join(root, "package.json"), '{"type":"module"}'); - const target = nativeCodegenTarget()!; - const toolchain: NativeToolchain = { - compilerVersion: "test", - target, - helper: target.helper, - helperExecutable: "unused", - helperPackageRoot: "unused", - runtimePackRoot: "unused", - linker: "unused", - linkerArgs: [], - dsymutil: "unused", - ts7Executable: ts7Executable(), - }; + const toolchain = fixtureToolchain(); const cache = new NativeCache(join(temporary, "cache")); const options = { outPath: join(root, ".scriptc", "main"), @@ -79,6 +83,36 @@ test("warm frontend reuse survives output creation and invalidates imported sour expect(f.loads()).toBe(2); }); +test("comment and whitespace edits reuse the frontend output a fresh build reproduces", () => { + const f = fixture(); + const first = f.prepare(); + expect(first.ok, JSON.stringify(first)).toBe(true); + if (!first.ok) return; + const commented = '// The exported value.\nexport const value = /* unchanged */ "first";\n'; + writeFileSync(f.dependency, commented); + const reused = f.prepare(); + expect(f.loads()).toBe(1); + expect(reused.ok).toBe(true); + if (!reused.ok) return; + expect(reused.llvm).toBe(first.llvm); + expect(new Map(reused.sources).get(f.dependency)).toBe(commented); + // The reused output is the one the edited sources lower to. + const fresh = prepareNativeExecutable( + f.entry, + f.options, + null, + fixtureToolchain(), + (path, npmStatic, externalTypes) => runFrontend(path, loadProgram, npmStatic, externalTypes), + new NativeCache(join(f.root, "fresh-cache")), + ); + expect(fresh.ok && fresh.llvm).toBe(first.llvm); + // The refreshed entry still notices a later code edit. + writeFileSync(f.dependency, 'export const value = "second";\n'); + const edited = f.prepare(); + expect(f.loads()).toBe(2); + expect(edited.ok && edited.llvm).not.toBe(first.llvm); +}); + test("structurally invalid cached features cause a rebuild even with a valid digest", () => { const f = fixture(); expect(f.prepare().ok).toBe(true); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 6b0a2fea5..53b22cfaa 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -16,6 +16,7 @@ import type { FfiProfile } from "../ffi/ffi-manifest.js"; import type { FrontendFactory } from "../frontend/pipeline.js"; import { FrontendInputTracker, + frontendInputsSemanticallyMatch, frontendInputsStillMatch, validFrontendInputSnapshot, type FrontendInputSnapshot, @@ -23,6 +24,8 @@ import { trackedReadFile, } from "../frontend/input-tracker.js"; import { provenanceSources } from "../frontend/provenance-registry.js"; +import { tokenEquivalentSource } from "../frontend/token-equivalence.js"; +import { llvmBytes, wtf8Bytes } from "../backend/literals.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; import type { IrModule } from "../ir/ir.js"; @@ -90,18 +93,68 @@ function publishFrontendCache( if (length <= NATIVE_STRING_BYTES) cache.writeParts("frontend", key, parts); } +/** The cached input when every frontend input still matches, else the + * entry the inputs no longer match, for reuse after equivalent edits. */ function readFrontendCache( cache: NativeCache, key: string, exclusions: FrontendInputExclusions, -): NativeExecutableInput | null { +): { input: NativeExecutableInput } | { stale: FrontendCacheEntry } | null { // The entry is decoded as one string; larger entries are misses. const bytes = cache.read("frontend", key, NATIVE_STRING_BYTES); if (bytes === null) return null; const cached: unknown = JSON.parse(bytes.toString("utf8")); - if (!validCachedInput(cached) || !frontendInputsStillMatch(cached.probes, exclusions)) + if (!validCachedInput(cached)) return null; + if (!frontendInputsStillMatch(cached.probes, exclusions)) return { stale: cached }; + return { input: { ...cached.input, llvm: cached.llvm, ir: cached.ir } }; +} + +/** The stale entry's input when every changed source is token-equivalent to + * the text the entry was built from (edits to whitespace and to comments + * that cannot affect compilation; see token-equivalence.ts). Equivalent + * sources lower to the same IR apart from source locations, which only dev + * builds' debug information and the serialized IR carry. Diagnostics carry + * locations too, so an entry that reported warnings is rebuilt. */ +function equivalentFrontendInput( + stale: FrontendCacheEntry, + options: CompileRequestOptions, + exclusions: FrontendInputExclusions, +): { input: NativeExecutableInput; probes: FrontendInputSnapshot } | null { + if ( + options.optimization === "dev" || + options.sanitize === true || + options.emitIr === true || + stale.ir !== null || + (stale.input.warnings?.length ?? 0) > 0 + ) return null; - return { ...cached.input, llvm: cached.llvm, ir: cached.ir }; + const match = frontendInputsSemanticallyMatch( + stale.probes, + new Map(stale.input.sources), + tokenEquivalentSource, + exclusions, + ); + if (match === null || match.changed.length === 0) return null; + // Runtime fences and unsupported-construct messages name their source as + // `[ at :]`, and an equivalent edit can move the line. + if ( + match.changed.some(({ path }) => + stale.llvm.includes(` at ${llvmBytes(wtf8Bytes(path), false)}:`), + ) + ) + return null; + return { + input: { + ...stale.input, + llvm: stale.llvm, + ir: null, + sources: stale.input.sources.map(([path, text]) => [ + path, + match.currentSources.get(path) ?? text, + ]), + }, + probes: match.snapshot, + }; } function implementationIdentity(toolchain: NativeToolchain): string { @@ -260,10 +313,19 @@ export function prepareNativeExecutable( .sort(([a], [b]) => a.localeCompare(b)), }), ); - const input = readFrontendCache(cache, key, exclusions); - if (input !== null) { + const cached = readFrontendCache(cache, key, exclusions); + if (cached !== null && "input" in cached) { timing("frontend-cache-hit"); - return input; + return cached.input; + } + const equivalent = + cached === null || outputKind === "ir" + ? null + : equivalentFrontendInput(cached.stale, options, exclusions); + if (equivalent !== null) { + publishFrontendCache(cache, key, equivalent.probes, equivalent.input); + timing("frontend-cache-equivalent"); + return equivalent.input; } } catch { key = null; @@ -373,10 +435,10 @@ export function prepareNativeLibrary( .sort(([a], [b]) => a.localeCompare(b)), }), ); - const input = readFrontendCache(cache, key, exclusions); - if (input !== null) { + const cached = readFrontendCache(cache, key, exclusions); + if (cached !== null && "input" in cached) { timing("frontend-cache-hit"); - return input; + return cached.input; } } catch { key = null; From 8e8abbe1535e6cf808ad334ce13a49d7e7ab0308 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 10:21:53 -0500 Subject: [PATCH 03/39] Rebuild after edits to source text the output carries --- .../src/frontend/input-tracker.test.ts | 22 +++++++++++++ .../compiler/src/frontend/input-tracker.ts | 31 ++++++++++++++++--- .../src/frontend/lowering/divergences.ts | 5 ++- .../src/frontend/token-equivalence.test.ts | 12 +++++++ .../src/frontend/token-equivalence.ts | 28 +++++++++++------ .../compiler/src/frontend/ts7/ast-node.ts | 23 +++++++++++++- packages/compiler/src/native/prepare.test.ts | 30 ++++++++++++++++++ packages/compiler/src/native/prepare.ts | 5 ++- 8 files changed, 140 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/frontend/input-tracker.test.ts b/packages/compiler/src/frontend/input-tracker.test.ts index 4b6ef6d90..b4f3957f5 100644 --- a/packages/compiler/src/frontend/input-tracker.test.ts +++ b/packages/compiler/src/frontend/input-tracker.test.ts @@ -60,6 +60,28 @@ test("semantic validation reads non-source dependencies once and still invalidat expect(reads.mock.calls.filter(([path]) => path === dependency)).toHaveLength(1); }); +test("an equivalent edit of a file whose raw text the frontend read does not match", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); + scratch.push(dir); + const source = join(dir, "entry.ts"); + const previous = "export const answer = 1;\n"; + await writeFile(source, previous); + const tracker = new FrontendInputTracker(); + tracker.run(() => { + trackedReadFile(source); + tracker.record({ op: "text", path: source }); + }); + const snapshot = tracker.snapshot(); + expect(validFrontendInputSnapshot(snapshot)).toBe(true); + expect(frontendInputsStillMatch(snapshot)).toBe(true); + await writeFile(source, `// comment\n${previous}`); + const equivalent = vi.fn(() => true); + expect( + frontendInputsSemanticallyMatch(snapshot, new Map([[source, previous]]), equivalent), + ).toBeNull(); + expect(equivalent).not.toHaveBeenCalled(); +}); + test("tracked frontend reads invalidate on byte edits", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-")); scratch.push(dir); diff --git a/packages/compiler/src/frontend/input-tracker.ts b/packages/compiler/src/frontend/input-tracker.ts index ed2f7b176..d27ab6285 100644 --- a/packages/compiler/src/frontend/input-tracker.ts +++ b/packages/compiler/src/frontend/input-tracker.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { AsyncLocalStorage } from "node:async_hooks"; import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; +import { observeSourceText } from "./ts7/ast-node.js"; /** * Filesystem observations made while constructing and lowering one frontend @@ -16,7 +17,10 @@ export type FrontendInputProbe = | { op: "kind"; path: string; kind: "file" | "directory" | "other" | "missing" } | { op: "entries"; path: string; files: string[]; directories: string[] } | { op: "entries-error"; path: string } - | { op: "realpath"; path: string; target: string | null }; + | { op: "realpath"; path: string; target: string | null } + /** The frontend's output depends on the file's raw text or line + * positions, not only on its tokens (observeSourceText). */ + | { op: "text"; path: string }; export interface FrontendInputSnapshot { version: 1; @@ -105,6 +109,7 @@ export class FrontendInputTracker { record(probe: FrontendInputProbe): void { const key = `${probe.op}\0${probe.path}`; + if (probe.op === "text" && this.probes.has(key)) return; const previous = this.probes.get(key); if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(probe)) { // An input changed while this frontend was running. The build may @@ -158,6 +163,13 @@ function record(probe: FrontendInputProbe): void { (synchronousTracker ?? activeTracker.getStore())?.record(probe); } +// Source text the frontend reads raw (call-site text in messages, comptime +// callbacks, line numbers) changes with edits that keep the tokens. +observeSourceText((fileName) => { + const tracker = synchronousTracker ?? activeTracker.getStore(); + if (tracker !== undefined) tracker.record({ op: "text", path: resolve(fileName) }); +}); + /** Mark the active frontend as unsafe to persist because an exact host query * bypassed the tracked filesystem wrappers. No-op outside a frontend run. */ export function markFrontendInputsUnstable(): void { @@ -394,6 +406,9 @@ export function frontendInputsStillMatch( return probe.target === null; } } + case "text": + // The file probe of the same path checks its content. + return true; } }); } @@ -411,7 +426,11 @@ export function frontendInputsSemanticallyMatch( ): FrontendSemanticMatch | null { if (!validFrontendInputSnapshot(snapshot)) return null; const sourceProbes = new Map>(); - for (const probe of snapshot.probes) if (probe.op === "file") sourceProbes.set(probe.path, probe); + const rawText = new Set(); + for (const probe of snapshot.probes) { + if (probe.op === "file") sourceProbes.set(probe.path, probe); + else if (probe.op === "text") rawText.add(probe.path); + } for (const [path, previous] of previousSources) { if (sourceProbes.get(path)?.digest !== frontendSourceDigest(previous)) return null; } @@ -436,8 +455,10 @@ export function frontendInputsSemanticallyMatch( probes.push(probe); continue; } - // One edit that is not equivalent already rules out the match. - if (!isSemanticEquivalent(probe.path, previous, current)) return null; + // One edit that is not equivalent already rules out the match, and so + // does any edit of a file whose raw text the frontend read. + if (rawText.has(probe.path) || !isSemanticEquivalent(probe.path, previous, current)) + return null; changed.push({ path: probe.path, previous, current }); probes.push({ ...probe, digest: currentDigest }); } @@ -488,6 +509,8 @@ export function validFrontendInputSnapshot(snapshot: unknown): snapshot is Front return true; case "realpath": return value.target === null || typeof value.target === "string"; + case "text": + return true; default: return false; } diff --git a/packages/compiler/src/frontend/lowering/divergences.ts b/packages/compiler/src/frontend/lowering/divergences.ts index 1c362cb09..c971c672d 100644 --- a/packages/compiler/src/frontend/lowering/divergences.ts +++ b/packages/compiler/src/frontend/lowering/divergences.ts @@ -464,8 +464,11 @@ function identityUse(root: ts.Node, name: string, from = -1): IdentityUse | null cur = cur.expression; return cur; }; + // The text appears only in warnings. A build that reported warnings is + // not reused after a source edit, so the read is not recorded as a source + // text dependency (ts.getTextOfNode would record it). const textOf = (node: ts.Node): string => { - const text = ts.getTextOfNode(node); + const text = ts.getSourceFileOfNode(node).text.slice(ts.tokenStartOfNode(node), node.end); return text.length > 60 ? `${text.slice(0, 57)}...` : text; }; const visit = (node: ts.Node): "skip" | "stop" | undefined => { diff --git a/packages/compiler/src/frontend/token-equivalence.test.ts b/packages/compiler/src/frontend/token-equivalence.test.ts index 2c7b8ea0a..afd81d9b6 100644 --- a/packages/compiler/src/frontend/token-equivalence.test.ts +++ b/packages/compiler/src/frontend/token-equivalence.test.ts @@ -28,6 +28,18 @@ test("comment-like text inside regular expressions is code", () => { ); expect(equivalent("const q = a / b; // ratio\n", "const q = a / b; // the ratio\n")).toBe(true); expect(equivalent("return /a/; // one\n", "return /a/; // two\n")).toBe(true); + // A `for await` header closes like a `for` header. + expect( + equivalent( + "for await (const x of xs) /[//]/.test(x); c = 1;\n", + "for await (const x of xs) /[//]/.test(x); c = 2;\n", + ), + ).toBe(false); +}); + +test("U+2028 and U+2029 end a line comment, so their files are never equivalent", () => { + expect(equivalent("// a\u2028c = 1;\n", "// a\u2028c = 2;\n")).toBe(false); + expect(equivalent("/* a\u2029*/ c = 1;\n", "/* b\u2029*/ c = 1;\n")).toBe(false); }); test("comments that can affect compilation are kept", () => { diff --git a/packages/compiler/src/frontend/token-equivalence.ts b/packages/compiler/src/frontend/token-equivalence.ts index 6387a0e4b..94c60d836 100644 --- a/packages/compiler/src/frontend/token-equivalence.ts +++ b/packages/compiler/src/frontend/token-equivalence.ts @@ -15,12 +15,14 @@ * annotations lowering reads). * - A `/` starts a regular expression or divides according to the token * before it. After `)` it divides unless the parenthesis closes an `if`, - * `while`, `for`, or `with` header. After `}`, `++` or `--` the scanner + * `while`, `for`, `for await`, or `with` header. After `}`, `++` or `--` the scanner * cannot tell which, so it gives up, as it does for escaped identifiers, * which can spell keywords. * - Template literals track their `${` substitutions on a brace stack. - * - JSX files, unterminated strings, comments, templates and regular - * expressions, and any line terminator outside ASCII give up. + * - JSX files, files that contain U+2028 or U+2029 anywhere (they end a + * line comment and separate lines for automatic semicolon insertion), + * and unterminated strings, comments, templates and regular expressions + * give up. * * Plain character scans keep the scanner free of Unicode property escapes: * any non-ASCII code unit outside strings, templates, regular expressions @@ -118,6 +120,7 @@ function isSpace(code: number): boolean { /** The tokens of `source`, or null when the scanner cannot classify it. */ function scan(path: string, source: string): Token[] | null { if (path.endsWith(".tsx") || path.endsWith(".jsx")) return null; + if (source.includes("\u2028") || source.includes("\u2029")) return null; const typeScript = isTypeScript(path); const tokens: Token[] = []; // Brace kinds: true for a template substitution, false otherwise. @@ -125,13 +128,17 @@ function scan(path: string, source: string): Token[] | null { // Paren kinds: true for a statement header. const parens: boolean[] = []; let lineBreakBefore = false; - // The previous token, and whether a `)` closed a statement header. - const last: { token: string | null } = { token: null }; + // The previous token and the one before it, and whether a `)` closed a + // statement header. + const last: { token: string | null; before: string | null } = { token: null, before: null }; let closedHeader = false; const push = (text: string, comment = false): void => { tokens.push({ text, comment, lineBreakBefore }); lineBreakBefore = false; - if (!comment) last.token = text; + if (!comment) { + last.before = last.token; + last.token = text; + } }; let index = 0; const length = source.length; @@ -162,6 +169,7 @@ function scan(path: string, source: string): Token[] | null { index = position; push(source.slice(start, position)); braces.push(true); + last.before = last.token; last.token = "${"; return true; } @@ -176,8 +184,7 @@ function scan(path: string, source: string): Token[] | null { index++; continue; } - if (code > 127 && (code === 0x2028 || code === 0x2029 || code === 0xa0 || code === 0xfeff)) - return null; + if (code > 127 && (code === 0xa0 || code === 0xfeff)) return null; const next = source.charCodeAt(index + 1); // Comments. if (code === 47 && (next === 47 || next === 42)) { @@ -305,7 +312,10 @@ function scan(path: string, source: string): Token[] | null { } // Brackets. if (code === 40) { - parens.push(last.token !== null && HEADER_KEYWORDS.has(last.token)); + parens.push( + last.token !== null && + (HEADER_KEYWORDS.has(last.token) || (last.token === "await" && last.before === "for")), + ); index++; push("("); closedHeader = false; diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index be2745616..40e77e6f1 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -17,6 +17,16 @@ import { import type { SourceFile } from "./ast-types.js"; import { SyntaxKind, NodeFlags } from "./enums.js"; +/** Receives the file name of a source file whose raw text or line + * positions a reader takes: the text of a node other than a token + * (getText), a node's text with its trivia (getFullText), or line starts. */ +let sourceTextObserver: ((fileName: string) => void) | undefined; + +/** Installs the observer of raw source text reads (sourceTextObserver). */ +export function observeSourceText(observer: ((fileName: string) => void) | undefined): void { + sourceTextObserver = observer; +} + /** A list of child nodes with the list's own position, the shape of tsc-ts's * NodeList. The file keeps the nodes array, not this object: each read of a * list field returns a new AstNodeList over the same array, so `nodes` keeps @@ -202,7 +212,14 @@ export class AstFile { return result; } + /** Reports a read of this file's raw text or line positions. */ + noteSourceText(): void { + if (sourceTextObserver !== undefined && this.root.kind === SyntaxKind.SourceFile) + sourceTextObserver(this.root.fileName); + } + lineStarts(): number[] { + this.noteSourceText(); if (this.lines === undefined) this.lines = astLineStarts(this.root.text ?? ""); return this.lines; } @@ -462,10 +479,14 @@ export class AstNode { return this.getStart(sourceFile) - this.pos; } getFullText(sourceFile?: AstNode): string { - return ((sourceFile ?? this.file.root).text ?? "").substring(this.pos, this.end); + const source = sourceFile ?? this.file.root; + source.file.noteSourceText(); + return (source.text ?? "").substring(this.pos, this.end); } getText(sourceFile?: AstNode): string { const source = sourceFile ?? this.file.root; + // A token's text is the same in every token-equivalent version. + if (this.kind > SyntaxKind.LastToken) source.file.noteSourceText(); return (source.text ?? "").substring(this.getStart(source), this.end); } diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 35e866d6a..78d451f46 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -113,6 +113,36 @@ test("comment and whitespace edits reuse the frontend output a fresh build repro expect(edited.ok && edited.llvm).not.toBe(first.llvm); }); +test("a comment edit inside source text the output carries is rebuilt", () => { + const f = fixture(); + // A failing assert(value) reports its call's source text. + const source = (call: string) => + `import assert from "node:assert";\nexport const value = "first";\n${call};\n`; + writeFileSync(f.dependency, source("assert(value.length > 0)")); + const first = f.prepare(); + expect(first.ok, JSON.stringify(first)).toBe(true); + if (!first.ok) return; + writeFileSync(f.dependency, source("assert(value.length > 0 /* checked */)")); + const edited = f.prepare(); + expect(f.loads()).toBe(2); + expect(edited.ok && edited.llvm).not.toBe(first.llvm); +}); + +test("a whitespace edit that moves a reported source line is rebuilt", () => { + const f = fixture(); + // node:test reports the file, line and column of each test. + const source = (gap: string) => + `import { test } from "node:test";\nexport const value = "first";\n${gap}test("value", () => {});\n`; + writeFileSync(f.dependency, source("")); + const first = f.prepare(); + expect(first.ok, JSON.stringify(first)).toBe(true); + if (!first.ok) return; + writeFileSync(f.dependency, source("\n")); + const edited = f.prepare(); + expect(f.loads()).toBe(2); + expect(edited.ok && edited.llvm).not.toBe(first.llvm); +}); + test("structurally invalid cached features cause a rebuild even with a valid digest", () => { const f = fixture(); expect(f.prepare().ok).toBe(true); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 53b22cfaa..261879194 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -114,7 +114,10 @@ function readFrontendCache( * that cannot affect compilation; see token-equivalence.ts). Equivalent * sources lower to the same IR apart from source locations, which only dev * builds' debug information and the serialized IR carry. Diagnostics carry - * locations too, so an entry that reported warnings is rebuilt. */ + * locations too, so an entry that reported warnings is rebuilt. A changed + * source whose raw text or line positions the frontend read (call-site text + * in messages, comptime callbacks, test locations) is rebuilt as well: + * frontendInputsSemanticallyMatch refuses its `text` probe. */ function equivalentFrontendInput( stale: FrontendCacheEntry, options: CompileRequestOptions, From 291587aae17242e55cb6a1ab9801735cb22d4e49 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 11:08:55 -0500 Subject: [PATCH 04/39] Scan regular expressions after jump statements, and give up on non-ASCII whitespace --- .../src/frontend/token-equivalence.test.ts | 22 +++++++++++ .../src/frontend/token-equivalence.ts | 37 ++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/frontend/token-equivalence.test.ts b/packages/compiler/src/frontend/token-equivalence.test.ts index afd81d9b6..18bfb32e6 100644 --- a/packages/compiler/src/frontend/token-equivalence.test.ts +++ b/packages/compiler/src/frontend/token-equivalence.test.ts @@ -37,6 +37,28 @@ test("comment-like text inside regular expressions is code", () => { ).toBe(false); }); +test("a regular expression can start after a statement that ends at its keyword or label", () => { + const loop = (body: string) => `a: for (;;) {\n${body}\n}\n`; + expect( + equivalent(loop("break\n/[//]/.test(x); c = 1;"), loop("break\n/[//]/.test(x); c = 2;")), + ).toBe(false); + expect( + equivalent(loop("break a\n/[//]/.test(x); c = 1;"), loop("break a\n/[//]/.test(x); c = 2;")), + ).toBe(false); + expect( + equivalent("debugger\n/[//]/.test(x); c = 1;\n", "debugger\n/[//]/.test(x); c = 2;\n"), + ).toBe(false); + // The next line's `x` is no label: it starts a new statement, and `/` divides. + expect(equivalent(loop("continue\nx / 2; // one"), loop("continue\nx / 2; // two"))).toBe(true); +}); + +test("whitespace outside ASCII is never equivalent", () => { + expect( + equivalent("return\u3000/[//]/.test(x); c = 1;\n", "return\u3000/[//]/.test(x); c = 2;\n"), + ).toBe(false); + expect(equivalent("const a = 1;\u00a0// one\n", "const a = 1;\u00a0// two\n")).toBe(false); +}); + test("U+2028 and U+2029 end a line comment, so their files are never equivalent", () => { expect(equivalent("// a\u2028c = 1;\n", "// a\u2028c = 2;\n")).toBe(false); expect(equivalent("/* a\u2029*/ c = 1;\n", "/* b\u2029*/ c = 1;\n")).toBe(false); diff --git a/packages/compiler/src/frontend/token-equivalence.ts b/packages/compiler/src/frontend/token-equivalence.ts index 94c60d836..b13b67498 100644 --- a/packages/compiler/src/frontend/token-equivalence.ts +++ b/packages/compiler/src/frontend/token-equivalence.ts @@ -19,10 +19,13 @@ * cannot tell which, so it gives up, as it does for escaped identifiers, * which can spell keywords. * - Template literals track their `${` substitutions on a brace stack. - * - JSX files, files that contain U+2028 or U+2029 anywhere (they end a - * line comment and separate lines for automatic semicolon insertion), - * and unterminated strings, comments, templates and regular expressions - * give up. + * - After `break`, `continue` and `debugger`, and after the label of a + * `break` or `continue`, a `/` starts a regular expression: it can only + * follow there once automatic semicolon insertion ends the statement. + * - JSX files, files that contain whitespace or a line terminator outside + * ASCII anywhere (the identifier scan would take it as part of a word, and + * U+2028 and U+2029 end a line comment), and unterminated strings, + * comments, templates and regular expressions give up. * * Plain character scans keep the scanner free of Unicode property escapes: * any non-ASCII code unit outside strings, templates, regular expressions @@ -55,6 +58,12 @@ const EXPRESSION_KEYWORDS = new Set([ "extends", ]); +/** Statements that end at their keyword, or at the label after it. */ +const JUMP_KEYWORDS = new Set(["break", "continue", "debugger"]); + +/** Whitespace and line terminators outside ASCII. */ +const NON_ASCII_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/; + /** Keywords whose parenthesized header is followed by a statement. */ const HEADER_KEYWORDS = new Set(["if", "while", "for", "with"]); @@ -120,7 +129,7 @@ function isSpace(code: number): boolean { /** The tokens of `source`, or null when the scanner cannot classify it. */ function scan(path: string, source: string): Token[] | null { if (path.endsWith(".tsx") || path.endsWith(".jsx")) return null; - if (source.includes("\u2028") || source.includes("\u2029")) return null; + if (NON_ASCII_SPACE.test(source)) return null; const typeScript = isTypeScript(path); const tokens: Token[] = []; // Brace kinds: true for a template substitution, false otherwise. @@ -128,14 +137,23 @@ function scan(path: string, source: string): Token[] | null { // Paren kinds: true for a statement header. const parens: boolean[] = []; let lineBreakBefore = false; - // The previous token and the one before it, and whether a `)` closed a - // statement header. - const last: { token: string | null; before: string | null } = { token: null, before: null }; + // The previous token and the one before it, whether the previous token is + // the label of a `break` or `continue` on its line, and whether a `)` + // closed a statement header. + const last: { token: string | null; before: string | null; label: boolean } = { + token: null, + before: null, + label: false, + }; let closedHeader = false; const push = (text: string, comment = false): void => { tokens.push({ text, comment, lineBreakBefore }); lineBreakBefore = false; if (!comment) { + last.label = + (last.token === "break" || last.token === "continue") && + !tokens[tokens.length - 1]!.lineBreakBefore && + /^[A-Za-z_$]/.test(text); last.before = last.token; last.token = text; } @@ -184,7 +202,6 @@ function scan(path: string, source: string): Token[] | null { index++; continue; } - if (code > 127 && (code === 0xa0 || code === 0xfeff)) return null; const next = source.charCodeAt(index + 1); // Comments. if (code === 47 && (next === 47 || next === 42)) { @@ -276,7 +293,7 @@ function scan(path: string, source: string): Token[] | null { else if (before === "++" || before === "--") return null; else if (before === "]") regex = false; else if (/^[A-Za-z0-9_$\\]/.test(before) || before.charCodeAt(0) > 127) - regex = EXPRESSION_KEYWORDS.has(before); + regex = EXPRESSION_KEYWORDS.has(before) || JUMP_KEYWORDS.has(before) || last.label; else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`")) regex = false; else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false; From dc9b60656d010567bb47c6c4c4f4ba14a3254f4e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 13:11:34 -0500 Subject: [PATCH 05/39] Index arrays with exact i64 products of int32 values `a[i * n + k]` with int32 operands kept the index in doubles whenever the product's range could pass 2^53: integer arithmetic needs a proven safe range, and an int32 product can reach 2^62. Every access then converted the double back with an exactness test. Such arithmetic now also carries a wide view: the exact integer as an i64, the magnitude it is bounded by, and a threshold below which the double equals it. A product of two exact integers equals its double below 2^53 in magnitude, and a sum or difference of a wide view exact below T and an exact integer of magnitude at most B equals its double below min(T - B, 2^53). Every i64 result stays within 2^62 + 2^53, so none wraps. An array index below a length or capacity is below 2^32, under every threshold kept, so the dense array guards and local array reads compare the wide view, unsigned, with the array's counts and skip the round trip. Any index that fails those comparisons, including every index whose double rounds, takes the double's path as before. Typed arrays and strings are unchanged. --- .../src/backend/llvm/dense-array-access.ts | 15 ++-- .../compiler/src/backend/llvm/expr-context.ts | 12 +++ .../src/backend/llvm/expr-primitives.ts | 10 ++- .../src/backend/llvm/integer-values.ts | 85 ++++++++++++++++++- .../src/backend/llvm/local-array-reads.ts | 22 +++-- .../test/array-index-product-emission.test.ts | 55 ++++++++++++ ...325-array-index-products-near-2-53.ts.json | 6 ++ .../7325-array-index-products-near-2-53.ts | 82 ++++++++++++++++++ ...325-array-index-products-near-2-53.ts.json | 1 + 9 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 packages/compiler/test/array-index-product-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json create mode 100644 tests/corpus/7325-array-index-products-near-2-53.ts create mode 100644 tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 4488f90cb..ba3b48b02 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -27,7 +27,7 @@ import { storeArrCount, } from "./common.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; -import { exactInteger, widenInteger } from "./integer-values.js"; +import { indexInteger } from "./integer-values.js"; const SCR_ARR_VALUE = 1; @@ -74,13 +74,16 @@ function emitDenseGuard( ): DenseGuard { const B = host.B; const size = host.sizeType; - const integer = exactInteger(host, index, indexExpr); + const integer = indexInteger(host, index, indexExpr); let wide: string; let integral: string | null = null; - if (integer) { - // A proven integer needs no double round-trip. Negative signed values - // compare above any length or capacity as unsigned 64-bit integers. - wide = widenInteger(host, integer); + if (integer !== null) { + // A proven integer, or a wide view that equals the double at every + // index below a length, needs no double round-trip. Negative signed + // values compare above any length or capacity as unsigned 64-bit + // integers, and every index that fails the comparisons takes the + // double's path. + wide = integer; } else { // freeze keeps NaN/out-of-range conversions from producing poison; the // exact round-trip comparison then rejects every value that is not an diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index 9dd6bbcac..df6dfc608 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -15,11 +15,23 @@ export interface LlValue { /** An exact integer snapshot, excluding negative zero, with no discarded * high bits and the proven range of its original value. */ integer?: { name: string; type: "i32" | "i64"; signed: boolean; range: IntegerRange }; + /** An i64 holding the exact integer that the arithmetic producing this + * number computes, when that integer has no proven safe range: its + * absolute value is at most `bound`, and the double equals it whenever + * its absolute value is below `below` (a zero may be -0). Index consumers + * use it in place of a round trip (integer-values.ts `indexInteger`). */ + wideInteger?: WideInteger; slot?: boolean; /** A sequence local held in a capture box; release the box itself. */ boxed?: boolean; } +export interface WideInteger { + name: string; + below: number; + bound: number; +} + export type ExprOf = Extract; export type LibCallExpr = ExprOf<"libCall">; type LibCallPrefixOf = T extends `${infer Prefix}.${string}` ? Prefix : never; diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 354004e9e..4d3904bde 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -16,6 +16,7 @@ import { int32Number, integerNumber, narrowInteger, + wideArithmetic, widenInteger, } from "./integer-values.js"; import { @@ -337,7 +338,14 @@ export function emitOperatorExpr( if (arith[e.op] !== undefined) { B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`); const uint32 = emitWrappingSum(host, e, l, r, li, ri); - if (uint32 !== undefined) return { name: t, type: e.type, uint32 }; + // The exact integer beside the double, for index consumers. + const wideInteger = wideArithmetic(host, e.op, l, li, r, ri); + if (uint32 !== undefined || wideInteger) { + const value: LlValue = { name: t, type: e.type }; + if (uint32 !== undefined) value.uint32 = uint32; + if (wideInteger) value.wideInteger = wideInteger; + return value; + } } else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`); } else if (bit[e.op] !== undefined) { if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") { diff --git a/packages/compiler/src/backend/llvm/integer-values.ts b/packages/compiler/src/backend/llvm/integer-values.ts index 63e279d04..ddbcdd8da 100644 --- a/packages/compiler/src/backend/llvm/integer-values.ts +++ b/packages/compiler/src/backend/llvm/integer-values.ts @@ -1,6 +1,6 @@ import type { IrExpr, IrType } from "../../ir/ir.js"; import type { IntegerRange } from "../../ir/integer-ranges.js"; -import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; +import type { LlValue, LlvmEmitterContext, WideInteger } from "./expr-context.js"; export type LlInteger = NonNullable; @@ -51,6 +51,89 @@ export function exactInteger( return { name, type: "i64", signed: true, range }; } +/** Doubles hold every integer of smaller magnitude exactly. */ +const EXACT_DOUBLES = 2 ** 53; +/** Wide views stay within this magnitude: a product of two int32s reaches + * 2^62, one exact term of at most 2^53 may be added to it, and no i64 + * result wraps. */ +const WIDE_BOUND = 2 ** 62 + 2 ** 53; +/** Array and string indexes are below 2^32 - 1: a wide view exact below + * this many is exact at every index a length can admit. */ +const INDEX_LIMIT = 2 ** 32; + +function magnitude(range: IntegerRange): number { + return Math.max(Math.abs(range.min), Math.abs(range.max)); +} + +/** The wide view of `l op r` for `+`, `-` and `*` when the exact operands + * have no proven result range (their product or sum may pass 2^53). + * + * - A product of two exact integers equals its double whenever its + * magnitude is below 2^53: the double product of integers is exact there. + * - A sum or difference of two exact integers likewise. + * - A sum or difference of a wide view (exact below T) and an exact integer + * of magnitude at most B equals its double below min(T - B, 2^53): the + * view's magnitude is then below T, so its double is exact, and the + * double sum of two exact integers below 2^53 is exact. + * + * Every i64 result stays within 2^62 + 2^53 in magnitude, so none wraps. A view + * exact below fewer than 2^32 helps no index and is not kept. */ +export function wideArithmetic( + host: LlvmEmitterContext, + op: string, + l: LlValue, + li: LlInteger | null, + r: LlValue, + ri: LlInteger | null, +): WideInteger | null { + if (op !== "+" && op !== "-" && op !== "*") return null; + const view = (value: LlValue, integer: LlInteger | null): WideInteger | null => + integer + ? { name: "", below: Infinity, bound: magnitude(integer.range) } + : (value.wideInteger ?? null); + const lv = view(l, li), + rv = view(r, ri); + if (!lv || !rv) return null; + let below: number, bound: number; + if (op === "*") { + if (lv.below !== Infinity || rv.below !== Infinity) return null; + below = EXACT_DOUBLES; + bound = lv.bound * rv.bound; + } else if (lv.below === Infinity && rv.below === Infinity) { + below = EXACT_DOUBLES; + bound = lv.bound + rv.bound; + } else if (lv.below === Infinity || rv.below === Infinity) { + const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv]; + below = Math.min(wide.below - exact.bound, EXACT_DOUBLES); + bound = exact.bound + wide.bound; + } else return null; + if (!(bound <= WIDE_BOUND) || below < INDEX_LIMIT) return null; + const operand = (value: LlValue, integer: LlInteger | null): string => + integer ? widenInteger(host, integer) : value.wideInteger!.name; + const left = operand(l, li), + right = operand(r, ri); + const name = host.B.tmp(); + host.B.line( + `${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`, + ); + return { name, below, bound }; +} + +/** An i64 index for a consumer that compares it, unsigned, with a length or + * capacity below 2^32 and takes the double's path otherwise: the exact + * integer of `value`, or its wide view, which equals the double at every + * such index. Null when the index needs the double round trip. */ +export function indexInteger( + host: LlvmEmitterContext, + value: LlValue, + expr?: IrExpr, +): string | null { + const integer = exactInteger(host, value, expr); + if (integer) return widenInteger(host, integer); + const wide = value.wideInteger; + return wide && wide.below >= INDEX_LIMIT ? wide.name : null; +} + export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string { if (value.type === "i64") return value.name; const wide = host.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index cdd00c4e7..b9fc42392 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -13,7 +13,7 @@ import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js"; import type { LlValue, LlvmEmitterContext } from "./expr-context.js"; import { ReferenceEffects } from "./reference-effects.js"; import { SCR_ARR_FIELD, loadArrCount, scrArrStateAt } from "./common.js"; -import { exactInteger, widenInteger } from "./integer-values.js"; +import { indexInteger } from "./integer-values.js"; import { READ_FALLBACK } from "./dense-array-access.js"; export interface LocalArrayRead { @@ -605,11 +605,11 @@ export function emitBorrowedArrayRead( return; } const index = host.emitExpr(read.index); - // A proven integer index (an int32 local, say) needs no double - // round-trip; a negative one compares above the capacity as unsigned. - const exact = - loopIndex === null && host.sizeType === "i64" ? exactInteger(host, index, read.index) : null; - const integerIndex = loopIndex ?? (exact ? widenInteger(host, exact) : null); + // A proven integer index (an int32 local, say), or a wide view exact at + // every index below the capacity, needs no double round-trip; a negative + // one compares above the capacity as unsigned. + const integerIndex = + loopIndex ?? (host.sizeType === "i64" ? indexInteger(host, index, read.index) : null); const slow = B.newLabel("borrow.array.slow"), join = B.newLabel("borrow.array.join"); emitDenseReferenceArrayRead( @@ -644,8 +644,12 @@ export function emitLocalArrayRead( ): { slot: string; type: IrType } | null { const B = host.B; const array = host.emitStableReceiver(read.array, [read.index]); - const integerIndex = inline ? host.emitIntegerLoopIndex(read.index) : null; + const loopIndex = inline ? host.emitIntegerLoopIndex(read.index) : null; const index = host.emitExpr(read.index); + // As in emitBorrowedArrayRead: an exact integer or wide view needs no + // round trip, and only the loop's own counter is an induction variable. + const integerIndex = + loopIndex ?? (inline && host.sizeType === "i64" ? indexInteger(host, index, read.index) : null); const nullable = read.type.kind === "union" ? host.nullableUnions.get(read.type.unionId) : null; if (nullable) { // A nullable union is the element pointer itself: borrowed from an @@ -665,7 +669,7 @@ export function emitLocalArrayRead( array, index, integerIndex, - integerIndex !== null, + loopIndex !== null, store, no, slow, @@ -726,7 +730,7 @@ export function emitLocalArrayRead( array, index, integerIndex, - integerIndex !== null, + loopIndex !== null, storeValue, no, slow, diff --git a/packages/compiler/test/array-index-product-emission.test.ts b/packages/compiler/test/array-index-product-emission.test.ts new file mode 100644 index 000000000..bfd891398 --- /dev/null +++ b/packages/compiler/test/array-index-product-emission.test.ts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-index-product-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const source = ` +function at(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y + k]; +} +function cancel(a: number[], p: number, q: number, r: number, s: number): number | undefined { + return a[p * q - r * s]; +} +const a = [1, 2, 3]; +const v = process.argv.length; +console.log(at(a, v | 0, (v + 1) | 0, -v | 0), cancel(a, v | 0, v | 0, v | 0, v | 0)); +`; + +function body(llvm: string, name: string): string { + return new RegExp(`^define [^@]*@sc_\\w+?_${name}(\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? ""; +} + +test("an int32 product plus an int32 indexes as an exact i64 without a round trip", async () => { + const llvm = await emitLlvm(source); + // The int32 entry: the index is the i64 product and sum, compared with + // the array's counts; the double goes only to the slow path's runtime call. + const at = /^define [^@]*@sc_bf_at\.i32\([^]*?^}/m.exec(llvm)?.[0] ?? ""; + const product = /(%\S+) = mul nsw i64 %\S+, %\S+/.exec(at)?.[1]; + expect(product).toBeDefined(); + expect(at).toContain(`= add nsw i64 ${product}, `); + expect(at).not.toMatch(/fptosi double %\S+ to i64/); +}); + +test("a difference of two int32 products keeps the double's round trip", async () => { + const llvm = await emitLlvm(source); + // Each product may pass 2^53, where its double rounds before the + // difference: the index keeps the exactness test of the double. + const cancel = body(llvm, "cancel"); + expect(cancel).toMatch(/fptosi double %\S+ to i64/); + expect(cancel).toMatch(/fcmp oeq double/); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json new file mode 100644 index 000000000..3239c26bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7325-array-index-products-near-2-53.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7325-array-index-products-near-2-53.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7325-array-index-products-near-2-53.ts b/tests/corpus/7325-array-index-products-near-2-53.ts new file mode 100644 index 000000000..41e60b09d --- /dev/null +++ b/tests/corpus/7325-array-index-products-near-2-53.ts @@ -0,0 +1,82 @@ +// Element reads and writes indexed by products of int32 values plus or +// minus int32 terms. The products can pass the int32 range and 2^53, where +// a double product rounds: an index that lands in an array's range reads +// and writes that element, and every other one (negative, past the length, +// rounded past 2^53) keeps the property key Node computes from the double. + +function show(label: string, value: unknown): void { + console.log(label, Object.is(value, -0) ? "-0" : String(value)); +} + +function read(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y + k]; +} + +function readBack(a: number[], x: number, y: number, k: number): number | undefined { + return a[x * y - k]; +} + +function write(a: number[], x: number, y: number, k: number, v: number): void { + a[x * y + k] = v; +} + +function rows(n: number, cols: number, a: number[]): number { + let s = 0; + for (let i = 0; i < n; i++) for (let j = 0; j < cols; j++) s += a[i * cols + j]! * (j + 1); + return s; +} + +const a: number[] = []; +for (let i = 0; i < 64; i++) a.push(i * 10); + +const big = 2147483647; +const half = 65536; + +// In range. +show("3*5+2", read(a, 3 | 0, 5 | 0, 2 | 0)); +show("7*-1+9", read(a, 7 | 0, -1 | 0, 9 | 0)); +show("-0 index", read(a, -1 | 0, 0 | 0, 0 | 0)); +// The product passes the int32 range; the difference lands in range. +show("2^16*2^15-2^31+12", readBack(a, half | 0, 32768 | 0, (-2147483648 * -1 - 12) | 0)); +show("(2^31-1)*1-(2^31-1)+5", read(a, big | 0, 1 | 0, -2147483642 | 0)); +show("2^16*2^16-big+20", readBack(a, half | 0, half | 0, (big - 20) | 0)); +// Out of range or negative: holes and named keys. +show("past end", read(a, 8 | 0, 8 | 0, 1 | 0)); +show("negative", read(a, -3 | 0, 5 | 0, 0 | 0)); +show("2^31*2^31", read(a, -2147483648 | 0, -2147483648 | 0, 7 | 0)); + +// Products around 2^53, where the double product rounds. +const keys: number[] = []; +const x = 134217727; // 2^27 - 1 +const y = 67108865; // 2^26 + 1: x * y = 2^53 + 2^26 - 1 +const near: [number, number, number][] = [ + [x, y, 0], + [x, y, 1], + [x, y, -67108864], + [x, y, -67108863], + [94906265, 94906265, 0], + [94906267, 94906267, -3], + [-2147483648, 2147483647, 2147483647], +]; +for (const [p, q, k] of near) { + const sparse: number[] = []; + write(sparse, p | 0, q | 0, k | 0, 1); + // The key the double computes; a local holds only the double. + const key: number = p * q + k; + keys.push(key); + show(`write ${p}*${q}+${k}`, `${sparse[key]} ${sparse[key + 1]} ${sparse[key - 1]} ${sparse.length}`); + show(`read ${p}*${q}+${k}`, read(sparse, p | 0, q | 0, k | 0)); +} +show("keys", keys.join(",")); + +// A difference of two products above 2^53 keeps the double's rounding. +function cancel(sparse: number[], p: number, q: number, r: number, s: number): number | undefined { + return sparse[p * q - r * s]; +} +const cancelled: number[] = [7, 8, 9]; +show("cancel", cancel(cancelled, x | 0, y | 0, x | 0, y | 0)); +show("cancel+1", cancel(cancelled, x | 0, y | 0, (9007199254740992 / 67108864) | 0, 67108864 | 0)); + +// Row-major sums with a width from JSON. +const dims = JSON.parse("[[8, 8], [4, 16], [16, 4], [3, 7]]") as [number, number][]; +for (const [n, cols] of dims) show(`rows ${n}x${cols}`, rows(n | 0, cols | 0, a)); diff --git a/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json b/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json new file mode 100644 index 000000000..f71f3eb98 --- /dev/null +++ b/tests/harness/corpus-costs/7325-array-index-products-near-2-53.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2400 } From 3cace3e785f3d4d5eb6b24ab5c15e771a13be998 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 19:36:04 -0500 Subject: [PATCH 06/39] Throw Node's TypeError for class constructors called through call/apply `C.call(thisArg, ...)` and `C.apply(thisArg, args)` on a program class that declares no static `call` or `apply` now lower as a call through the boxed class value. It evaluates the arguments in order, applies apply's argument-list checks, and throws `TypeError: Class constructor C cannot be invoked without 'new'` with the runtime class name, as Node does. Before, TypeScript reported SC2020 at compile time and JavaScript threw the standard-library fence's Error at run time. This covers ES5 constructors that call a class as their base (`function Sub() { Base.call(this); }`). Runtime-provided classes (Error, EventEmitter, streams, EventTarget), classes with runtime statics, spread arguments, and receivers other than a name, `this` or a namespace member keep their existing lowering. --- .../src/frontend/lowering/lower-classes.ts | 47 +++++++++++++- .../7480-class-call-without-new.ts.json | 6 ++ .../7481-class-call-without-new.mjs.json | 6 ++ tests/corpus/7480-class-call-without-new.ts | 60 ++++++++++++++++++ tests/corpus/7481-class-call-without-new.mjs | 63 +++++++++++++++++++ .../7480-class-call-without-new.ts.json | 1 + .../7481-class-call-without-new.mjs.json | 1 + 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-class-call-without-new.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-call-without-new.mjs.json create mode 100644 tests/corpus/7480-class-call-without-new.ts create mode 100644 tests/corpus/7481-class-call-without-new.mjs create mode 100644 tests/harness/corpus-costs/7480-class-call-without-new.ts.json create mode 100644 tests/harness/corpus-costs/7481-class-call-without-new.mjs.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b23697cab..17d48913a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6638,7 +6638,7 @@ function staticCallOn( // functions, called directly as `%C.static:m%n` — with the same // through-a-VALUE shadowing fence as plain statics. const gfound = findGenericStaticOn(lowerer, info, access.name.text); - if (!gfound) return null; + if (!gfound) return classConstructorCall(lowerer, call, access, info); if (throughValue && staticShadowBelow(info, access.name.text)) { lowerer.unsupported( "SC1090", @@ -6731,6 +6731,51 @@ function staticCallOn( return { kind: "call", callee: fnName, args, type: found.method.ret, loc }; } +/** `C.call(thisArg, ...)` / `C.apply(thisArg, args)` on a program class that + * declares no such static: Function.prototype.call/apply invoke the class + * constructor without `new`, which throws Node's TypeError "Class + * constructor C cannot be invoked without 'new'" after the arguments are + * evaluated (and, for apply, after the argument list is read). The boxed + * class value's call path throws exactly that. Neither expando statics nor + * writes through untyped values can give a class an own `call` or `apply`. + * The receiver must be one the caller does not evaluate again (a name, + * `this`, or a namespace member). Null otherwise. */ +function classConstructorCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + info: ClassInfo, +): IrExpr | null { + const name = access.name.text; + const receiver = access.expression; + if ( + (name !== "call" && name !== "apply") || + !info.decl || + info.def.runtime || + info.builtinError || + info.builtinEmitter || + info.builtinStream || + info.builtinEventTarget || + info.runtimeStatics?.length || + call.arguments.nodes.some(ts.isSpreadElement) || + !( + ts.isIdentifier(receiver) || + receiver.kind === ts.SyntaxKind.ThisKeyword || + (ts.isPropertyAccessExpression(receiver) && nsMemberIdentOf(lowerer, receiver) !== null) + ) + ) + return null; + return { + kind: "dynInvoke", + recv: lowerer.lowerExprExpecting(receiver, DYN), + method: name, + calleeName: ts.getTextOfNode(access), + args: call.arguments.nodes.map((argument) => lowerer.lowerExprExpecting(argument, DYN)), + type: DYN, + loc: locOf(call), + }; +} + /** `C.m(...)` where `m` is a static method that a function-local * JavaScript class defines at runtime (a factory's `class extends base`), * inherited by `C` through its statically known base chain. When the body diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-class-call-without-new.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-class-call-without-new.ts.json new file mode 100644 index 000000000..8e5ed83bc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-class-call-without-new.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7480-class-call-without-new.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-call-without-new.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-call-without-new.mjs.json new file mode 100644 index 000000000..7f55a233d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-call-without-new.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7481-class-call-without-new.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/7480-class-call-without-new.ts b/tests/corpus/7480-class-call-without-new.ts new file mode 100644 index 000000000..82b3b8452 --- /dev/null +++ b/tests/corpus/7480-class-call-without-new.ts @@ -0,0 +1,60 @@ +// Function.prototype.call and apply invoke a class constructor without +// `new`, which throws a TypeError naming the class after the arguments are +// evaluated. A declared static `call` answers instead. +class Base { + n = 1; +} +class Derived extends Base {} +class Pair { + left: string; + right: string; + constructor(left: string, right: string) { + this.left = left; + this.right = right; + } +} +class WithCall { + static call(value: number): number { + return value + 1; + } +} + +function report(label: string, run: () => unknown): void { + try { + run(); + console.log(label, "returned"); + } catch (error) { + console.log(label, error instanceof TypeError, (error as Error).message); + } +} + +const receiver = new Base(); +report("call", () => Base.call(receiver)); +report("apply", () => Base.apply(receiver, [])); +report("derived", () => Derived.call(new Derived())); +report("arguments", () => Pair.call(new Pair("a", "b"), "c", "d")); + +const order: string[] = []; +function effect(label: string, value: T): T { + order.push(label); + return value; +} +report("ordered", () => Pair.call(effect("this", new Pair("a", "b")), effect("left", "x"), effect("right", "y"))); +console.log(order.join(",")); + +function local(): void { + class Inner { + x = 2; + } + report("local", () => Inner.call(new Inner())); +} +local(); + +class Statics { + static run(): void { + report("static this", () => this.call(new Statics())); + } +} +Statics.run(); + +console.log(WithCall.call(4)); diff --git a/tests/corpus/7481-class-call-without-new.mjs b/tests/corpus/7481-class-call-without-new.mjs new file mode 100644 index 000000000..4734f0491 --- /dev/null +++ b/tests/corpus/7481-class-call-without-new.mjs @@ -0,0 +1,63 @@ +// An ES5 constructor that calls a class constructor as its base, and the +// other ways of calling a class without `new`, throw Node's TypeError. +class Base { + constructor() { + this.n = 1; + } +} + +function report(label, run) { + try { + run(); + console.log(label, "returned"); + } catch (error) { + console.log(label, error instanceof TypeError, error.message); + } +} + +function Sub() { + Base.call(this); + this.m = 2; +} +report("es5 subclass", () => new Sub()); + +function Applied() { + Base.apply(this, arguments); +} +report("es5 apply", () => new Applied(1, 2)); + +report("apply null", () => Base.apply(null, null)); +report("apply non-object", () => Base.apply(null, 5)); +report("apply array-like", () => Base.apply({}, { length: 1, 0: "a" })); + +const order = []; +function effect(label, value) { + order.push(label); + return value; +} +report("ordered", () => Base.call(effect("this", {}), effect("a", 1), effect("b", 2))); +console.log(order.join(",")); + +function viaValue(C) { + report("value " + C.name, () => C.call({})); +} +class Derived extends Base {} +viaValue(Base); +viaValue(Derived); + +class Configured {} +Configured.defaults = { size: 1 }; +function Wrapped() { + Configured.call(this, Configured.defaults); +} +report("expando statics", () => new Wrapped()); + +const Anonymous = class {}; +report("anonymous", () => Anonymous.call({})); + +class WithCall { + static call(value) { + return "own " + value; + } +} +console.log(WithCall.call(4)); diff --git a/tests/harness/corpus-costs/7480-class-call-without-new.ts.json b/tests/harness/corpus-costs/7480-class-call-without-new.ts.json new file mode 100644 index 000000000..f14d666a4 --- /dev/null +++ b/tests/harness/corpus-costs/7480-class-call-without-new.ts.json @@ -0,0 +1 @@ +{ "plain": 3114, "san": 2571 } diff --git a/tests/harness/corpus-costs/7481-class-call-without-new.mjs.json b/tests/harness/corpus-costs/7481-class-call-without-new.mjs.json new file mode 100644 index 000000000..cecf59f78 --- /dev/null +++ b/tests/harness/corpus-costs/7481-class-call-without-new.mjs.json @@ -0,0 +1 @@ +{ "plain": 2315, "san": 2145 } From ac940ed936ad5c906812fac1b10555c155a52fa9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 15:30:16 -0700 Subject: [PATCH 07/39] Support new.target in class constructors Inside a class constructor new.target is the class of the instance under construction. A compiled new allocates the instance with its concrete class before the constructor chain runs, and the class hierarchy is closed, so new.target lowers to a static dispatch over the constructor's class and its descendants (deepest instanceof first). new.target.name reads the exact class's name and new.target === C is an exact-class test, without a class value, so subclasses may construct with other signatures. new.target as a class value requires every subclass to share the constructor's signature. Plain functions and function-local subclasses keep explicit refusals. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 13 +- .../src/frontend/lowering/new-target.ts | 219 ++++++++++++++++++ .../7362-new-target-constructors.ts.json | 6 + .../7363-new-target-generic-registry.ts.json | 6 + .../new-target-local-subclass.ts.json | 6 + .../new-target-plain-function.ts.json | 6 + .../new-target-value-signatures.ts.json | 6 + tests/corpus/7362-new-target-constructors.ts | 55 +++++ .../7363-new-target-generic-registry.ts | 32 +++ .../diagnostics/new-target-local-subclass.ts | 5 + .../diagnostics/new-target-plain-function.ts | 3 + .../new-target-value-signatures.ts | 5 + .../new-target-local-subclass.ts.txt | 6 + .../new-target-plain-function.ts.txt | 6 + .../new-target-value-signatures.ts.txt | 6 + .../7362-new-target-constructors.ts.json | 1 + .../7363-new-target-generic-registry.ts.json | 1 + 17 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/new-target.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-local-subclass.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-plain-function.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-signatures.ts.json create mode 100644 tests/corpus/7362-new-target-constructors.ts create mode 100644 tests/corpus/7363-new-target-generic-registry.ts create mode 100644 tests/diagnostics/new-target-local-subclass.ts create mode 100644 tests/diagnostics/new-target-plain-function.ts create mode 100644 tests/diagnostics/new-target-value-signatures.ts create mode 100644 tests/harness/__snapshots__/new-target-local-subclass.ts.txt create mode 100644 tests/harness/__snapshots__/new-target-plain-function.ts.txt create mode 100644 tests/harness/__snapshots__/new-target-value-signatures.ts.txt create mode 100644 tests/harness/corpus-costs/7362-new-target-constructors.ts.json create mode 100644 tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index ac8b02b69..2bde5f2aa 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,4 +1,10 @@ import { keyedHelperName } from "./stable-names.js"; +import { + isNewTarget, + lowerNewTarget, + lowerNewTargetComparison, + lowerNewTargetName, +} from "./new-target.js"; import { lowerWebAssemblyValue, refuseWebAssemblyUse } from "./builtins/webassembly.js"; import { lowerAwaitedUniformPromiseAll } from "./builtins/promises.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js"; @@ -713,7 +719,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isPrefixUnaryExpression(expr)) return lowerer.lowerPrefixUnary(expr); // `x++` / `x--` in expression position: yields the OLD value. if (ts.isPostfixUnaryExpression(expr)) return lowerIncDec(lowerer, expr, false); - if (ts.isBinaryExpression(expr)) return lowerer.lowerBinary(expr); + if (ts.isBinaryExpression(expr)) + return lowerNewTargetComparison(lowerer, expr) ?? lowerer.lowerBinary(expr); if (ts.isCallExpression(expr)) { // `h.return?.()` on a helper object: the method exists, so the // optional call is the call. @@ -729,13 +736,15 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { if (ts.isElementAccessExpression(expr)) return lowerer.lowerElementAccess(expr); if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr); - if (ts.isPropertyAccessExpression(expr)) return lowerPropertyAccessExpr(lowerer, expr, loc); + if (ts.isPropertyAccessExpression(expr)) + return lowerNewTargetName(lowerer, expr) ?? lowerPropertyAccessExpr(lowerer, expr, loc); // Meta-properties, named: `new.target` reflects HOW a function was // invoked (compiled functions are never constructors of themselves — // no runtime invocation record exists), and `import.meta`/ // `import.defer` are module-loader surface a native binary does not // carry. + if (isNewTarget(expr)) return lowerNewTarget(lowerer, expr); if (ts.isMetaProperty(expr)) { const name = expr.keywordToken === ts.SyntaxKind.NewKeyword ? "new.target" : `import.${expr.name.text}`; diff --git a/packages/compiler/src/frontend/lowering/new-target.ts b/packages/compiler/src/frontend/lowering/new-target.ts new file mode 100644 index 000000000..8b5f5858a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/new-target.ts @@ -0,0 +1,219 @@ +/* `new.target`. Compiled code has no invocation record, but inside a class + * constructor `new.target` is always the class of the object under + * construction: a compiled `new` allocates the instance with its concrete + * class before any constructor of the chain runs, and every constructor of + * the chain receives that instance as `this`. The program's class + * hierarchy is closed, so the value is a static dispatch over the + * constructor's class and its descendants: the deepest class the instance + * is an instance of is its exact class. + * + * The checker rejects `new.target` in methods, accessors, static blocks and + * field initializers. Plain functions (constructible in JavaScript) keep + * the refusal. */ +import * as ts from "../ts7/adapter.js"; +import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import type { ClassInfo } from "./lower-classes.js"; +import { classValueRef } from "./lower-classes.js"; + +export function isNewTarget(node: ts.Node): node is ts.MetaProperty { + return ( + ts.isMetaProperty(node) && + node.keywordToken === ts.SyntaxKind.NewKeyword && + node.name.text === "target" + ); +} + +/** Where `new.target` is bound: a class constructor, or a plain function + * (refused). */ +type Binding = + | { kind: "constructor"; declaration: ts.ConstructorDeclaration } + | { kind: "function" }; + +function bindingOf(node: ts.Node): Binding { + for (let p = node.parent; p !== undefined; p = p.parent) { + if (ts.isArrowFunction(p)) continue; + if (ts.isConstructorDeclaration(p)) return { kind: "constructor", declaration: p }; + if (ts.isFunctionLike(p) || ts.isSourceFile(p)) return { kind: "function" }; + } + return { kind: "function" }; +} + +interface Dispatch { + info: ClassInfo; + /** The constructor's class and its descendants, deepest first. */ + classes: ClassInfo[]; + self: IrExpr | null; +} + +function depth(info: ClassInfo): number { + let d = 0; + for (let c = info.base; c; c = c.base) d++; + return d; +} + +/** The classes `new.target` can denote in the constructor being lowered, + * or null after a diagnostic. */ +function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { + const binding = bindingOf(expr); + if (binding.kind !== "constructor") return null; + const info = lowerer.currentClass; + const cls = binding.declaration.parent; + if (!info || (info.decl !== null && info.decl !== cls)) { + lowerer.unsupported( + "SC1090", + expr, + "'new.target' in this constructor (its class has no lowering here)", + ); + return null; + } + const classes = [info]; + for (const other of lowerer.classes.values()) { + if (other === info || other.generic) continue; + if (lowerer.isSubclassOf(other.def.name, info.def.name)) classes.push(other); + } + for (const c of classes) { + if (c.localClass) { + lowerer.unsupported( + "SC1090", + expr, + `'new.target' where '${c.def.jsName ?? c.def.name}' is a function-local class (each evaluation creates a new class)`, + ); + return null; + } + } + classes.sort((a, b) => depth(b) - depth(a)); + let self: IrExpr | null = null; + if (classes.length > 1) { + const thisLocal = lowerer.resolveThis(expr); + if (!thisLocal || thisLocal.type.kind !== "object") { + lowerer.unsupported( + "SC1090", + expr, + "'new.target' where the constructor's 'this' has no class representation", + ); + return null; + } + self = varRef(thisLocal.id, thisLocal.type, locOf(expr)); + } + return { info, classes, self }; +} + +/** A conditional chain selecting `arm(D)` for the exact class D of the + * instance under construction. */ +function select( + dispatch: Dispatch, + type: IrType, + loc: SrcLoc, + arm: (info: ClassInfo) => IrExpr, +): IrExpr { + const { classes, self } = dispatch; + let result = arm(classes[classes.length - 1]!); + for (let i = classes.length - 2; i >= 0; i--) { + const c = classes[i]!; + result = { + kind: "ternary", + cond: { kind: "instanceOf", value: self!, className: c.def.name, type: BOOL, loc }, + then: arm(c), + else_: result, + type, + loc, + }; + } + return result; +} + +function refuseFunction(lowerer: Lowerer, expr: ts.MetaProperty): never { + return lowerer.unsupported( + "SC1090", + expr, + "'new.target' in a plain function (a constructible function's invocation record does not exist in compiled code; use a class constructor)", + ); +} + +/** `new.target` as a value. */ +export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr { + const loc = locOf(expr); + if (bindingOf(expr).kind === "function") refuseFunction(lowerer, expr); + const dispatch = dispatchOf(lowerer, expr)!; + const type: IrType = { kind: "classval", className: dispatch.info.def.name }; + for (const c of dispatch.classes) { + if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) { + lowerer.unsupported( + "SC1090", + expr, + `'new.target' as a value where '${c.def.jsName ?? c.def.name}' constructs with a different signature than '${dispatch.info.def.jsName ?? dispatch.info.def.name}' (read 'new.target.name' or compare 'new.target' with a class instead)`, + ); + } + } + return select(dispatch, type, loc, (c) => + lowerer.coerceToExpected(classValueRef(lowerer, c, expr), type), + ); +} + +/** `new.target.name`: the exact class's name, without materializing a + * class value (subclasses may construct with other signatures). */ +export function lowerNewTargetName( + lowerer: Lowerer, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (access.questionDotToken || access.name.text !== "name") return null; + let target: ts.Expression = access.expression; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (!isNewTarget(target) || bindingOf(target).kind !== "constructor") return null; + const dispatch = dispatchOf(lowerer, target); + if (!dispatch) return null; + const loc = locOf(access); + return select(dispatch, STRING, loc, (c) => ({ + kind: "libCall", + fn: "class.name", + args: [classValueRef(lowerer, c, target)], + type: STRING, + loc, + })); +} + +/** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a + * program class named by the other operand: an exact-class test. */ +export function lowerNewTargetComparison( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const op = expr.operatorToken.kind; + const negated = + op === ts.SyntaxKind.ExclamationEqualsEqualsToken || + op === ts.SyntaxKind.ExclamationEqualsToken; + if ( + !negated && + op !== ts.SyntaxKind.EqualsEqualsEqualsToken && + op !== ts.SyntaxKind.EqualsEqualsToken + ) + return null; + const strip = (e: ts.Expression): ts.Expression => { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; + }; + const left = strip(expr.left); + const right = strip(expr.right); + const target = isNewTarget(left) ? left : isNewTarget(right) ? right : null; + if (!target || bindingOf(target).kind !== "constructor") return null; + const other = target === left ? right : left; + if (!ts.isIdentifier(other)) return null; + // A constructor's new.target is never undefined. + if (other.text === "undefined" && (lowerer.typeOf(other).flags & ts.TypeFlags.Undefined) !== 0) + return { kind: "boolLit", value: negated, type: BOOL, loc: locOf(expr) }; + const symbol = lowerer.resolveValueSymbol(other); + const named = symbol ? lowerer.classBySymbol.get(symbol) : undefined; + if (!named || named.generic || named.localClass) return null; + const dispatch = dispatchOf(lowerer, target); + if (!dispatch) return null; + const loc = locOf(expr); + return select(dispatch, BOOL, loc, (c) => ({ + kind: "boolLit", + value: (c === named) !== negated, + type: BOOL, + loc, + })); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json new file mode 100644 index 000000000..f38c901e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7362-new-target-constructors.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json new file mode 100644 index 000000000..a1c8cb535 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7363-new-target-generic-registry.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-local-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-local-subclass.ts.json new file mode 100644 index 000000000..4f597a8a2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-local-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-local-subclass.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-plain-function.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-plain-function.ts.json new file mode 100644 index 000000000..9f424457c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-plain-function.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-plain-function.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-signatures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-signatures.ts.json new file mode 100644 index 000000000..249f8f4bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-signatures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-value-signatures.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7362-new-target-constructors.ts b/tests/corpus/7362-new-target-constructors.ts new file mode 100644 index 000000000..9e94200bf --- /dev/null +++ b/tests/corpus/7362-new-target-constructors.ts @@ -0,0 +1,55 @@ +// new.target in class constructors: the class under construction, through +// inherited and explicit subclass constructors, Error subclasses, arrows, +// identity comparisons and class values. +abstract class AppError extends Error { + abstract readonly status: number; + constructor(message: string) { + super(message); + this.name = new.target.name; + } +} +class NotFound extends AppError { + readonly status = 404; +} +class Invalid extends AppError { + readonly status = 400; + readonly field: string; + constructor(field: string, detail: number) { + super(`bad ${field} ${detail}`); + this.field = field; + } +} +class Strict extends Invalid {} +const errors: AppError[] = [new NotFound("missing"), new Invalid("isbn", 3), new Strict("title", 1)]; +for (const e of errors) console.log(e.name, e.status, e.message, String(e), e instanceof Invalid); + +class Shape { + readonly kind: string; + readonly exact: boolean; + constructor() { + this.kind = new.target.name; + this.exact = new.target === Shape; + const probe = () => new.target !== Circle; + console.log("probe", probe(), new.target == Square, new.target === undefined); + } + describe(): string { + return `${this.kind}${this.exact ? " (exact)" : ""}`; + } +} +class Circle extends Shape {} +class Square extends Shape { + constructor() { + super(); + console.log("square ctor sees", new.target.name); + } +} +for (const s of [new Shape(), new Circle(), new Square()]) console.log(s.describe()); + +class Node2 { + static make(): Node2 { return new this(); } + readonly origin: typeof Node2; + constructor() { this.origin = new.target; } + where(): string | undefined { return typeof (() => 1)() === "number" ? String(this.origin.name) : undefined; } +} +class Leaf extends Node2 {} +console.log(new Node2().origin === Node2, new Leaf().origin === Leaf, new Leaf().where(), Node2.make().origin.name); diff --git a/tests/corpus/7363-new-target-generic-registry.ts b/tests/corpus/7363-new-target-generic-registry.ts new file mode 100644 index 000000000..17866d143 --- /dev/null +++ b/tests/corpus/7363-new-target-generic-registry.ts @@ -0,0 +1,32 @@ +// new.target in generic classes and as a construction registry key. +class Box { + readonly label: string; + readonly value: T; + constructor(value: T) { + this.value = value; + this.label = `${new.target.name}<${typeof value}>`; + } +} +class NumBox extends Box { + constructor(n: number) { + super(n * 2); + } +} +const a = new Box("s"); +const b = new NumBox(4); +console.log(a.label, b.label, b.value); + +class Registry { + static readonly created: string[] = []; + constructor() { + Registry.created.push(new.target.name); + if (new.target === Registry) console.log("plain registry"); + } +} +class Users extends Registry {} +class Books extends Registry {} +new Users(); +new Books(); +new Registry(); +new Books(); +console.log(Registry.created.join(",")); diff --git a/tests/diagnostics/new-target-local-subclass.ts b/tests/diagnostics/new-target-local-subclass.ts new file mode 100644 index 000000000..a5f153049 --- /dev/null +++ b/tests/diagnostics/new-target-local-subclass.ts @@ -0,0 +1,5 @@ +// A function-local subclass makes a new class per evaluation, so the +// base constructor's new.target has no static dispatch. +class A { constructor() { console.log(new.target.name); } } +function make() { class Local extends A {} return new Local(); } +make(); diff --git a/tests/diagnostics/new-target-plain-function.ts b/tests/diagnostics/new-target-plain-function.ts new file mode 100644 index 000000000..da592e084 --- /dev/null +++ b/tests/diagnostics/new-target-plain-function.ts @@ -0,0 +1,3 @@ +// new.target in a plain function has no invocation record to read. +function F(this: unknown) { console.log(new.target === undefined); } +F.call(null); diff --git a/tests/diagnostics/new-target-value-signatures.ts b/tests/diagnostics/new-target-value-signatures.ts new file mode 100644 index 000000000..af0c906e5 --- /dev/null +++ b/tests/diagnostics/new-target-value-signatures.ts @@ -0,0 +1,5 @@ +// new.target as a class value needs every subclass to construct with the +// base's signature; B's constructor takes a parameter A's does not. +class A { readonly t: typeof A; constructor() { this.t = new.target; } } +class B extends A { constructor(readonly n: number) { super(); } } +console.log(new B(1).t.name); diff --git a/tests/harness/__snapshots__/new-target-local-subclass.ts.txt b/tests/harness/__snapshots__/new-target-local-subclass.ts.txt new file mode 100644 index 000000000..0f1d465b1 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-local-subclass.ts.txt @@ -0,0 +1,6 @@ +new-target-local-subclass.ts:3:39 - error SC1090: 'new.target' where 'Local' is a function-local class (each evaluation creates a new class) is not supported yet + + 2 | // base constructor's new.target has no static dispatch. + 3 | class A { constructor() { console.log(new.target.name); } } + | ^~~~~~~~~~ + 4 | function make() { class Local extends A {} return new Local(); } \ No newline at end of file diff --git a/tests/harness/__snapshots__/new-target-plain-function.ts.txt b/tests/harness/__snapshots__/new-target-plain-function.ts.txt new file mode 100644 index 000000000..5005f5915 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-plain-function.ts.txt @@ -0,0 +1,6 @@ +new-target-plain-function.ts:2:41 - error SC1090: 'new.target' in a plain function (a constructible function's invocation record does not exist in compiled code; use a class constructor) is not supported yet + + 1 | // new.target in a plain function has no invocation record to read. + 2 | function F(this: unknown) { console.log(new.target === undefined); } + | ^~~~~~~~~~ + 3 | F.call(null); \ No newline at end of file diff --git a/tests/harness/__snapshots__/new-target-value-signatures.ts.txt b/tests/harness/__snapshots__/new-target-value-signatures.ts.txt new file mode 100644 index 000000000..9c38e4d51 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-value-signatures.ts.txt @@ -0,0 +1,6 @@ +new-target-value-signatures.ts:3:58 - error SC1090: 'new.target' as a value where 'B' constructs with a different signature than 'A' (read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet + + 2 | // base's signature; B's constructor takes a parameter A's does not. + 3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } } + | ^~~~~~~~~~ + 4 | class B extends A { constructor(readonly n: number) { super(); } } \ No newline at end of file diff --git a/tests/harness/corpus-costs/7362-new-target-constructors.ts.json b/tests/harness/corpus-costs/7362-new-target-constructors.ts.json new file mode 100644 index 000000000..2c7339a24 --- /dev/null +++ b/tests/harness/corpus-costs/7362-new-target-constructors.ts.json @@ -0,0 +1 @@ +{ "plain": 6000, "san": 26000 } diff --git a/tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json b/tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json new file mode 100644 index 000000000..eceea988d --- /dev/null +++ b/tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 23500 } From f210b1102abf9c57e229d7610567cca9dd45c287 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 16:38:34 -0700 Subject: [PATCH 08/39] Run zod 4's core constructor factory in the dynamic tier zod 4 builds every schema through `$constructor`, a factory of JavaScript patterns the dynamic tier refused or answered wrongly. Each is fixed generally: - `"key" in BuiltinCtor` (`"captureStackTrace" in Error`) folds to Node's answer from a table of the builtin constructor globals' keys. - A class whose heritage evaluates to Object or Error at runtime (`class D extends (params?.Parent ?? Object)`): Object means no heritage; Error links Error.prototype and super() installs message, cause and stack. - `Object.defineProperty` on a class value boxes to its constructor, and a JavaScript class targeted by defineProperty gets a live static table. - A Symbol.hasInstance defined on a function's own table answers untyped `instanceof`; an error constructor held as a value answers by error kind. - A builtin error constructor stored in a slot of its class value type (`{ Parent: Error }`) is the class value, not a checked cast that fails. - A function hoisted above a local class it references captures the class binding as a TDZ box the class statement initializes. - Names a program stores on the global object read back from it instead of folding to undefined. - A module namespace whose exports never change converts to an untyped object (`{ ...ns }`). - An untyped call of a String-only method name (`x.trim()`) dispatches on the receiver's runtime kind, so objects defining that member work. Co-Authored-By: Claude Opus 5.5 --- .../builtins/global-namespace-keys.ts | 123 +++++++++++++++++ .../lowering/expressions/identifiers.ts | 7 + .../lowering/expressions/property-access.ts | 41 ++++++ .../lowering/expressions/relational.ts | 11 ++ .../src/frontend/lowering/lower-calls.ts | 13 +- .../src/frontend/lowering/lower-classes.ts | 43 +++++- .../src/frontend/lowering/lower-stmts.ts | 46 +++++++ .../compiler/src/frontend/lowering/lowerer.ts | 24 ++++ .../lowering/module-namespace-elements.ts | 82 ++++++++++- .../7480-builtin-constructor-in.js.json | 6 + ...lass-runtime-heritage-object-error.js.json | 6 + ...ine-property-function-class-values.js.json | 6 + .../7483-constructor-factory-traits.js.json | 6 + ...sted-function-captures-later-class.js.json | 6 + .../7485-global-object-own-properties.js.json | 6 + ...otype-accessor-string-method-names.js.json | 6 + .../main.js.json | 8 ++ packages/runtime/src/scr_dyn.h | 2 + packages/runtime/src/scr_dyn_builtin_class.c | 2 + packages/runtime/src/scr_json.c | 128 ++++++++++++++++++ packages/runtime/src/scr_symbol.c | 5 + tests/corpus/7480-builtin-constructor-in.js | 9 ++ ...481-class-runtime-heritage-object-error.js | 24 ++++ ...2-define-property-function-class-values.js | 35 +++++ .../corpus/7483-constructor-factory-traits.js | 58 ++++++++ ...4-hoisted-function-captures-later-class.js | 23 ++++ .../7485-global-object-own-properties.js | 22 +++ ...-prototype-accessor-string-method-names.js | 53 ++++++++ .../7487-namespace-spread-untyped/extra.js | 2 + .../7487-namespace-spread-untyped/main.js | 11 ++ .../7487-namespace-spread-untyped/shapes.js | 4 + .../7480-builtin-constructor-in.js.json | 1 + ...lass-runtime-heritage-object-error.js.json | 1 + ...ine-property-function-class-values.js.json | 1 + .../7483-constructor-factory-traits.js.json | 1 + ...sted-function-captures-later-class.js.json | 1 + .../7485-global-object-own-properties.js.json | 1 + ...otype-accessor-string-method-names.js.json | 1 + .../main.js.json | 1 + 39 files changed, 821 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/builtins/global-namespace-keys.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-builtin-constructor-in.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-runtime-heritage-object-error.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7482-define-property-function-class-values.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7483-constructor-factory-traits.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7484-hoisted-function-captures-later-class.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7485-global-object-own-properties.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7486-prototype-accessor-string-method-names.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json create mode 100644 tests/corpus/7480-builtin-constructor-in.js create mode 100644 tests/corpus/7481-class-runtime-heritage-object-error.js create mode 100644 tests/corpus/7482-define-property-function-class-values.js create mode 100644 tests/corpus/7483-constructor-factory-traits.js create mode 100644 tests/corpus/7484-hoisted-function-captures-later-class.js create mode 100644 tests/corpus/7485-global-object-own-properties.js create mode 100644 tests/corpus/7486-prototype-accessor-string-method-names.js create mode 100644 tests/corpus/7487-namespace-spread-untyped/extra.js create mode 100644 tests/corpus/7487-namespace-spread-untyped/main.js create mode 100644 tests/corpus/7487-namespace-spread-untyped/shapes.js create mode 100644 tests/harness/corpus-costs/7480-builtin-constructor-in.js.json create mode 100644 tests/harness/corpus-costs/7481-class-runtime-heritage-object-error.js.json create mode 100644 tests/harness/corpus-costs/7482-define-property-function-class-values.js.json create mode 100644 tests/harness/corpus-costs/7483-constructor-factory-traits.js.json create mode 100644 tests/harness/corpus-costs/7484-hoisted-function-captures-later-class.js.json create mode 100644 tests/harness/corpus-costs/7485-global-object-own-properties.js.json create mode 100644 tests/harness/corpus-costs/7486-prototype-accessor-string-method-names.js.json create mode 100644 tests/harness/corpus-costs/7487-namespace-spread-untyped/main.js.json diff --git a/packages/compiler/src/frontend/lowering/builtins/global-namespace-keys.ts b/packages/compiler/src/frontend/lowering/builtins/global-namespace-keys.ts new file mode 100644 index 000000000..bb364ee55 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/global-namespace-keys.ts @@ -0,0 +1,123 @@ +/* The string-keyed properties Node 24 exposes on its builtin constructor + * and namespace globals, own and inherited, so `"key" in Error` (a feature + * probe such as `"captureStackTrace" in Error`) answers statically. The + * global must be the unshadowed builtin; compiled programs cannot add + * properties to these objects, so the answer is fixed. */ + +const FUNCTION_PROTOTYPE = [ + "length", + "name", + "arguments", + "caller", + "constructor", + "apply", + "bind", + "call", + "toString", +]; +const OBJECT_PROTOTYPE = [ + "constructor", + "__defineGetter__", + "__defineSetter__", + "hasOwnProperty", + "__lookupGetter__", + "__lookupSetter__", + "isPrototypeOf", + "propertyIsEnumerable", + "toString", + "valueOf", + "__proto__", + "toLocaleString", +]; +const CONSTRUCTOR_OWN = ["length", "name", "prototype"]; +const ERROR_OWN = [ + ...CONSTRUCTOR_OWN, + "captureStackTrace", + "prepareStackTrace", + "isError", + "stackTraceLimit", +]; + +/** Own string keys per global, beyond what the prototype chain adds. */ +const CONSTRUCTORS: Readonly> = { + Error: ERROR_OWN, + // The native error constructors inherit from Error. + TypeError: ERROR_OWN, + RangeError: ERROR_OWN, + SyntaxError: ERROR_OWN, + ReferenceError: ERROR_OWN, + EvalError: ERROR_OWN, + URIError: ERROR_OWN, + Object: [ + ...CONSTRUCTOR_OWN, + "assign", + "getOwnPropertyDescriptor", + "getOwnPropertyDescriptors", + "getOwnPropertyNames", + "getOwnPropertySymbols", + "hasOwn", + "is", + "preventExtensions", + "seal", + "create", + "defineProperties", + "defineProperty", + "freeze", + "getPrototypeOf", + "setPrototypeOf", + "isExtensible", + "isFrozen", + "isSealed", + "keys", + "entries", + "fromEntries", + "values", + "groupBy", + ], + Array: [...CONSTRUCTOR_OWN, "isArray", "from", "fromAsync", "of"], + Promise: [ + ...CONSTRUCTOR_OWN, + "all", + "allSettled", + "any", + "race", + "resolve", + "reject", + "withResolvers", + "try", + ], + Number: [ + ...CONSTRUCTOR_OWN, + "isFinite", + "isInteger", + "isNaN", + "isSafeInteger", + "parseFloat", + "parseInt", + "MAX_VALUE", + "MIN_VALUE", + "NaN", + "NEGATIVE_INFINITY", + "POSITIVE_INFINITY", + "MAX_SAFE_INTEGER", + "MIN_SAFE_INTEGER", + "EPSILON", + ], + String: [...CONSTRUCTOR_OWN, "fromCharCode", "fromCodePoint", "raw"], + Map: [...CONSTRUCTOR_OWN, "groupBy"], + Set: CONSTRUCTOR_OWN, + WeakMap: CONSTRUCTOR_OWN, + WeakSet: CONSTRUCTOR_OWN, + Boolean: CONSTRUCTOR_OWN, + Function: CONSTRUCTOR_OWN, + Date: [...CONSTRUCTOR_OWN, "now", "parse", "UTC"], + BigInt: [...CONSTRUCTOR_OWN, "asUintN", "asIntN"], +}; + +/** Whether Node answers `key in ` with true, or null when `name` is + * not a tabled global. */ +export function builtinGlobalHasKey(name: string, key: string): boolean | null { + const own = CONSTRUCTORS[name]; + if (own === undefined) return null; + return own.includes(key) || FUNCTION_PROTOTYPE.includes(key) || OBJECT_PROTOTYPE.includes(key); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index eca3c33f5..e0f43cc4a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -1,4 +1,5 @@ /* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */ +import { predeclareForwardClass } from "../lower-stmts.js"; import { cjsMemberAliasValueOf } from "../cjs-exports-object.js"; import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js"; import * as ts from "../../ts7/adapter.js"; @@ -862,6 +863,12 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: { const sym = lowerer.resolveValueSymbol(expr); const classInfo = sym ? lowerer.classBySymbol.get(sym) : undefined; + // A local class referenced from a function hoisted above its statement: + // its TDZ box, holding the class as a checked-dynamic constructor. + if (!classInfo && sym && predeclareForwardClass(lowerer, sym)) { + const local = lowerer.resolveLocal(expr); + if (local) return { kind: "varRef", localId: local.id, type: local.type, loc }; + } // A GENERIC class name whose CONTEXTUAL type pins one instantiation // (`const B: new (v: string) => Counter = Counter`): the // value is that instantiation's class object — the generic-fn diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 5df3c7fe0..d1b9795d5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -553,6 +553,19 @@ export function lowerPropertyAccessExpr( expr.name.text, ) ) { + // A name the program stores on the global object + // (`(_a = globalThis).__config ?? (_a.__config = {})`) reads back from + // the global object's own properties. + const globalType = lowerer.typeOf(staticallySelected(lowerer, expr.expression)); + if (globalThisWrittenNames(lowerer, globalType).has(expr.name.text)) { + return { + kind: "dynKeyGet", + value: lowerer.coerceToExpected(lowerer.lowerExpr(expr.expression), DYN), + key: { kind: "strLit", value: expr.name.text, type: STRING, loc }, + type: DYN, + loc, + }; + } return dynUndefinedExpr(loc); } // `Uint8Array.prototype.toBase64` and its siblings, the base64/hex @@ -4490,3 +4503,31 @@ function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | null { } return count; } + +const globalThisWrites = new WeakMap>(); + +/** The property names the program assigns on the global object, through + * `globalThis` or a binding of its type (`(_a = globalThis).x = v`). */ +function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set { + const known = globalThisWrites.get(lowerer); + if (known) return known; + const names = new Set(); + globalThisWrites.set(lowerer, names); + for (const sf of lowerer.program.getImplementationSourceFiles()) { + if (sf.isDeclarationFile || !sf.text.includes("globalThis")) continue; + ts.walkPreorder(sf, (node) => { + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.EqualsToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionEqualsToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken || + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandEqualsToken) && + ts.isPropertyAccessExpression(node.left) && + lowerer.typeOf(node.left.expression) === globalType + ) + names.add(node.left.name.text); + return undefined; + }); + } + return names; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 759687ff0..24a582746 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -51,6 +51,7 @@ import { isJsSourceFile, locOf } from "../../program.js"; import { checkedClassInstanceOf } from "../class-construction.js"; import { unsupportedDiag } from "../../../diagnostics/diagnostic.js"; import { globalSymbolKey } from "./global-symbols.js"; +import { builtinGlobalHasKey } from "../builtins/global-namespace-keys.js"; import { foldedStringKeyOf } from "./object-literals.js"; import { NARROW_FIRST, sideEffectFreeOptionValue } from "../surfaces.js"; import { isExecExceptionType, refuseExecExceptionCode } from "../builtins/errors.js"; @@ -1426,6 +1427,16 @@ export function lowerInExpression( "'in' with computed (non-literal) keys (a const whose type is one string literal folds; a string-typed key answers over index-signature record receivers)", ); } + // A feature probe on a builtin constructor global (`"captureStackTrace" + // in Error`): the global is a fixed object, so the answer is static. + { + let recvNode = expr.right; + while (ts.isParenthesizedExpression(recvNode)) recvNode = recvNode.expression; + if (ts.isIdentifier(recvNode) && lowerer.isStdlibGlobal(recvNode, recvNode.text)) { + const has = builtinGlobalHasKey(recvNode.text, key); + if (has !== null) return { kind: "boolLit", value: has, type: BOOL, loc }; + } + } // A receiver re-read (the static folds below) is safe for plain reads // AND for side-effect-free expressions (an object literal of literals // — the `"a" in { a: true }` shape). diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c0406e77b..42e3a20cf 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3804,11 +3804,12 @@ function lowerDynReceiverMethodCall( if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) { // An object-typed receiver the member is missing from (a JavaScript // module object whose members are assigned in a function body, - // `common.repeat(" ", n)`) holds a string or an object with that + // `common.repeat(" ", n)`), or an untyped receiver (an object whose + // prototype defines `trim`), holds a string or an object with that // member: dispatch on the receiver's runtime kind. if ( - !checkerUntyped && - (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 && + (checkerUntyped || + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && !call.questionDotToken && !access.questionDotToken && !call.arguments.nodes.some(ts.isSpreadElement) @@ -8233,6 +8234,12 @@ function lowerObjectStaticCall( ...(fnName !== null ? { fnName } : {}), }; } + // A class value (`Object.defineProperty(Definition, "name", { value })`) + // boxes to its constructor function, whose own-property table holds the + // definition for every alias of the class. + if (target?.type.kind === "classval" && lowerer.dynConvertible(target.type)) { + target = { kind: "dynFrom", value: target, type: DYN, loc: locOf(call.arguments.nodes[0]!) }; + } const descriptorPrimitive = member === "getOwnPropertyDescriptor" && target && diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b23697cab..d283067cd 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -5,6 +5,7 @@ import { isDatePrototypeMethod, lowerDateSuperMethodCall, } from "./builtins/date-classes.js"; +import { predeclareForwardClass } from "./lower-stmts.js"; import { lowerDateStaticCall } from "./builtins/dates.js"; import { keyedHelperName } from "./stable-names.js"; import { lowerValueReturningConstructorNew } from "./js-dynamic-objects.js"; @@ -2355,6 +2356,7 @@ export function collectClassShapeInner( const checkedStatics = isJsSourceFile(ts.getSourceFileOfNode(decl)) && ((base && hasRuntimeStatics(base)) || + definedThroughReflection(lowerer, decl) || decl.members.nodes.some( (member) => ts.isAccessor(member) && @@ -5476,7 +5478,12 @@ export function classValueRef(lowerer: Lowerer, info: ClassInfo, blame: ts.Node) const display = info.def.name.replace(/^%|^%m\d+\./, ""); fenceDecorationThrows(lowerer, info, blame); if (info.localClass && ts.isIdentifier(blame)) { - const stored = lowerer.resolveLocal(blame); + let stored = lowerer.resolveLocal(blame); + // A reference from a function hoisted above the class statement. + if (!stored) { + const symbol = lowerer.resolveValueSymbol(blame); + if (symbol && predeclareForwardClass(lowerer, symbol)) stored = lowerer.resolveLocal(blame); + } if (stored) return lowerer.coerceToExpected( { kind: "varRef", localId: stored.id, type: stored.type, loc: locOf(blame) }, @@ -11152,3 +11159,37 @@ function genericFieldFnNodeOf( } return null; } + +/** Whether a JavaScript class's own binding is the target of + * `Object.defineProperty`/`defineProperties` in its file + * (`Object.defineProperty(Definition, "name", { value })`): its constructor + * then needs a live own-property table. */ +function definedThroughReflection(lowerer: Lowerer, decl: ts.ClassLikeDeclaration): boolean { + const name = decl.name; + const sf = ts.getSourceFileOfNode(decl); + if (name === undefined || !sf.text.includes("definePropert")) return false; + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol === undefined) return false; + let found = false; + ts.walkPreorder(sf, (node) => { + if (found) return "stop"; + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + (node.expression.name.text === "defineProperty" || + node.expression.name.text === "defineProperties") && + lowerer.stdlibGlobalMember(node.expression, "Object") !== null + ) { + const target = node.arguments.nodes[0]; + if ( + target !== undefined && + ts.isIdentifier(target) && + target.text === name.text && + lowerer.checker.getSymbolAtLocation(target) === symbol + ) + found = true; + } + return undefined; + }); + return found; +} diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ea5ef72ac..0f1b097c2 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1010,6 +1010,44 @@ export function predeclareForwardCapture(lowerer: Lowerer, symbol: ts.Symbol): b return false; } +/** The CLASS-DECLARATION twin of predeclareForwardCapture: a function + * hoisted above a local `class D` (`function make() { return new D(); }` + * reached through an earlier function) captures the class binding, which + * is in its temporal dead zone until the class statement runs. The class + * cannot be collected yet (its heritage may name bindings declared between + * here and the statement), so the binding is pre-declared as a TDZ box + * holding the class as a checked-dynamic constructor; the class statement + * later initializes it, and every reference reads it from there. */ +export function predeclareForwardClass(lowerer: Lowerer, symbol: ts.Symbol): boolean { + const decl = symbol.valueDeclaration; + if (!decl || !ts.isClassDeclaration(decl) || decl.name === undefined) return false; + if (!isJsSourceFile(ts.getSourceFileOfNode(decl))) return false; + for (let i = lowerer.activeStmtLists.length - 1; i >= 0; i--) { + const entry = lowerer.activeStmtLists[i]!; + const idx = entry.stmts.indexOf(decl); + if (idx < 0) continue; + if (idx < entry.index || entry.ctx === lowerer.ctx || entry.ctx.tdzPredeclared.has(symbol)) + return false; + const name = decl.name.text; + const count = entry.ctx.localCounters.get(name) ?? 0; + entry.ctx.localCounters.set(name, count + 1); + const local: IrLocal = { + id: `${name}.${count}`, + name, + type: DYN, + mutable: false, + boxed: true, + tdz: true, + }; + entry.ctx.locals.push(local); + entry.frame.set(symbol, local); + entry.out.push({ kind: "varDecl", localId: local.id, init: null, loc: locOf(decl) }); + entry.ctx.tdzPredeclared.set(symbol, local); + return true; + } + return false; +} + /** The FUNCTION-DECLARATION twin of predeclareForwardCapture: JS hoists a * nested `function f() {}` to scope entry — the binding is LIVE from the * first statement, so a reference lexically above the declaration in the @@ -1529,6 +1567,14 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt if (ts.isClassDeclaration(stmt)) { if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous local class declarations"); const info = lowerer.lowerClassExpressionInfo(stmt); + // An earlier hoisted function captured the class: its TDZ box exists. + const symbol = lowerer.checker.getSymbolAtLocation(stmt.name); + const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined; + if (pre) { + lowerer.ctx.tdzPredeclared.delete(symbol!); + const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN); + return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) }; + } const type: IrType = { kind: "classval", className: info.def.name }; const local = lowerer.declareLocal(stmt.name, stmt.name.text, type, true); // Static members of a function-local class run while the class value is diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 93c6246d9..83ac404d4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,4 +1,5 @@ import { relieveHeapPressure } from "../../heap-pressure.js"; +import { moduleNamespaceObject } from "./module-namespace-elements.js"; import { keyedHelperName } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; @@ -9445,6 +9446,22 @@ export class Lowerer { } if (expr.type.kind === "void" && expected.kind !== "void" && this.neverValued.has(expr)) return this.divergentValue(expr, expected); + // A builtin error constructor global stored in a slot of its class value + // type (`{ Parent: Error }`): the class value itself, which a checked + // cast of the dynamic constructor value could not produce. + if ( + expected.kind === "classval" && + expr.kind === "libCall" && + expr.fn === "dyn.globalConstructor" && + expr.args[0]?.kind === "strLit" && + `%${expr.args[0].value}` === expected.className + ) { + const info = this.classes.get(expected.className); + if (info?.builtinError && info.valueCtorParams !== undefined) { + this.defineClassValueConstruct(info); + return { kind: "classRef", className: info.def.name, type: expected, loc: expr.loc }; + } + } // process.stdout/stderr into a general Writable slot (general-streams.ts). const stdioStream = stdioIntoGeneralStream(this, expr, expected); if (stdioStream) return this.coerceToExpected(stdioStream, expected); @@ -9551,6 +9568,13 @@ export class Lowerer { // constructor. `require("events")` is the EventEmitter class itself, // so its module binding is too (`ReleaseEmitter = EventEmitter` over // `const EventEmitter = require("events")`). + if (expr.type.kind === "moduleNs" && isJsSourceFileName(expr.loc.file)) { + const object = moduleNamespaceObject( + this, + expr as IrExpr & { type: IrType & { kind: "moduleNs" } }, + ); + if (object) return object; + } if ( expr.type.kind === "moduleNs" && expr.type.moduleId === "builtin:events" && diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index d2425750b..df40573da 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -1,6 +1,13 @@ import { dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; import * as ts from "../ts7/adapter.js"; -import { DYN, STRING, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js"; +import { + DYN, + STRING, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; import { locOf } from "../program.js"; import { newFnCtx, type Lowerer } from "./lowerer.js"; @@ -88,3 +95,76 @@ export function lowerModuleNamespaceElement( }; return { kind: "call", callee: name, args: [receiver, text], type: DYN, loc }; } + +/** A module namespace taken as an untyped value (`{ ...ns }`, `{ iso: ns }` + * in JavaScript): an object holding each value export, keyed in the + * namespace's sorted key order. The copy is exact while every export is a + * binding that never changes after initialization (const, function or + * class); null for other namespaces, which keep their fence. */ +export function moduleNamespaceObject( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const source = lowerer.sourceFileOfModuleNamespace(ns.type); + if (!source) return null; + const symbol = lowerer.checker.getSymbolAtLocation(source); + if (!symbol) return null; + const exports: { key: string; identifier: ts.Identifier }[] = []; + let exact = true; + symbol.getExports().forEach((exported, key) => { + const target = + exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; + if (!(target.flags & ts.SymbolFlags.Value)) return; + const declaration = target.valueDeclaration; + const identifier = declaration?.name as ts.Node | undefined; + const immutable = + declaration !== undefined && + (ts.isFunctionDeclaration(declaration) || + ts.isClassDeclaration(declaration) || + (ts.isVariableDeclaration(declaration) && + (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) !== 0)); + if (!identifier || !ts.isIdentifier(identifier) || !immutable) { + exact = false; + return; + } + exports.push({ key: String(key), identifier }); + }); + if (!exact) return null; + exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + const name = `%module.object:${ns.type.moduleId}`; + const loc = ns.loc; + if (!lowerer.liftedFns.some((fn) => fn.name === name)) { + const context = newFnCtx(false, null, null, DYN); + const previousClass = lowerer.currentClass; + lowerer.currentClass = null; + lowerer.fnStack.push(context); + try { + const fields = exports.map(({ key, identifier }) => ({ + key: strLit(key, loc), + value: lowerer.lowerExprExpecting(identifier, DYN), + })); + if (fields.some((field) => field.value.type.kind !== "dyn")) return null; + const fn: IrFunction = { + name, + params: [], + returnType: DYN, + locals: [...context.locals], + body: [{ kind: "return", value: { kind: "dynObjLit", fields, type: DYN, loc }, loc }], + loc, + }; + lowerer.liftedFns.push(fn); + } finally { + lowerer.fnStack.pop(); + lowerer.currentClass = previousClass; + } + } + const call: IrExpr = { kind: "call", callee: name, args: [], type: DYN, loc }; + if (ns.kind === "moduleNsRef") return call; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: ns, loc }], + result: call, + type: DYN, + loc, + }; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-builtin-constructor-in.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-builtin-constructor-in.js.json new file mode 100644 index 000000000..efb9dd725 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7480-builtin-constructor-in.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7480-builtin-constructor-in.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-runtime-heritage-object-error.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-runtime-heritage-object-error.js.json new file mode 100644 index 000000000..d0d390662 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7481-class-runtime-heritage-object-error.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7481-class-runtime-heritage-object-error.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7482-define-property-function-class-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7482-define-property-function-class-values.js.json new file mode 100644 index 000000000..d1d97ba4f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7482-define-property-function-class-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7482-define-property-function-class-values.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7483-constructor-factory-traits.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7483-constructor-factory-traits.js.json new file mode 100644 index 000000000..c3fa9964f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7483-constructor-factory-traits.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7483-constructor-factory-traits.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7484-hoisted-function-captures-later-class.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7484-hoisted-function-captures-later-class.js.json new file mode 100644 index 000000000..515b9bf97 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7484-hoisted-function-captures-later-class.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7484-hoisted-function-captures-later-class.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7485-global-object-own-properties.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7485-global-object-own-properties.js.json new file mode 100644 index 000000000..aa1affb3c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7485-global-object-own-properties.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7485-global-object-own-properties.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7486-prototype-accessor-string-method-names.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7486-prototype-accessor-string-method-names.js.json new file mode 100644 index 000000000..0cf87c443 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7486-prototype-accessor-string-method-names.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7486-prototype-accessor-string-method-names.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json new file mode 100644 index 000000000..0c768ef66 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/7487-namespace-spread-untyped/shapes.js", + "/tests/corpus/7487-namespace-spread-untyped/extra.js", + "/tests/corpus/7487-namespace-spread-untyped/main.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 74b710dc9..63d7cb1b7 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -863,6 +863,7 @@ ScrDyn *scr_dyn_open_iterator(const ScrDyn *value, int *kind, bool generators); * for-of protocol reads it. +1, or NULL with the error pending. */ ScrDyn *scr_dyn_iterator_result_get(const ScrDyn *result, const char *key, size_t len); void scr_dyn_install_iterator_symbol(ScrDyn *key); +void scr_dyn_install_has_instance_symbol(ScrDyn *key); void scr_dyn_install_async_iterator_symbol(ScrDyn *key); void scr_dyn_install_async_dispose_symbol(ScrDyn *key); /* True when key is Symbol.asyncDispose (registered by the symbol unit when @@ -1270,6 +1271,7 @@ 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); +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. */ ScrStr *scr_dyn_display(const ScrDyn *d); diff --git a/packages/runtime/src/scr_dyn_builtin_class.c b/packages/runtime/src/scr_dyn_builtin_class.c index 2f947048a..0647a65c0 100644 --- a/packages/runtime/src/scr_dyn_builtin_class.c +++ b/packages/runtime/src/scr_dyn_builtin_class.c @@ -1581,6 +1581,8 @@ bool scr_dyn_function_instanceof(ScrDyn *value, ScrDyn *callee) { scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return false; } + int hooked = scr_dyn_has_instance_hook(value, callee); + if (hooked >= 0) return hooked == 1; ScrDyn *prototype = bc_get(callee, "prototype"); if (!prototype) return false; bool found = false; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index cf658313f..57c9e4df4 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -189,6 +189,19 @@ void scr_dyn_install_async_iterator_symbol(ScrDyn *key) { scr_atexit(scr_async_iterator_symbol_cleanup); } +/* Symbol.hasInstance, installed when the program first evaluates it: a + * function can only define a hook after reading the symbol. */ +static SCR_TL ScrDyn *scr_has_instance_symbol; +static void scr_has_instance_symbol_cleanup(void) { + scr_dyn_release(scr_has_instance_symbol); + scr_has_instance_symbol = NULL; +} +void scr_dyn_install_has_instance_symbol(ScrDyn *key) { + if (scr_has_instance_symbol) return; + scr_has_instance_symbol = scr_dyn_retain(key); + scr_atexit(scr_has_instance_symbol_cleanup); +} + void scr_dyn_install_iterator_symbol(ScrDyn *key) { if (scr_iterator_symbol) return; scr_iterator_symbol = scr_dyn_retain(key); @@ -3983,12 +3996,49 @@ static int scr_dyn_global_constructor_instanceof(const ScrDyn *value, const ScrD /* `value instanceof callee` for an untyped constructor value. `refusal`, when * given, is the site's SC1090 message for a runtime constructor with no * instance test. */ +/* A function whose own-property table defines Symbol.hasInstance + * (`Object.defineProperty(F, Symbol.hasInstance, { value })`): 1 or 0 by the + * truthiness of the method's answer with `this` bound to F, -1 when F + * defines none, or 0 with the exception pending. Functions without a table + * use the ordinary prototype test. */ +int scr_dyn_has_instance_hook(const ScrDyn *value, const ScrDyn *callee) { + if (!scr_has_instance_symbol || callee->kind != SCR_DYN_FUNC || !scr_closure_identity(callee->v.fn.clo)->props) + return -1; + ScrDyn *hook = scr_dyn_symbol_key_get(callee, scr_has_instance_symbol, true); + if (scr_exc_pending()) { + scr_dyn_release(hook); + return 0; + } + if (!hook || hook->kind != SCR_DYN_FUNC) { + scr_dyn_release(hook); + return -1; + } + scr_dyn_this_push_dyn((ScrDyn *)callee); + ScrDyn *argument = (ScrDyn *)value; + ScrDyn *result = scr_dyn_call(hook, &argument, 1, "Symbol.hasInstance"); + scr_dyn_this_pop(); + scr_dyn_release(hook); + if (!result) return 0; + bool truthy = scr_dyn_truthy(result); + scr_dyn_release(result); + return truthy ? 1 : 0; +} + bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const ScrStr *refusal) { if (!callee || callee->kind != SCR_DYN_FUNC) { static const char message[] = "Right-hand side of 'instanceof' is not callable"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return false; } + int hooked = scr_dyn_has_instance_hook(value, callee); + if (hooked >= 0) return hooked == 1; + /* An error constructor held as a value (`inst instanceof params.Parent` + * with Parent === Error): the error kind's interval, and for Error itself + * objects inheriting Error.prototype. */ + for (size_t i = 0; i < SCR_ERROR_CTORS; i++) { + if (!scr_error_ctor_values[i] || callee->v.fn.clo != scr_error_ctor_values[i]->v.fn.clo) continue; + return scr_dyn_err_instanceof(value, (double)i) || (i == 0 && scr_dyn_inherits_error_prototype(value)); + } if (scr_array_buffer_ctor && callee->v.fn.clo == scr_array_buffer_ctor->v.fn.clo) return scr_array_buffer_is(value); if (scr_array_ctor && callee->v.fn.clo == scr_array_ctor->v.fn.clo) return value->kind == SCR_DYN_ARR || scr_dyn_ref_is_array(value); for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { @@ -5021,7 +5071,43 @@ ScrDyn *scr_dyn_class_inherit(ScrDyn *constructor, ScrDyn *base) { return scr_exc_pending() ? NULL : scr_dyn_retain(constructor); } +/* Defines `key` on target as a writable, configurable, non-enumerable data + * property holding value (borrowed), as Error's constructor installs its + * message and cause. */ +static void scr_dyn_define_hidden(ScrDyn *target, const ScrStr *key, ScrDyn *value) { + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "value", 5, scr_dyn_retain(value)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(true)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(true)); + ScrDyn *name = scr_dyn_new_str((ScrStr *)key); + ScrDyn *defined = scr_dyn_define_property(target, name, descriptor); + scr_dyn_release(name); + scr_dyn_release(descriptor); + scr_dyn_release(defined); +} + +/* A builtin constructor a class expression's runtime heritage can name: + * 1 for Object, 2 for Error, 0 otherwise. */ +static int scr_dyn_builtin_base_kind(const ScrDyn *constructor) { + if (constructor->kind != SCR_DYN_FUNC || constructor->v.fn.class_obj || !constructor->v.fn.sig || + !constructor->v.fn.name || strcmp(constructor->v.fn.sig, "native:constructor") != 0) + return 0; + if (strcmp(constructor->v.fn.name, "Object") == 0) return 1; + if (strcmp(constructor->v.fn.name, "Error") == 0 && constructor->v.fn.clo->fn == (void *)scr_error_ctor_names[0]) + return 2; + return 0; +} + ScrDyn *scr_dyn_class_base_prototype(ScrDyn *constructor) { + /* `class extends (Base ?? Object)`: Object's instances are ordinary + * objects, laid out like a class without heritage (undefined); Error's + * inherit Error.prototype. */ + switch (scr_dyn_builtin_base_kind(constructor)) { + case 1: return scr_dyn_retain(scr_dyn_undefined()); + case 2: return scr_dyn_error_prototype(); + default: break; + } if (constructor->kind != SCR_DYN_FUNC || constructor->v.fn.class_obj || scr_closure_identity(constructor->v.fn.clo)->function_kind != 4) { static const char message[] = "Class extends value is not a native ordinary constructor"; @@ -5038,6 +5124,48 @@ ScrDyn *scr_dyn_class_base_prototype(ScrDyn *constructor) { } void scr_dyn_class_super(ScrDyn *constructor, ScrDyn *receiver, ScrDyn *arguments) { + switch (scr_dyn_builtin_base_kind(constructor)) { + case 1: + /* super() through Object keeps the receiver the derived constructor + * created (OrdinaryCreateFromConstructor). */ + return; + case 2: { + /* super(message, options) through Error: the message and cause become + * non-enumerable own properties, then the stack (its text alone, as for + * scriptc's other errors). */ + size_t argc = arguments->v.arr.len; + ScrDyn *const *argv = arguments->v.arr.items; + if (argc > 0 && argv[0]->kind != SCR_DYN_UNDEF) { + ScrStr *text = scr_dyn_string_coerce_js(argv[0]); + if (!text) return; + ScrDyn *value = scr_dyn_new_str(text); + scr_str_release(text); + ScrStr *key = scr_str_new("message", 7); + scr_dyn_define_hidden(receiver, key, value); + scr_str_release(key); + scr_dyn_release(value); + if (scr_exc_pending()) return; + } + if (argc > 1 && argv[1]->kind == SCR_DYN_OBJ && scr_dyn_find_entry(argv[1], "cause", 5)) { + ScrDyn *cause = scr_dyn_obj_get(argv[1], "cause", 5); + ScrStr *key = scr_str_new("cause", 5); + scr_dyn_define_hidden(receiver, key, cause ? cause : scr_dyn_undefined()); + scr_str_release(key); + if (scr_exc_pending()) return; + } + ScrStr *text = scr_dyn_error_string(receiver); + if (!text) return; + ScrDyn *stack = scr_dyn_new_str(text); + scr_str_release(text); + ScrStr *key = scr_str_new("stack", 5); + scr_dyn_define_hidden(receiver, key, stack); + scr_str_release(key); + scr_dyn_release(stack); + return; + } + default: + break; + } scr_dyn_this_push_dyn(receiver); ScrDyn *result = scr_dyn_call(constructor, arguments->v.arr.items, arguments->v.arr.len, "super"); scr_dyn_this_pop(); diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index 304f2544f..647c31dc3 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -85,6 +85,11 @@ ScrSym *scr_sym_well_known(ScrStr *name) { scr_dyn_install_iterator_symbol(key); scr_dyn_release(key); } + if (name->len == 11 && !memcmp(name->data, "hasInstance", 11)) { + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_dyn_install_has_instance_symbol(key); + scr_dyn_release(key); + } if (name->len == 13 && !memcmp(name->data, "asyncIterator", 13)) { ScrDyn *key = scr_dyn_new_symbol(symbol); scr_dyn_install_async_iterator_symbol(key); diff --git a/tests/corpus/7480-builtin-constructor-in.js b/tests/corpus/7480-builtin-constructor-in.js new file mode 100644 index 000000000..781a00105 --- /dev/null +++ b/tests/corpus/7480-builtin-constructor-in.js @@ -0,0 +1,9 @@ +// Feature probes on builtin constructor globals answer like Node. +const E = "captureStackTrace" in Error ? Error : null; +console.log(E === Error, "stackTraceLimit" in Error, "nope" in Error); +console.log("captureStackTrace" in TypeError, "keys" in Object, "fromAsync" in Array, "withResolvers" in Promise); +console.log("call" in Map, "hasOwnProperty" in Set, "groupBy" in Map, "groupBy" in Set, "now" in Date); +const capture = "captureStackTrace" in Error ? Error.captureStackTrace : () => {}; +const target = {}; +capture(target); +console.log(typeof target.stack); diff --git a/tests/corpus/7481-class-runtime-heritage-object-error.js b/tests/corpus/7481-class-runtime-heritage-object-error.js new file mode 100644 index 000000000..13d59dc63 --- /dev/null +++ b/tests/corpus/7481-class-runtime-heritage-object-error.js @@ -0,0 +1,24 @@ +function make(name, params) { + const Parent = (params && params.Parent) || Object; + class Definition extends Parent { + describe() { + return `${name}:${this.kind}`; + } + } + return Definition; +} +const A = make("A"); +const a = new A(); +a.kind = "plain"; +console.log(typeof a, a instanceof A, a.describe(), JSON.stringify(a), Object.keys(a)); +const E = make("E", { Parent: Error }); +const e = new E("boom"); +e.kind = "error"; +console.log(e instanceof E, e.message, typeof e.stack, e.describe(), Object.keys(e), String(e)); +const Parent = E; +console.log(e instanceof Parent, new E() .message === "", Object.prototype.hasOwnProperty.call(new E(), "message")); +try { + throw new E("thrown"); +} catch (err) { + console.log(err instanceof E, err.message); +} diff --git a/tests/corpus/7482-define-property-function-class-values.js b/tests/corpus/7482-define-property-function-class-values.js new file mode 100644 index 000000000..7a682adab --- /dev/null +++ b/tests/corpus/7482-define-property-function-class-values.js @@ -0,0 +1,35 @@ +const desc = { value: undefined, enumerable: false }; +function factory(name) { + class Definition {} + Object.defineProperty(Definition, "name", { value: name }); + function init(inst, def) { + if (!inst._meta) { + desc.value = { def, traits: new Set() }; + try { + Object.defineProperty(inst, "_meta", desc); + } finally { + desc.value = undefined; + } + } + inst._meta.traits.add(name); + } + function _(def) { + const inst = this; + init(inst, def); + return inst; + } + Object.defineProperty(_, "init", { value: init }); + Object.defineProperty(_, Symbol.hasInstance, { + value: (inst) => !!(inst && inst._meta && inst._meta.traits.has(name)), + }); + Object.defineProperty(_, "name", { value: name }); + return { ctor: _, Definition }; +} +const { ctor: Thing, Definition } = factory("Thing"); +console.log(Definition.name, Thing.name, typeof Thing.init); +const t = new Thing({ a: 1 }); +console.log(Object.keys(t), JSON.stringify(t), t._meta.def.a, [...t._meta.traits]); +console.log(t instanceof Thing, {} instanceof Thing); +const plain = {}; +Thing.init(plain, { b: 2 }); +console.log(plain instanceof Thing, Object.keys(plain), plain._meta.def.b); diff --git a/tests/corpus/7483-constructor-factory-traits.js b/tests/corpus/7483-constructor-factory-traits.js new file mode 100644 index 000000000..e022f0672 --- /dev/null +++ b/tests/corpus/7483-constructor-factory-traits.js @@ -0,0 +1,58 @@ +const _desc = { value: undefined, enumerable: false }; +function $constructor(name, initializer, params) { + const proto = {}; + function Internals(def) { + this.def = def; + this.constr = _; + this.traits = new Set(); + } + Internals.prototype = proto; + function init(inst, def) { + if (!inst._zod) { + _desc.value = new Internals(def); + try { + Object.defineProperty(inst, "_zod", _desc); + } finally { + _desc.value = undefined; + } + } else if (inst._zod.traits.has(name)) { + return; + } + inst._zod.traits.add(name); + initializer(inst, def); + } + const Parent = params?.Parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function _(def) { + const inst = params?.Parent ? new Definition() : this; + init(inst, def); + return inst; + } + Object.defineProperty(_, "init", { value: init }); + Object.defineProperty(_, Symbol.hasInstance, { + value: (inst) => { + if (params?.Parent && inst instanceof params.Parent) return true; + return inst?._zod?.traits?.has(name); + }, + }); + Object.defineProperty(_, "name", { value: name }); + return _; +} +const $Base = $constructor("$Base", (inst, def) => { + inst.kind = def.kind; + inst._zod.parse = (v) => (typeof v === def.kind ? { value: v } : { issues: [`expected ${def.kind}`] }); +}); +const $Str = $constructor("$Str", (inst, def) => { + $Base.init(inst, def); + inst.min = def.min; +}); +const $Err = $constructor("$Err", (inst, def) => { + inst.issues = def; + inst.message = JSON.stringify(def); +}, { Parent: Error }); +const s = new $Str({ kind: "string", min: 2 }); +console.log(s.kind, s.min, [...s._zod.traits], s instanceof $Str, s instanceof $Base, Object.keys(s)); +console.log(JSON.stringify(s._zod.parse("ab")), JSON.stringify(s._zod.parse(3))); +const e = new $Err(["bad"]); +console.log(e instanceof $Err, e.issues, e.message, $Str.name, $Err.name); diff --git a/tests/corpus/7484-hoisted-function-captures-later-class.js b/tests/corpus/7484-hoisted-function-captures-later-class.js new file mode 100644 index 000000000..2272882af --- /dev/null +++ b/tests/corpus/7484-hoisted-function-captures-later-class.js @@ -0,0 +1,23 @@ +function f(params) { + function early() { + return make; + } + const Parent = params?.Parent ?? Object; + class D extends Parent {} + function make() { + return new D(); + } + return early()(); +} +console.log(typeof f(), typeof f({ Parent: Error })); +function g() { + function early() { + return make; + } + class E {} + function make() { + return new E(); + } + return early()(); +} +console.log(typeof g()); diff --git a/tests/corpus/7485-global-object-own-properties.js b/tests/corpus/7485-global-object-own-properties.js new file mode 100644 index 000000000..a9a72be18 --- /dev/null +++ b/tests/corpus/7485-global-object-own-properties.js @@ -0,0 +1,22 @@ +var _a; +globalThis.__direct = { n: 1 }; +console.log(typeof globalThis.__direct, globalThis.__direct.n); + +(_a = globalThis).__alias ?? (_a.__alias = { n: 2 }); +console.log(typeof globalThis.__alias); +const g = globalThis; +g.__viaConst = { n: 3 }; +console.log(typeof globalThis.__viaConst); +globalThis.__nullish ??= { n: 4 }; +console.log(typeof globalThis.__nullish); + +(_a = globalThis).__demo_config ?? (_a.__demo_config = {}); +const globalConfig = globalThis.__demo_config; +function config(next) { + if (next) Object.assign(globalConfig, next); + return globalConfig; +} +config({ localeError: (issue) => `bad ${issue}` }); +console.log(typeof globalConfig, typeof globalConfig.localeError, config().localeError("x")); +const pp = globalThis.__demo_config?.postProcessor; +console.log(pp === undefined, globalThis.__demo_config === globalConfig); diff --git a/tests/corpus/7486-prototype-accessor-string-method-names.js b/tests/corpus/7486-prototype-accessor-string-method-names.js new file mode 100644 index 000000000..9920717ea --- /dev/null +++ b/tests/corpus/7486-prototype-accessor-string-method-names.js @@ -0,0 +1,53 @@ +function own(inst, key, value, enumerable = true) { + Object.defineProperty(inst, key, { configurable: true, writable: true, enumerable, value }); + return value; +} +function defineBound(proto, key, fn) { + Object.defineProperty(proto, key, { + configurable: true, + get() { + return this == null ? fn : own(this, key, fn.bind(this)); + }, + set(value) { + own(this, key, value); + }, + }); +} +function members(proto, table) { + for (const key in table) { + const desc = Object.getOwnPropertyDescriptor(table, key); + if (desc.get) Object.defineProperty(proto, key, { ...desc, enumerable: false }); + else defineBound(proto, key, desc.value); + } +} +function Schema(def) { + this.def = def; +} +let installed = false; +function make(def) { + const inst = new Schema(def); + if (!installed) { + installed = true; + members(Object.getPrototypeOf(inst), { + min(n) { return make({ ...this.def, min: n }); }, + max(n) { return make({ ...this.def, max: n }); }, + trim() { return make({ ...this.def, trim: true }); }, + get size() { return Object.keys(this.def).length; }, + }); + } + return inst; +} +const s = make({ kind: "string" }).min(1).max(200).trim(); +console.log(JSON.stringify(s.def), s.size, Object.keys(s), typeof s.trim); +const detached = make({ kind: "x" }).max; +console.log(JSON.stringify(detached(3).def)); +const proto2 = {}; +Object.defineProperty(proto2, "aaa", { configurable: true, get() { return () => "custom-aaa"; } }); +Object.defineProperty(proto2, "trim", { configurable: true, get() { return () => "custom-trim"; } }); +Object.defineProperty(proto2, "trimx", { configurable: true, get() { return () => "custom-trimx"; } }); +Object.defineProperty(proto2, "slice", { configurable: true, get() { return () => "custom-slice"; } }); +const o2 = Object.create(proto2); +console.log(o2.aaa()); +console.log(o2.trimx()); +console.log(o2.slice()); +console.log(o2.trim()); diff --git a/tests/corpus/7487-namespace-spread-untyped/extra.js b/tests/corpus/7487-namespace-spread-untyped/extra.js new file mode 100644 index 000000000..8c62c2dd0 --- /dev/null +++ b/tests/corpus/7487-namespace-spread-untyped/extra.js @@ -0,0 +1,2 @@ +export const tag = (s) => `<${s}>`; +export function zeta() { return "z"; } diff --git a/tests/corpus/7487-namespace-spread-untyped/main.js b/tests/corpus/7487-namespace-spread-untyped/main.js new file mode 100644 index 000000000..021c00ac9 --- /dev/null +++ b/tests/corpus/7487-namespace-spread-untyped/main.js @@ -0,0 +1,11 @@ +import * as shapes from "./shapes.js"; +import * as extra from "./extra.js"; +// A namespace spread into an untyped object (zod's local +// `const z = { ..._schemas, ..._checks, iso: _iso }`). +function untyped(value) { + return value; +} +const z = untyped({ ...shapes, ...extra, extra }); +console.log(Object.keys(z).join(",")); +for (const key of ["circle", "square"]) console.log(key, z[key](2).area.toFixed(2)); +console.log(z.VERSION, typeof z.extra.tag, z.extra.tag("x"), new z.Box(4).size); diff --git a/tests/corpus/7487-namespace-spread-untyped/shapes.js b/tests/corpus/7487-namespace-spread-untyped/shapes.js new file mode 100644 index 000000000..a8b5cbfc2 --- /dev/null +++ b/tests/corpus/7487-namespace-spread-untyped/shapes.js @@ -0,0 +1,4 @@ +export const VERSION = "1.2"; +export function circle(r) { return { kind: "circle", area: Math.PI * r * r }; } +export function square(s) { return { kind: "square", area: s * s }; } +export class Box { constructor(size) { this.size = size; } } diff --git a/tests/harness/corpus-costs/7480-builtin-constructor-in.js.json b/tests/harness/corpus-costs/7480-builtin-constructor-in.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7480-builtin-constructor-in.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7481-class-runtime-heritage-object-error.js.json b/tests/harness/corpus-costs/7481-class-runtime-heritage-object-error.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7481-class-runtime-heritage-object-error.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7482-define-property-function-class-values.js.json b/tests/harness/corpus-costs/7482-define-property-function-class-values.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7482-define-property-function-class-values.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7483-constructor-factory-traits.js.json b/tests/harness/corpus-costs/7483-constructor-factory-traits.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7483-constructor-factory-traits.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7484-hoisted-function-captures-later-class.js.json b/tests/harness/corpus-costs/7484-hoisted-function-captures-later-class.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7484-hoisted-function-captures-later-class.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7485-global-object-own-properties.js.json b/tests/harness/corpus-costs/7485-global-object-own-properties.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7485-global-object-own-properties.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7486-prototype-accessor-string-method-names.js.json b/tests/harness/corpus-costs/7486-prototype-accessor-string-method-names.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7486-prototype-accessor-string-method-names.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } diff --git a/tests/harness/corpus-costs/7487-namespace-spread-untyped/main.js.json b/tests/harness/corpus-costs/7487-namespace-spread-untyped/main.js.json new file mode 100644 index 000000000..20168ce9a --- /dev/null +++ b/tests/harness/corpus-costs/7487-namespace-spread-untyped/main.js.json @@ -0,0 +1 @@ +{ "plain": 3500, "san": 3800 } From 122c98cf4fdd5e8c6ab5c81775a284ef009b7bda Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 15:48:22 -0700 Subject: [PATCH 09/39] Read the global object without rebuilding its name table Every evaluation of `globalThis` as a value built an array literal of every name the global type declares (hundreds of strings) and registered each one in the known-name table again. A server that reads a singleton through `globalThis` per request spent about a tenth of its time there. Emit the name table as one immortal static array per program (the tagged-template site array, keyed by its contents), and return the global object at once when the runtime sees a table it has already registered. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lower-global-value.ts | 9 ++++++--- packages/runtime/src/scr_node_builtin.c | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-global-value.ts b/packages/compiler/src/frontend/lowering/lower-global-value.ts index 4b8e7b10a..e7ab6b0f0 100644 --- a/packages/compiler/src/frontend/lowering/lower-global-value.ts +++ b/packages/compiler/src/frontend/lowering/lower-global-value.ts @@ -1,6 +1,5 @@ import * as ts from "../ts7/adapter.js"; import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js"; -import { strLit } from "../../ir/build.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { TEMPORAL_GLOBAL } from "../builtin-modules.js"; @@ -19,10 +18,14 @@ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr return { kind: "libCall", fn: "global.native", + // One immortal name table per program (keyed by its contents): reading + // the global object in a hot path allocates nothing, and the runtime + // recognizes a table it has already registered. args: [ { - kind: "arrayLit", - elems: names.map((name) => strLit(name, loc)), + kind: "templateStrings", + key: `%globalThis.names:${names.join(",")}`, + cooked: names, type: arrayOf(STRING), loc, }, diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 233617092..75df7e892 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -246,8 +246,13 @@ ScrDyn *scr_global_object(void) { } ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)) { + /* The compiler passes one immortal name table per program: once it is + * registered, later reads of the global object only take a reference. */ + static SCR_TL const ScrArr *registered; + if (known == registered && scr_global_box) return scr_dyn_retain(scr_global_box); ScrDyn *global = scr_global_object(); scr_global_console_get = console_get; + if (known->rc == SIZE_MAX) registered = known; for (size_t i = 0; i < known->len; i++) { ScrStr *key = (ScrStr *)scr_arr_get_ref(known, (double)i); if (!scr_dyn_obj_get(scr_global_known, key->data, key->len)) From 982572c173d8f6c2fb8b75512b46ee9a1fb6601a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 17:47:20 -0700 Subject: [PATCH 10/39] Dispatch splice on arrays of unknown elements Arrays the checker types with unknown elements are stored checked-dynamic, and their methods dispatch on the runtime array. splice was missing from that dispatch table, so unknown[] receivers refused it, including generic bodies instantiated at unknown (pool.splice(i, 1) in a sample helper gets such an instantiation once a program has a dynamic tier). The runtime's dynInvoke already implements splice; the table now names it, and arrays of unknown elements take the dispatch from the array-method lowering. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/containers/array-methods.ts | 15 +++++++++++++++ .../compiler/src/frontend/lowering/lower-calls.ts | 1 + .../corpus/7402-unknown-array-methods.ts.json | 6 ++++++ tests/corpus/7402-unknown-array-methods.ts | 14 ++++++++++++++ .../7402-unknown-array-methods.ts.json | 1 + 5 files changed, 37 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7402-unknown-array-methods.ts.json create mode 100644 tests/corpus/7402-unknown-array-methods.ts create mode 100644 tests/harness/corpus-costs/7402-unknown-array-methods.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index 44182fbda..3ab2e84e2 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -361,6 +361,21 @@ export function lowerArrayMethodCall( if (probe.type.kind === "array") { receiverIr = probe.type; probedUntyped = true; + } else if ( + probe.type.kind === "dyn" && + !isJsSourceFile(ts.getSourceFileOfNode(access)) && + lowerer.isStdlibMember(access) + ) { + // An `unknown[]` (or `any[]`) array stored checked-dynamic — a + // generic body instantiated at `unknown`, a spread of unknown + // elements: the method runs on the runtime array (dynInvoke, JS + // exact), and a typed result is checked like any member read. + const dispatched = lowerDynDispatchMethodCall(lowerer, call, access, probe, true); + if (!dispatched) return null; + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + return result && result.kind !== "void" && result.kind !== "dyn" + ? lowerer.coerceInto(call, dispatched, result) + : dispatched; } } if (receiverIr?.kind === "union") { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c0406e77b..246958d54 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4243,6 +4243,7 @@ const DYN_DISPATCH_METHODS = new Set([ "shift", "unshift", "slice", + "splice", "at", "indexOf", "lastIndexOf", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7402-unknown-array-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7402-unknown-array-methods.ts.json new file mode 100644 index 000000000..db33da675 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7402-unknown-array-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7402-unknown-array-methods.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7402-unknown-array-methods.ts b/tests/corpus/7402-unknown-array-methods.ts new file mode 100644 index 000000000..e374dd40f --- /dev/null +++ b/tests/corpus/7402-unknown-array-methods.ts @@ -0,0 +1,14 @@ +// Methods on arrays of unknown elements, which are stored checked-dynamic: +// direct `unknown[]` bindings and generic bodies instantiated at `unknown`. +const values: unknown[] = [1, "two", 3, { four: 4 }]; +const removed = values.splice(1, 2); +console.log(values, removed, removed.length); +values.splice(1, 0, "inserted", null); +console.log(values, values.indexOf(null), values.includes("inserted")); +console.log(values.slice(1).reverse(), values.join("|")); + +function takeAt(items: readonly T[], index: number): T[] { + const pool = [...items]; + return pool.splice(index, 1); +} +console.log(takeAt([10, 20, 30], 1), takeAt([true, "x", 7], 2)); diff --git a/tests/harness/corpus-costs/7402-unknown-array-methods.ts.json b/tests/harness/corpus-costs/7402-unknown-array-methods.ts.json new file mode 100644 index 000000000..9eb118813 --- /dev/null +++ b/tests/harness/corpus-costs/7402-unknown-array-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 20000 } From a9ee38611ebd4fa7fdd0c1638b498119270c4942 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:20:56 -0500 Subject: [PATCH 11/39] Register the global object's names again after a library re-init --- packages/runtime/src/scr_node_builtin.c | 10 +++++++--- tests/harness/library-mode.test.ts | 1 + tests/library-mode/reinit/lib.ts | 18 ++++++++++++++++++ tests/library-mode/reinit/probe.c | 3 +++ tests/library-mode/reinit/profile.json | 3 ++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/scr_node_builtin.c b/packages/runtime/src/scr_node_builtin.c index 75df7e892..c0031e67b 100644 --- a/packages/runtime/src/scr_node_builtin.c +++ b/packages/runtime/src/scr_node_builtin.c @@ -90,6 +90,10 @@ static SCR_TL ScrDyn *scr_global_own; * live on the box (string-keyed ones in scr_global_own), so every spelling * of the global object answers this box. */ static SCR_TL ScrDyn *scr_global_box; +/* The compiler's immortal name table (one per program) whose names + * scr_global_known holds; cleared with the tables, so a library re-init + * registers the names again. */ +static SCR_TL const ScrArr *scr_global_registered; static SCR_TL bool scr_global_cleanup_registered; static SCR_TL ScrDyn *(*scr_global_console_get)(void); static SCR_TL ScrDyn *(*scr_global_fetch_get)(void); @@ -190,6 +194,7 @@ static void scr_global_cleanup(void) { scr_dyn_release(scr_global_own); scr_global_known = NULL; scr_global_own = NULL; + scr_global_registered = NULL; } static void *scr_global_retain(void *handle) { return handle; } @@ -248,11 +253,10 @@ ScrDyn *scr_global_object(void) { ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)) { /* The compiler passes one immortal name table per program: once it is * registered, later reads of the global object only take a reference. */ - static SCR_TL const ScrArr *registered; - if (known == registered && scr_global_box) return scr_dyn_retain(scr_global_box); + if (known == scr_global_registered && scr_global_box) return scr_dyn_retain(scr_global_box); ScrDyn *global = scr_global_object(); scr_global_console_get = console_get; - if (known->rc == SIZE_MAX) registered = known; + if (known->rc == SIZE_MAX) scr_global_registered = known; for (size_t i = 0; i < known->len; i++) { ScrStr *key = (ScrStr *)scr_arr_get_ref(known, (double)i); if (!scr_dyn_obj_get(scr_global_known, key->data, key->len)) diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index ffd42bd8e..0d4052952 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -351,6 +351,7 @@ bump: 1 2 note: 1 2 indexed: 72100 recall: a,b +globals: undefined fenced `; test("K4: three init sessions are byte-identical", async () => { diff --git a/tests/library-mode/reinit/lib.ts b/tests/library-mode/reinit/lib.ts index dad54090a..73e466f70 100644 --- a/tests/library-mode/reinit/lib.ts +++ b/tests/library-mode/reinit/lib.ts @@ -29,4 +29,22 @@ export function indexedUnicode(): number { return s.length + s.charCodeAt(n) + s.slice(n - 1, n + 2).length; } +// A symbol-keyed global access creates the global object before any read of +// `globalThis` in a session: the builtin names must still be fenced on the +// stored global object, in every session. +const probeKey = Symbol.for("scriptc.reinit"); +function stored(global: typeof globalThis): any { + return global; +} +export function globalFences(): string { + // @ts-expect-error: globalThis has no symbol index signature + const before: unknown = globalThis[probeKey]; + const view = stored(globalThis); + try { + return `${typeof before} ${typeof view.Reflect}`; + } catch { + return `${typeof before} fenced`; + } +} + console.log(`session start counter=${counter}`); diff --git a/tests/library-mode/reinit/probe.c b/tests/library-mode/reinit/probe.c index 8cd17be1e..409766d0d 100644 --- a/tests/library-mode/reinit/probe.c +++ b/tests/library-mode/reinit/probe.c @@ -12,6 +12,7 @@ extern double kr_bump(void); extern double kr_note(const uint8_t *p, size_t len); extern double kr_indexed_unicode(void); extern void kr_recall(const uint8_t **out, size_t *out_len); +extern void kr_global_fences(const uint8_t **out, size_t *out_len); static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) { (void)ctx; (void)addr; @@ -30,6 +31,8 @@ static void session(void) { const uint8_t *s; size_t n; kr_recall(&s, &n); printf("recall: %.*s\n", (int)n, s); + kr_global_fences(&s, &n); + printf("globals: %.*s\n", (int)n, s); } int main(void) { diff --git a/tests/library-mode/reinit/profile.json b/tests/library-mode/reinit/profile.json index 620150db1..cfcfebc9a 100644 --- a/tests/library-mode/reinit/profile.json +++ b/tests/library-mode/reinit/profile.json @@ -14,6 +14,7 @@ { "export": "bump", "symbol": "kr_bump", "params": [], "returns": "f64" }, { "export": "note", "symbol": "kr_note", "params": ["string"], "returns": "f64" }, { "export": "recall", "symbol": "kr_recall", "params": [], "returns": "string" }, - { "export": "indexedUnicode", "symbol": "kr_indexed_unicode", "params": [], "returns": "f64" } + { "export": "indexedUnicode", "symbol": "kr_indexed_unicode", "params": [], "returns": "f64" }, + { "export": "globalFences", "symbol": "kr_global_fences", "params": [], "returns": "string" } ] } From 5966f23bd369bfb916db8175d53b0170eab6743f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:33:33 -0500 Subject: [PATCH 12/39] Keep the class-value call fence when a subclass declares a static call `C.call(...)` through a class value typed as `Base` may run a subclass's own static `call`, which Node calls instead of the class constructor. The constructor-call lowering now applies only when no class the receiver can hold declares a static `call` or `apply`: the class and its bases (any static member of that name, including accessors, fields without initializers and computed names), and, for a receiver that may hold a subclass, every subclass. Otherwise the call keeps its existing diagnostic. --- .../src/frontend/lowering/lower-classes.ts | 42 ++++++++++++++++++- .../class-call-shadowed-static.ts.json | 6 +++ .../diagnostics/class-call-shadowed-static.ts | 15 +++++++ .../class-call-shadowed-static.ts.txt | 8 ++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-call-shadowed-static.ts.json create mode 100644 tests/diagnostics/class-call-shadowed-static.ts create mode 100644 tests/harness/__snapshots__/class-call-shadowed-static.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 17d48913a..a3eddf09a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -6738,8 +6738,10 @@ function staticCallOn( * evaluated (and, for apply, after the argument list is read). The boxed * class value's call path throws exactly that. Neither expando statics nor * writes through untyped values can give a class an own `call` or `apply`. - * The receiver must be one the caller does not evaluate again (a name, - * `this`, or a namespace member). Null otherwise. */ + * A receiver that may hold a subclass at run time (a class value, `this` in + * a static method) needs every subclass to lack such a static too. The + * receiver must be one the caller does not evaluate again (a name, `this`, + * or a namespace member). Null otherwise. */ function classConstructorCall( lowerer: Lowerer, call: ts.CallExpression, @@ -6757,6 +6759,9 @@ function classConstructorCall( info.builtinStream || info.builtinEventTarget || info.runtimeStatics?.length || + declaresStaticAbove(info, name) || + (exactClassOfReceiver(lowerer, receiver) !== info && + info.subclasses.some((subclass) => declaresStaticBelow(subclass, name))) || call.arguments.nodes.some(ts.isSpreadElement) || !( ts.isIdentifier(receiver) || @@ -6776,6 +6781,39 @@ function classConstructorCall( }; } +/** Whether `info`'s declaration has a static member that may be named `name` + * (a method, field or accessor of that name, or a computed name), or `info` + * has no declaration to tell. */ +function declaresStatic(info: ClassInfo, name: string): boolean { + if (!info.decl) return true; + return info.decl.members.nodes.some( + (member) => + member.name !== undefined && + ts.canHaveModifiers(member) && + ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) === + true && + (ts.isComputedPropertyName(member.name) || + ((ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) && + member.name.text === name)), + ); +} + +/** declaresStatic over `info` and its bases, which it inherits statics from. */ +function declaresStaticAbove(info: ClassInfo, name: string): boolean { + for (let c: ClassInfo | null = info; c; c = c.base) if (declaresStatic(c, name)) return true; + return false; +} + +/** declaresStatic over `info` and its subclasses, or runtime statics, which a + * function-local JavaScript class defines at run time. */ +function declaresStaticBelow(info: ClassInfo, name: string): boolean { + return ( + declaresStatic(info, name) || + !!info.runtimeStatics?.length || + info.subclasses.some((subclass) => declaresStaticBelow(subclass, name)) + ); +} + /** `C.m(...)` where `m` is a static method that a function-local * JavaScript class defines at runtime (a factory's `class extends base`), * inherited by `C` through its statically known base chain. When the body diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-call-shadowed-static.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-call-shadowed-static.ts.json new file mode 100644 index 000000000..6c87e5f66 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/class-call-shadowed-static.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/class-call-shadowed-static.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/class-call-shadowed-static.ts b/tests/diagnostics/class-call-shadowed-static.ts new file mode 100644 index 000000000..53a5dc9ad --- /dev/null +++ b/tests/diagnostics/class-call-shadowed-static.ts @@ -0,0 +1,15 @@ +// Through a class value that may hold a subclass, `C.call(...)` reaches the +// subclass's own static `call` when it declares one, so the call is not the +// constructor call that throws Node's TypeError. +class Base { + n = 1; +} +class OwnCall extends Base { + static call(value: unknown): string { + return `own call ${typeof value}`; + } +} +function callThrough(C: typeof Base): unknown { + return C.call(new Base()); +} +console.log(callThrough(OwnCall)); diff --git a/tests/harness/__snapshots__/class-call-shadowed-static.ts.txt b/tests/harness/__snapshots__/class-call-shadowed-static.ts.txt new file mode 100644 index 000000000..e04c8423a --- /dev/null +++ b/tests/harness/__snapshots__/class-call-shadowed-static.ts.txt @@ -0,0 +1,8 @@ +class-call-shadowed-static.ts:13:10 - error SC2020: 'typeof Base.call' is part of the standard library types but has no scriptc lowering yet + + 12 | function callThrough(C: typeof Base): unknown { + 13 | return C.call(new Base()); + | ^~~~~~ + 14 | } + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file From 3991a49094d3fdc5a058d7383efe26dea5483b58 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:40:56 -0500 Subject: [PATCH 13/39] Hold any later value in JavaScript var bindings A JavaScript `var` took the checker's type of its initializer, which no later write is checked against in a JavaScript file. js-yaml 4.1.1 failed on every timestamp with "a 'string' value is not representable in the target union": its `var fraction = 0` holds the fraction's digits as a string while it pads them (`fraction = match[7].slice(0, 3)`). Every YAML error also threw the "assignment to non-variables" fence instead of the YAMLException, because its error mark is a `var` object literal that gains a member (`mark.snippet = snippet(mark)`). - A `var` whose writes the declared type cannot hold is checked-dynamic, by the rules `let` already uses (jsBindingHasOpenWrites), and so is a `var` whose object literal gains members or carries methods (jsDeclarationExpandsObject, shared with `let` and `const`). - `+=` of a string onto a number or boolean binding (`width += "px"`) counts as such a write, for `var` and `let`. - A JavaScript function that returns such a binding returns a checked-dynamic value (an async one a promise of one) instead of the initializer's type. A string binding that is only appended to stays a string. --- .../src/frontend/lowering/function-returns.ts | 44 ++++++ .../src/frontend/lowering/lower-stmts.ts | 133 +++++++++++++----- .../7037-js-var-open-writes/main.ts.json | 6 + .../corpus/7038-js-var-later-writes.mjs.json | 6 + tests/corpus/7037-js-var-open-writes/main.ts | 18 +++ .../node_modules/scalar-marks/index.d.ts | 4 + .../node_modules/scalar-marks/index.js | 81 +++++++++++ .../node_modules/scalar-marks/package.json | 1 + tests/corpus/7038-js-var-later-writes.mjs | 59 ++++++++ .../7037-js-var-open-writes/main.ts.json | 1 + .../7038-js-var-later-writes.mjs.json | 1 + 11 files changed, 321 insertions(+), 33 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7037-js-var-open-writes/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7038-js-var-later-writes.mjs.json create mode 100644 tests/corpus/7037-js-var-open-writes/main.ts create mode 100644 tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.d.ts create mode 100644 tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.js create mode 100644 tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/package.json create mode 100644 tests/corpus/7038-js-var-later-writes.mjs create mode 100644 tests/harness/corpus-costs/7037-js-var-open-writes/main.ts.json create mode 100644 tests/harness/corpus-costs/7038-js-var-later-writes.mjs.json diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 5ede3efe3..dde109ea7 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -15,6 +15,7 @@ import { import { isJsSourceFile } from "../program.js"; import { PoisonError, dynFallbackType } from "./lowerer.js"; import { functionCanReturnUndefined } from "../function-completion.js"; +import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js"; import type { FnCtx } from "./function-context.js"; /** Keep the conservative completion rule shared by return construction @@ -124,6 +125,32 @@ export function producesConstructor(lowerer: Lowerer, type: ts.Type): boolean { return false; } +/** Whether a return of `decl` itself (not of a nested function) is a bare + * read of a JavaScript variable whose writes the checker does not + * constrain. A string that is only appended to stays a string. */ +function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): boolean { + let open = false; + const opens = (value: ts.Expression | undefined): boolean => { + while (value && ts.isParenthesizedExpression(value)) value = value.expression; + if (!value || !ts.isIdentifier(value)) return false; + const binding = lowerer.resolveValueSymbol(value)?.valueDeclaration; + return ( + binding !== undefined && + ts.isVariableDeclaration(binding) && + jsBindingHasOpenWrites(lowerer, binding) && + !jsBindingAppendsOnly(lowerer, binding) + ); + }; + const scan = (node: ts.Node): void => { + if (open || (node !== decl && ts.isFunctionLike(node))) return; + if (ts.isReturnStatement(node) && opens(node.expression)) open = true; + ts.forEachChild(node, scan); + }; + if (ts.isArrowFunction(decl) && decl.body && !ts.isBlock(decl.body)) open = opens(decl.body); + else scan(decl); + return open; +} + /** Map a declaration's return contract while retaining JavaScript * inference fallbacks. Diagnostics preserve the caller's blame node. */ export function declaredReturnType( @@ -212,6 +239,23 @@ export function declaredReturnType( lowerer.unions.get(mappedReturn.unionId)?.arms.some((arm) => arm.kind === "func") ) return DYN; + // A JS function returning a local that holds whatever its writes store + // (jsBindingHasOpenWrites): the inferred result is the initializer's + // type, which the returned value need not have. + // An async function's promise fulfills with that value. + if ( + isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !decl.type && + !decl.asteriskToken && + !hasExplicitJsDocReturn(decl) && + mappedReturn !== null && + mappedReturn.kind !== "dyn" && + !(mappedReturn.kind === "promise" && mappedReturn.inner.kind === "dyn") && + returnsOpenJsBinding(lowerer, decl) + ) + return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ? { kind: "promise", inner: DYN } + : DYN; // JS inference assigns Number to arithmetic over any, although the // runtime operands can both be BigInts. Preserve that native result. if ( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ea5ef72ac..62c2e3a0f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -1220,6 +1220,43 @@ function lowerJsVarFromExisting( }; } +/** Whether a JavaScript declaration's object value changes shape after its + * initializer (jsOpenObjectType, jsObjectBindingExpands) or carries + * methods, so only the checked-dynamic representation holds it. + * `inferred` is the declaration's mapped checker type. */ +function jsDeclarationExpandsObject( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + inferred: IrType | null, +): boolean { + const arms = + inferred?.kind === "union" + ? (lowerer.unions.get(inferred.unionId)?.arms ?? []) + : inferred + ? [inferred] + : []; + return ( + (!decl.type && + !hasJsTypeAnnotation(decl) && + inferred !== null && + jsOpenObjectType(decl, inferred, lowerer.shapes, lowerer.unions).kind === "dyn") || + arms.some((arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm)) || + (isJsSourceFile(ts.getSourceFileOfNode(decl)) && + !decl.type && + !hasJsTypeAnnotation(decl) && + decl.initializer !== undefined && + ts.isObjectLiteralExpression(decl.initializer) && + decl.initializer.properties.nodes.some( + (property) => + ts.isMethodDeclaration(property) || + ts.isAccessor(property) || + (ts.isPropertyAssignment(property) && + (ts.isFunctionExpression(property.initializer) || + ts.isArrowFunction(property.initializer))), + )) + ); +} + /** The `var` symbol's function-scoped binding TYPE — the declared type at * the binding name, with the JS-source fallbacks every mutable binding * takes (an empty-object-literal type is checked-dynamic because tsc @@ -1254,6 +1291,19 @@ function varBindingType(lowerer: Lowerer, nameNode: ts.Identifier): IrType | nul type = staticImportNamespaceType(lowerer, nameNode.parent.initializer) ?? type; } if (!type || type.kind === "void") return null; + // A JavaScript `var` holds whatever its writes store, as a `let` does: + // the checker types it by its initializer and checks no later write + // (`var fraction = 0;`, later `fraction = match[7].slice(0, 3)`), and an + // object it holds may gain members (`mark.snippet = snippet(mark)`). + if ( + type.kind !== "dyn" && + ts.isVariableDeclaration(nameNode.parent) && + nameNode.parent.name === nameNode && + isJsSourceFile(ts.getSourceFileOfNode(nameNode)) && + (jsBindingHasOpenWrites(lowerer, nameNode.parent) || + jsDeclarationExpandsObject(lowerer, nameNode.parent, type)) + ) + return DYN; // A binding readable before its first store holds undefined until then. return lowerer.runtimeOptionalBindingType(nameNode, type); } @@ -5597,26 +5647,7 @@ export function lowerVarDecl( : inferredObjectType ? [inferredObjectType] : []; - const expandsObject = - (!decl.type && - !hasJsTypeAnnotation(decl) && - inferredObjectType !== null && - jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn") || - inferredObjectArms.some( - (arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm), - ) || - (isJsSourceFile(ts.getSourceFileOfNode(decl)) && - !decl.type && - !hasJsTypeAnnotation(decl) && - ts.isObjectLiteralExpression(decl.initializer) && - decl.initializer.properties.nodes.some( - (property) => - ts.isMethodDeclaration(property) || - ts.isAccessor(property) || - (ts.isPropertyAssignment(property) && - (ts.isFunctionExpression(property.initializer) || - ts.isArrowFunction(property.initializer))), - )); + const expandsObject = jsDeclarationExpandsObject(lowerer, decl, inferredObjectType); let init: IrExpr; try { init = @@ -6327,18 +6358,15 @@ export function hasJsTypeAnnotation(decl: ts.VariableDeclaration): boolean { ); } -/** Inferred JS scalar initializers do not constrain later writes through - * an any-valued callback. Keep those bindings open, including captured writes. */ -export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { - if ( - !isJsSourceFile(ts.getSourceFileOfNode(decl)) || - decl.type || - hasJsTypeAnnotation(decl) || - !ts.isIdentifier(decl.name) - ) - return false; - const symbol = lowerer.resolveValueSymbol(decl.name); - if (!symbol) return false; +/** The assignments to a declaration's binding in its function (or file), + * `=` and compound alike. Null when the name has no symbol. */ +function jsBindingWrites( + lowerer: Lowerer, + decl: ts.VariableDeclaration, + name: ts.Identifier, +): ts.BinaryExpression[] | null { + const symbol = lowerer.resolveValueSymbol(name); + if (!symbol) return null; let owner: ts.Node = decl; while (owner.parent && !ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent; let writes = lowerer.jsBindingWritesByOwner.get(owner); @@ -6359,9 +6387,36 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar writes = indexed; lowerer.jsBindingWritesByOwner.set(owner, writes); } - const candidates = (writes.get(decl.name.text) ?? []).filter( + return (writes.get(name.text) ?? []).filter( (node) => lowerer.resolveValueSymbol(node.left as ts.Identifier) === symbol, ); +} + +/** Whether a JavaScript binding always holds a string: the checker types it + * as one, and every write appends to it (`result += part`), which yields a + * string whatever the part is. */ +export function jsBindingAppendsOnly(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if (!ts.isIdentifier(decl.name)) return false; + if (lowerer.mapTypeOf(lowerer.typeOf(decl.name))?.kind !== "string") return false; + const writes = jsBindingWrites(lowerer, decl, decl.name); + return ( + writes !== null && + writes.every((node) => node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken) + ); +} + +/** Inferred JS scalar initializers do not constrain later writes through + * an any-valued callback. Keep those bindings open, including captured writes. */ +export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { + if ( + !isJsSourceFile(ts.getSourceFileOfNode(decl)) || + decl.type || + hasJsTypeAnnotation(decl) || + !ts.isIdentifier(decl.name) + ) + return false; + const candidates = jsBindingWrites(lowerer, decl, decl.name); + if (candidates === null) return false; if ( candidates.some( (node) => @@ -6369,6 +6424,18 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar ) ) return true; + // A `+=` of a string turns a number or boolean binding into a string + // (`n += "px"`); the checker keeps the declared type. + if ( + candidates.some( + (node) => + node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken && + (lowerer.typeOf(node.right).flags & ts.TypeFlags.StringLike) !== 0, + ) + ) { + const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name)); + if (declared?.kind === "f64" || declared?.kind === "bool") return true; + } // A write that can store null or undefined into a binding the checker // types as a primitive (`offset = match[3] || null`): JavaScript files // are checked without strict null checks, so the declared type does not diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7037-js-var-open-writes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7037-js-var-open-writes/main.ts.json new file mode 100644 index 000000000..a1bdb91b8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7037-js-var-open-writes/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7037-js-var-open-writes/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7038-js-var-later-writes.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7038-js-var-later-writes.mjs.json new file mode 100644 index 000000000..1e51a088f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7038-js-var-later-writes.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7038-js-var-later-writes.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/7037-js-var-open-writes/main.ts b/tests/corpus/7037-js-var-open-writes/main.ts new file mode 100644 index 000000000..cc0612974 --- /dev/null +++ b/tests/corpus/7037-js-var-open-writes/main.ts @@ -0,0 +1,18 @@ +// A JavaScript package whose `var` bindings start as one type and later +// hold another, as js-yaml's timestamp and error code does: a number that +// becomes a string and back (`fraction = match[7].slice(0, 3)`), an object +// literal that gains a member (`mark.snippet = snippet(mark)`), and `+=` of +// a string onto a number. Node runs every write; each binding holds it. +import { errorAt, millis, label, total } from "scalar-marks"; + +console.log(millis("2001-12-14t21:59:43.10"), millis("2001-12-14t21:59:43"), millis("1970-01-01t00:00:00.12345")); +for (const [input, at] of [["a: [1", 5], ["key: 'open", 10], ["a:\n [1", 7], ["ok", 1]] as const) { + try { + console.log(errorAt(input, at, "unexpected end")); + } catch (e) { + const err = e as { name: string; message: string; mark: { line: number; column: number; snippet: string } }; + console.log(err.name, "|", err.message, "|", err.mark.line, err.mark.column, JSON.stringify(err.mark.snippet)); + } +} +console.log(label(3), label(0)); +console.log(total([1, 2, 3]), total(["a", "b"]), total([1, "b", 2])); diff --git a/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.d.ts b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.d.ts new file mode 100644 index 000000000..0e0f8252c --- /dev/null +++ b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.d.ts @@ -0,0 +1,4 @@ +export declare function millis(data: string): number; +export declare function errorAt(input: string, position: number, message: string): string; +export declare function label(n: number): string; +export declare function total(values: (number | string)[]): number | string; diff --git a/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.js b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.js new file mode 100644 index 000000000..7d6b6116c --- /dev/null +++ b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/index.js @@ -0,0 +1,81 @@ +"use strict"; +var TIMESTAMP = new RegExp( + '^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)' + + '[Tt]([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?$'); + +// The milliseconds of a timestamp: the fraction starts as the number 0, +// holds its digits as a string while it is padded, and ends as a number. +function millis(data) { + var match, fraction = 0, delta = null; + match = TIMESTAMP.exec(data); + if (match === null) throw new Error('Date resolve error'); + if (match[7]) { + fraction = match[7].slice(0, 3); + while (fraction.length < 3) { + fraction += '0'; + } + fraction = +fraction; + } + if (delta) fraction -= delta; + return Date.UTC(+match[1], +match[2] - 1, +match[3], +match[4], +match[5], +match[6], fraction); +} + +function State(input) { + this.input = input; + this.position = 0; + this.line = 0; + this.lineStart = 0; +} + +function snippet(mark) { + var start = mark.position, text = mark.buffer; + return text.slice(0, start) + '<' + text.slice(start) + '>'; +} + +function MarkError(reason, mark) { + Error.call(this); + this.name = 'MarkError'; + this.reason = reason; + this.mark = mark; + this.message = reason + ' (' + (mark.line + 1) + ':' + (mark.column + 1) + ')'; +} +MarkError.prototype = Object.create(Error.prototype); +MarkError.prototype.constructor = MarkError; + +// The error's mark is an object literal that gains its snippet afterwards. +function generateError(state, message) { + var mark = { + buffer: state.input, + position: state.position, + line: state.line, + column: state.position - state.lineStart + }; + mark.snippet = snippet(mark); + return new MarkError(message, mark); +} + +function errorAt(input, position, message) { + var state = new State(input); + for (var i = 0; i < position && i < input.length; i++) { + state.position++; + if (input[i] === '\n') { state.line++; state.lineStart = state.position; } + } + if (state.position >= input.length) throw generateError(state, message); + return 'no error at ' + state.position; +} + +// A number binding that `+=` turns into a string. +function label(n) { + var width = 10; + width += 'px'; + return n + ' ' + width; +} + +// A sum that becomes a string concatenation once a string arrives. +function total(values) { + var sum = 0; + for (var i = 0; i < values.length; i++) sum += values[i]; + return sum; +} + +module.exports = { millis: millis, errorAt: errorAt, label: label, total: total }; diff --git a/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/package.json b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/package.json new file mode 100644 index 000000000..ade0a9891 --- /dev/null +++ b/tests/corpus/7037-js-var-open-writes/node_modules/scalar-marks/package.json @@ -0,0 +1 @@ +{ "name": "scalar-marks", "version": "1.0.0", "main": "./index.js", "types": "./index.d.ts" } diff --git a/tests/corpus/7038-js-var-later-writes.mjs b/tests/corpus/7038-js-var-later-writes.mjs new file mode 100644 index 000000000..48b85d77a --- /dev/null +++ b/tests/corpus/7038-js-var-later-writes.mjs @@ -0,0 +1,59 @@ +// JavaScript `var` and `let` bindings that later hold another type than +// their initializer's, which nothing checks in a JavaScript file: Node +// runs each write, and a function returning the binding returns what it +// holds. +function retyped(input) { + var value = 0; + value = input.slice(0, 2); + return value; +} + +function padded(digits) { + var fraction = 0; + if (digits) { + fraction = digits.slice(0, 3); + while (fraction.length < 3) fraction += "0"; + fraction = +fraction; + } + return fraction + 1; +} + +function suffixed(n) { + var width = n * 2; + width += "px"; + return width; +} + +function letRetyped(record) { + let current = 1; + current = record.name; + return current; +} + +function grown(line) { + var mark = { line: line, column: line - 1 }; + mark.text = "at " + (mark.line + 1); + return mark; +} + +async function awaitedTotal(values) { + var total = 0; + for (var i = 0; i < values.length; i++) total += await values[i]; + console.log("awaited", total, typeof total); + return total; +} + +function counted(values) { + var count = 0; + for (var i = 0; i < values.length; i++) count += values[i]; + return count; +} + +console.log(retyped("abc"), typeof retyped("abc")); +console.log(padded("5"), padded("12345"), padded("")); +console.log(suffixed(4), typeof suffixed(4)); +console.log(letRetyped({ name: "x" }), letRetyped({ name: 2 })); +console.log(JSON.stringify(grown(3)), Object.keys(grown(0)).join(",")); +console.log(counted([1, 2]), counted([1, "a", 2])); +await awaitedTotal([1, Promise.resolve(2)]); +await awaitedTotal([1, Promise.resolve("b")]); diff --git a/tests/harness/corpus-costs/7037-js-var-open-writes/main.ts.json b/tests/harness/corpus-costs/7037-js-var-open-writes/main.ts.json new file mode 100644 index 000000000..2e747487d --- /dev/null +++ b/tests/harness/corpus-costs/7037-js-var-open-writes/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1970, "san": 2150 } diff --git a/tests/harness/corpus-costs/7038-js-var-later-writes.mjs.json b/tests/harness/corpus-costs/7038-js-var-later-writes.mjs.json new file mode 100644 index 000000000..29130736b --- /dev/null +++ b/tests/harness/corpus-costs/7038-js-var-later-writes.mjs.json @@ -0,0 +1 @@ +{ "plain": 1570, "san": 2390 } From 5e4303e62d9da01a322bbff6ac22c01537774d5d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:52:22 -0500 Subject: [PATCH 14/39] Keep the native compiler's LLVM text as the emitter's parts The LLVM emitter builds a module as parts and joined them into one string: on a large program such as tsc-ts the parts and the 150 MB joined text were live together at the end of emission. The native compiler only writes, escapes and hashes the text, so it now takes the emitter's parts (emitLlvmModuleParts) and never joins them. Consecutive small parts are joined into batches of about 1 MiB as they are written, escaped or hashed (joinedParts), so the many small parts cost few calls. A frontend cache hit reads the text back as one part. The three emission entry points share one module pipeline (moduleEmitter). --- packages/compiler/src/backend/llvm/emitter.ts | 47 +++++++------------ packages/compiler/src/native/cache.ts | 9 +++- packages/compiler/src/native/compiler.ts | 8 ++-- .../compiler/src/native/executable-cache.ts | 6 +-- packages/compiler/src/native/prepare.test.ts | 16 +++++-- packages/compiler/src/native/prepare.ts | 23 +++++---- .../compiler/src/native/text-file.test.ts | 12 ++++- packages/compiler/src/native/text-file.ts | 24 ++++++++-- 8 files changed, 89 insertions(+), 56 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9c42920b9..9d6bc2dfd 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -483,7 +483,8 @@ function versionParameters(mod: IrModule): IrModule { return versionLoopBoundParameters(mod, mangleFunction); } -export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { +/** The emitter for `input` after the module passes every emission runs. */ +function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. const mod = unshareNodes(input); @@ -515,7 +516,19 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) ) : mod, options, - ).emit(); + ); +} + +export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { + return moduleEmitter(input, options).emit(); +} + +/** emitLlvmModule's text as parts that concatenate to it, without the copy + * that joining them makes: a large program's text is 150 MB. */ +export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] { + return moduleEmitter(input, options) + .emitParts() + .flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); } /** Keep large translation units below the host's single-string limit. */ @@ -525,35 +538,7 @@ export function emitLlvmModuleSource( ): string | readonly string[] { // IR validation, contracts and the frontend's disposal run after lowering without checks. relieveHeapPressure(true); - const mod = unshareNodes(input); - const parts = new LlEmitter( - options.debugSources === undefined - ? versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), - ), - ), - ), - ), - ), - mangleFunction, - ), - mangleFunction, - ), - ), - ), - ) - : mod, - options, - ).emitParts(); + const parts = moduleEmitter(input, options).emitParts(); const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1)); if (length <= 256 * 1024 * 1024) return parts.join("\n"); return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part])); diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 566df308a..3c9bdf411 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -14,7 +14,7 @@ import { } from "node:fs"; import { join } from "node:path"; import { resolveBuildCacheRoot } from "../backend/cache-root.js"; -import { writeTextGeneratedSync } from "./text-file.js"; +import { joinedParts, writeTextGeneratedSync } from "./text-file.js"; /** UTF-8 bytes one native compiler string can hold (the runtime's * SCR_STR_MAX_CAP). Exceeding it traps rather than throwing, so payloads @@ -28,6 +28,13 @@ export function contentDigest(value: string | Uint8Array): string { return hash.digest("hex"); } +/** contentDigest of the text the parts concatenate to. */ +export function partsDigest(parts: readonly string[]): string { + const hash = createHash("sha256"); + for (const part of joinedParts(parts)) hash.update(part); + return hash.digest("hex"); +} + /** contentDigest of a file's bytes, read in 1 MiB blocks so a large file * (a module's LLVM text) is never held whole. */ export function fileDigest(path: string): string { diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 2c850cb14..a37d76f80 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -362,7 +362,7 @@ export class NativeCompiler { mkdirSync(inputDirectory); const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll"); // In slices: the module text is never encoded whole. - writeTextPartsSync(llvmInput, [llvm]); + writeTextPartsSync(llvmInput, llvm); timing("llvm-write"); if (outputKind === "llvm") { renameSync(stagedOutput, output); @@ -496,7 +496,7 @@ export class NativeCompiler { renameSync(stagedOutput + ".dSYM", output + ".dSYM"); } mkdirSync(options.outDir, { recursive: true }); - writeTextPartsSync(llvmPath, [llvm]); + writeTextPartsSync(llvmPath, llvm); let irPath: string | undefined; if (options.emitIr) { irPath = join(options.outDir, `${stem}.ir.json`); @@ -585,7 +585,7 @@ export class NativeCompiler { const llvm = prepared.llvm; const llvmInput = join(stage, "program.ll"); // In slices: the module text is never encoded whole. - writeTextPartsSync(llvmInput, [llvm]); + writeTextPartsSync(llvmInput, llvm); timing("llvm-write"); const features = prepared.features; const pack = selectNativeRuntimePack( @@ -648,7 +648,7 @@ export class NativeCompiler { }); } timing("native-archive"); - writeTextPartsSync(llvmPath, [llvm]); + writeTextPartsSync(llvmPath, llvm); let irPath: string | undefined; if (options.emitIr) { irPath = join(options.outDir, `${stem}.lib.ir.json`); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index a122fb759..c0ac755b9 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -11,7 +11,7 @@ import { import { runNativeTool } from "../backend/native-tools.js"; import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js"; import type { NativeToolchain } from "./toolchain.js"; -import { contentDigest, NativeCache } from "./cache.js"; +import { contentDigest, NativeCache, partsDigest } from "./cache.js"; interface InputIdentity { path: string; @@ -254,7 +254,7 @@ export class NativeExecutableCache { export function openNativeExecutableCache( cache: NativeCache | null, toolchain: NativeToolchain, - llvm: string, + llvm: readonly string[], options: CompileRequestOptions, ffi: FfiProfile | null, pack: NativeRuntimeSelection, @@ -299,7 +299,7 @@ export function openNativeExecutableCache( const key = contentDigest( JSON.stringify({ schema: 1, - llvm: contentDigest(llvm), + llvm: partsDigest(llvm), options, toolchain, runtimeIdentity: pack.packageText, diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 7e22450da..95274f1cd 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -11,6 +11,12 @@ import { NativeCache } from "./cache.js"; import { prepareNativeExecutable } from "./prepare.js"; import type { NativeToolchain } from "./toolchain.js"; +/** A prepared input with its LLVM parts joined: a cache hit reads the text + * back as one part. */ +function text(input: ReturnType["prepare"]>) { + return input.ok ? { ...input, llvm: input.llvm.join("") } : input; +} + const scratch: string[] = []; afterEach(() => { for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true }); @@ -68,14 +74,14 @@ test("warm frontend reuse survives output creation and invalidates imported sour if (!first.ok) return; mkdirSync(f.options.outDir); writeFileSync(f.options.outPath, "binary placeholder"); - writeFileSync(join(f.options.outDir, "main.ll"), first.llvm); + writeFileSync(join(f.options.outDir, "main.ll"), first.llvm.join("")); const warm = f.prepare(); - expect(warm).toEqual(first); + expect(text(warm)).toEqual(text(first)); expect(f.loads()).toBe(1); writeFileSync(f.dependency, 'export const value = "second";\n'); const edited = f.prepare(); expect(edited.ok).toBe(true); - if (edited.ok) expect(edited.llvm).not.toBe(first.llvm); + if (edited.ok) expect(edited.llvm.join("")).not.toBe(first.llvm.join("")); expect(f.loads()).toBe(2); }); @@ -101,13 +107,13 @@ test("serialized IR travels as bounded parts through the frontend cache", () => expect(mod.functions.length).toBeGreaterThan(0); const warm = f.prepare({ emitIr: true }); expect(f.loads()).toBe(1); - expect(warm).toEqual(first); + expect(text(warm)).toEqual(text(first)); const family = join(f.cache.root, "frontend"); const key = readdirSync(family).find((name) => /^[0-9a-f]{64}$/.test(name))!; const payload = JSON.parse(readFileSync(join(family, key), "utf8")); expect(payload.schema).toBe("scriptc.native-frontend.v3"); expect(payload.input.llvm).toBe(""); expect(payload.input.ir).toBeNull(); - expect(payload.llvm).toBe(first.llvm); + expect(payload.llvm).toBe(first.llvm.join("")); expect(payload.ir).toEqual(first.ir); }); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index edc90c449..a2c627edf 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -7,7 +7,11 @@ import type { CompileLibraryOptions, CompileRequestOptions, } from "../compile-types.js"; -import { emitLlvmModule, type ForkPrimitives, lowRegionTarget } from "../backend/llvm/emitter.js"; +import { + emitLlvmModuleParts, + type ForkPrimitives, + lowRegionTarget, +} from "../backend/llvm/emitter.js"; import { inlineRcOption } from "../backend/llvm/shapes.js"; import { executableLinkFeatures } from "../backend/executable-features.js"; import type { NativeLinkFeatures } from "../backend/native-link-info-core.js"; @@ -29,13 +33,15 @@ import type { LibraryProfile } from "../library/library-profile.js"; import type { IrModule } from "../ir/ir.js"; import { deserializeModuleBytes, serializeModuleParts } from "../ir/serialize.js"; import { contentDigest, NATIVE_STRING_BYTES, type NativeCache } from "./cache.js"; -import { batchTextParts, textSlices } from "./text-file.js"; +import { batchTextParts, joinedParts, textSlices } from "./text-file.js"; import type { NativeToolchain } from "./toolchain.js"; import { nativeFileIdentity } from "./file-identity.js"; export interface NativeExecutableInput { ok: true; - llvm: string; + /** The module's LLVM text as parts that concatenate to it + * (emitLlvmModuleParts), never joined: a large program's is 150 MB. */ + llvm: readonly string[]; /** Compact serialized IR as bounded parts (`batchTextParts`): compiler-scale * modules exceed one native string. */ ir: string[] | null; @@ -71,7 +77,8 @@ function* frontendCacheParts( input: { ...input, llvm: "", ir: null }, }); yield metadata.slice(0, metadata.length - 1) + ',"llvm":"'; - for (const slice of textSlices(input.llvm)) yield JSON.stringify(slice).slice(1, -1); + for (const part of joinedParts(input.llvm)) + for (const slice of textSlices(part)) yield JSON.stringify(slice).slice(1, -1); yield '"'; if (input.ir === null) yield ',"ir":null}'; else { @@ -122,7 +129,7 @@ function readFrontendCache( const cached: unknown = JSON.parse(bytes.toString("utf8")); if (!validCachedInput(cached) || !frontendInputsStillMatch(cached.probes, exclusions)) return null; - return { ...cached.input, llvm: cached.llvm, ir: cached.ir }; + return { ...cached.input, llvm: [cached.llvm], ir: cached.ir }; } function implementationIdentity(toolchain: NativeToolchain): string { @@ -310,8 +317,8 @@ export function prepareNativeExecutable( timing("ir-serialize"); const llvm = outputKind === "ir" - ? "" - : emitLlvmModule(prepared.mod, { + ? [] + : emitLlvmModuleParts(prepared.mod, { targetTriple: toolchain.target.llvmTriple, pointerBits: toolchain.target.pointerBits, wasi: toolchain.target.platform === "wasi", @@ -427,7 +434,7 @@ export function prepareNativeLibrary( ); }); if (!prepared.ok) return prepared; - const llvm = emitLlvmModule(prepared.mod, { + const llvm = emitLlvmModuleParts(prepared.mod, { targetTriple: toolchain.target.llvmTriple, pointerBits: toolchain.target.pointerBits, wasi: toolchain.target.platform === "wasi", diff --git a/packages/compiler/src/native/text-file.test.ts b/packages/compiler/src/native/text-file.test.ts index b497bc4a2..7534b5058 100644 --- a/packages/compiler/src/native/text-file.test.ts +++ b/packages/compiler/src/native/text-file.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; -import { textSlices, writeTextPartsSync } from "./text-file.js"; +import { joinedParts, textSlices, writeTextPartsSync } from "./text-file.js"; const directories: string[] = []; afterEach(() => { @@ -40,3 +40,13 @@ test("a part larger than one slice is written as its UTF-8 bytes", () => { expect(seen.join("")).toBe("head," + large + "tail"); expect(Math.max(...seen.map((slice) => slice.length))).toBeLessThanOrEqual(1024 * 1024); }); + +test("small parts join into batches and a long part passes through whole", () => { + const small = Array.from({ length: 3000 }, (_, index) => `line ${index}\n`); + const long = "x".repeat(2 * 1024 * 1024); + const parts = [...small, long, "tail"]; + const joined = [...joinedParts(parts)]; + expect(joined.join("")).toBe(parts.join("")); + expect(joined.length).toBeLessThan(5); + expect(joined).toContain("tail"); +}); diff --git a/packages/compiler/src/native/text-file.ts b/packages/compiler/src/native/text-file.ts index d377c5273..8b0953120 100644 --- a/packages/compiler/src/native/text-file.ts +++ b/packages/compiler/src/native/text-file.ts @@ -42,6 +42,24 @@ export function* textSlices(text: string, length = TEXT_WRITE_LENGTH): Generator } } +/** `parts` with consecutive parts joined into strings of about + * TEXT_WRITE_LENGTH UTF-16 units (a longer part passes through whole), so + * writing or escaping many small parts takes few calls. */ +export function* joinedParts(parts: readonly string[]): Generator { + let batch: string[] = []; + let length = 0; + for (const part of parts) { + batch.push(part); + length += part.length; + if (length >= TEXT_WRITE_LENGTH) { + yield batch.length === 1 ? batch[0]! : batch.join(""); + batch = []; + length = 0; + } + } + if (batch.length > 0) yield batch.join(""); +} + /** Encodes and writes `part` to `fd` in slices (`textSlices`), so a large * part is never encoded whole. `written` sees each slice after it is * written. */ @@ -55,8 +73,8 @@ function writeTextPart(fd: number, part: string, written?: (slice: string) => vo } } -/** Write text parts to `path` in order without joining them first; pass - * batched parts (`batchTextParts`) for many small ones. */ +/** Write text parts to `path` in order without joining them whole: small + * parts are written in batches (`joinedParts`). */ export function writeTextPartsSync( path: string, parts: readonly string[], @@ -64,7 +82,7 @@ export function writeTextPartsSync( ): void { const fd = openSync(path, "w"); try { - for (const part of parts) writeTextPart(fd, part, written); + for (const part of joinedParts(parts)) writeTextPart(fd, part, written); } finally { closeSync(fd); } From 7b79d73a550e530a4a20541ef5428e854a331fa5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:52:38 -0500 Subject: [PATCH 15/39] Store local number arrays of proven int32s in 4-byte slots A local number[] that only compiled element operations reach, and whose every stored element the int32 slot analysis proves an int32 (never -0), is now stored as SCR_ELEM_I32: 4-byte int32 slots, with state bytes once it holds a hole or undefined, as for every array. Element reads give consumers an exact int32, so indexing with an element skips the double round trip, and the slots take half the memory. The runtime's dense slot accessors convert between the int32 slot and the double, so its double-valued entry points serve int32 arrays unchanged; growth, copies, fill and length changes use the 4-byte width. ir/int32-arrays.ts proposes the candidates and inlines their element-reader helper calls; the int32 slot analysis keeps the proven ones, and selectInt32Arrays rewrites the module for exactly those, so a program without an int32 array emits the same LLVM IR as before. --- .../llvm/appended-array-literals.test.ts | 7 +- .../src/backend/llvm/dense-array-access.ts | 119 ++++- .../src/backend/llvm/emit-function.ts | 1 + .../src/backend/llvm/emit-statements.ts | 4 +- packages/compiler/src/backend/llvm/emitter.ts | 115 +++-- .../src/backend/llvm/expr-containers.ts | 31 +- .../src/backend/llvm/expr-primitives.ts | 52 ++- .../src/backend/llvm/local-array-reads.ts | 47 +- .../src/ir/function-references.test.ts | 1 + packages/compiler/src/ir/int32-arrays.ts | 442 ++++++++++++++++++ packages/compiler/src/ir/int32-slots.ts | 55 ++- packages/compiler/src/ir/integer-ranges.ts | 146 +++++- packages/compiler/src/ir/ir.ts | 5 + .../test/int32-element-array-emission.test.ts | 130 ++++++ .../corpus/7326-int32-element-arrays.ts.json | 6 + packages/runtime/src/scr_array.c | 7 +- packages/runtime/src/scr_array.h | 48 +- packages/runtime/test/test_array.c | 59 +++ tests/corpus/7326-int32-element-arrays.ts | 165 +++++++ .../7326-int32-element-arrays.ts.json | 1 + 20 files changed, 1335 insertions(+), 106 deletions(-) create mode 100644 packages/compiler/src/ir/int32-arrays.ts create mode 100644 packages/compiler/test/int32-element-array-emission.test.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json create mode 100644 tests/corpus/7326-int32-element-arrays.ts create mode 100644 tests/harness/corpus-costs/7326-int32-element-arrays.ts.json diff --git a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts index c483b7848..11a9704f6 100644 --- a/packages/compiler/src/backend/llvm/appended-array-literals.test.ts +++ b/packages/compiler/src/backend/llvm/appended-array-literals.test.ts @@ -57,10 +57,11 @@ test("only empty literals stored where the module appends construct small", () = expect(found.has(idle)).toBe(false); expect(found.has(returned)).toBe(false); - // Only `fill` builds arrays. + // Only `fill` builds arrays. They may hold int32 slots (element kind 7), + // which does not change the choice of constructor. const llvm = emitLlvmModule(module(body, ["xs", "ys"])); - expect(llvm.match(/call ptr @scr_arr_new_small\(i32 0\)/g)).toHaveLength(2); - expect(llvm.match(/call ptr @scr_arr_new\(i32 0, i64 0\)/g)).toHaveLength(2); + expect(llvm.match(/call ptr @scr_arr_new_small\(i32 [07]\)/g)).toHaveLength(2); + expect(llvm.match(/call ptr @scr_arr_new\(i32 [07], i64 0\)/g)).toHaveLength(2); }); test("a binding with the same id in another function does not count", () => { diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 9158d12cd..cc8d21ced 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -239,9 +239,10 @@ function statePointer( return statePtr; } -/** How an array's dense slots hold its elements: the access kind, or - * `cref` for references to compact class instances (SCR_ELEM_CREF: a 4-byte - * slot holding the address over 8; LlEmitter.compactArrayElem). */ +/** How an array's dense slots hold its elements: the access kind (`i32` + * for int32 arrays, ir/int32-arrays.ts), or `cref` for references to compact + * class instances (SCR_ELEM_CREF: a 4-byte slot holding the address over 8; + * LlEmitter.compactArrayElem). */ type SlotKind = ArrayAccess | "cref"; function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType): SlotKind { @@ -249,14 +250,14 @@ function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType): } /** The dense slot type: boolean arrays store one byte per element, compact - * references four, every other element kind an 8-byte slot. */ + * references and int32 arrays four, every other element kind an 8-byte slot. */ function slotType(kind: SlotKind): "i8" | "i32" | "i64" { - return kind === "bool" ? "i8" : kind === "cref" ? "i32" : "i64"; + return kind === "bool" ? "i8" : kind === "cref" || kind === "i32" ? "i32" : "i64"; } /** Bytes per dense slot (scr_arr_slot_width). */ -function slotWidth(kind: SlotKind): number { - return kind === "bool" ? 1 : kind === "cref" ? 4 : 8; +export function slotWidth(kind: SlotKind): number { + return kind === "bool" ? 1 : kind === "cref" || kind === "i32" ? 4 : 8; } /** Bytes per dense slot of an array of `element` (scr_arr_slot_width). */ @@ -264,6 +265,17 @@ export function denseSlotWidth(host: LlvmEmitterContext, element: IrType): numbe return slotWidth(slotKind(host, elemAccess(element), element)); } +/** The runtime accessor family of an access: int32 arrays answer through + * the double-valued entry points, which convert their slots. */ +export function runtimeAccess(acc: ArrayAccess): "f64" | "bool" | "ref" { + return acc === "i32" ? "f64" : acc; +} + +/** The LLVM type of an access's values at the emitter's interface. */ +function valueType(acc: ArrayAccess): "double" | "i1" | "ptr" { + return acc === "f64" || acc === "i32" ? "double" : acc === "bool" ? "i1" : "ptr"; +} + function valuePointer( host: LlvmEmitterContext, guard: Pick, @@ -285,7 +297,13 @@ function loadState(host: LlvmEmitterContext, guard: DenseGuard): string { return state; } -export type ArrayAccess = "f64" | "bool" | "ref"; +/** The ScrElemKind of int32 arrays (scr_array.h). */ +export const SCR_ELEM_I32 = 7; + +/** How a dense array's slots are read and written: doubles, booleans, + * references, or the int32 slots of SCR_ELEM_I32 arrays (ir/int32-arrays.ts), + * whose values are doubles at the emitter's interface. */ +export type ArrayAccess = "f64" | "bool" | "ref" | "i32"; /** Before a self-append (`a[i] = a[i] + suffix`) replaces a reference * element: when `index` lies in the hole-free prefix and the slot still @@ -341,15 +359,33 @@ export function emitDenseArrayGet( retain: boolean, proven = false, ): string { + return emitDenseArrayRead(host, array, index, indexExpr, acc, element, getter, retain, proven) + .value; +} + +/** emitDenseArrayGet, with the int32 of an int32 array's element: the + * slot itself on the dense path, the runtime getter's double converted + * (exactly: the element is an int32) on the other. */ +export function emitDenseArrayRead( + host: LlvmEmitterContext, + array: string, + index: LlValue, + indexExpr: IrExpr | undefined, + acc: ArrayAccess, + element: IrType, + getter: string, + retain: boolean, + proven = false, +): { value: string; int32: string | null } { const B = host.B; - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const ty = valueType(acc); const ret = acc === "bool" ? "zeroext i1" : ty; const kind = slotKind(host, acc, element); host.declare(`declare ${ret} @${getter}(ptr, double)`); if (acc === "ref" && retain && !isRefCounted(element)) { const t = B.tmp(); B.line(`${t} = call ${ty} @${getter}(ptr ${array}, double ${index.name})`); - return t; + return { value: t, int32: null }; } const slow = B.newLabel("arr.get.slow"), join = B.newLabel("arr.get.join"), @@ -380,10 +416,16 @@ export function emitDenseArrayGet( B.startBlock(valueLabel); const ptr = valuePointer(host, guard, kind); let fast: string; + let fastInt: string | null = null; let fastLabel = valueLabel; if (acc === "f64") { fast = B.tmp(); B.line(`${fast} = load double, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); + } else if (acc === "i32") { + fastInt = B.tmp(); + fast = B.tmp(); + B.line(`${fastInt} = load i32, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`); + B.line(`${fast} = sitofp i32 ${fastInt} to double`); } else if (acc === "bool") { const raw = B.tmp(); fast = B.tmp(); @@ -410,11 +452,21 @@ export function emitDenseArrayGet( const slowValue = B.tmp(); const attrs = acc === "ref" && retain ? " cold" : READ_FALLBACK; B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`); + let slowInt: string | null = null; + if (fastInt !== null) { + slowInt = B.tmp(); + B.line(`${slowInt} = fptosi double ${slowValue} to i32`); + } B.br(join); B.startBlock(join); const result = B.tmp(); B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`); - return result; + let int32: string | null = null; + if (fastInt !== null && slowInt !== null) { + int32 = B.tmp(); + B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slow} ]`); + } + return { value: result, int32 }; } /** `scr_arr_get_number`: a numeric element, or NaN for every missing, @@ -425,13 +477,14 @@ export function emitDenseArrayNumber( index: LlValue, indexExpr: IrExpr | undefined, proven = false, + acc: "f64" | "i32" = "f64", ): string { return emitDenseArrayGet( host, array, index, indexExpr, - "f64", + acc, { kind: "f64" }, "scr_arr_get_number", false, @@ -498,9 +551,9 @@ export function emitDenseArraySet( proven = false, ): void { const B = host.B; - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const ty = valueType(acc); const kind = slotKind(host, acc, element); - const setter = `scr_arr_set_${acc}`; + const setter = `scr_arr_set_${runtimeAccess(acc)}`; host.declare(`declare void @${setter}(ptr, double, ${acc === "bool" ? "i1 zeroext" : ty})`); const call = (attrs: string): void => B.line(`call void @${setter}(ptr ${array}, double ${index.name}, ${ty} ${value})${attrs}`); @@ -508,6 +561,13 @@ export function emitDenseArraySet( call(""); return; } + // An int32 array's stores are proven int32s (ir/int32-arrays.ts); both + // dense paths below store the converted value. + let int32Value: string | null = null; + if (acc === "i32") { + int32Value = B.tmp(); + B.line(`${int32Value} = fptosi double ${value} to i32`); + } const slow = B.newLabel("arr.set.slow"), join = B.newLabel("arr.set.join"), filledLabel = B.newLabel("arr.set.filled"); @@ -526,6 +586,8 @@ export function emitDenseArraySet( const storeValue = (valuePtr: string): void => { if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "i32") { + B.line(`store i32 ${int32Value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { const bits = B.tmp(); B.line(`${bits} = zext i1 ${value} to i8`); @@ -713,12 +775,21 @@ export function emitDenseArrayPop( B.line(`${wide} = zext i8 ${raw} to i64`); raw = wide; } else if (slotTy === "i32") { - // The 8-byte slot of a compact reference is its address. - const wide = B.tmp(), - address = B.tmp(); - B.line(`${wide} = zext i32 ${raw} to i64`); - B.line(`${address} = shl nuw nsw i64 ${wide}, 3`); - raw = address; + if (kind === "cref") { + // The 8-byte slot of a compact reference is its address. + const wide = B.tmp(), + address = B.tmp(); + B.line(`${wide} = zext i32 ${raw} to i64`); + B.line(`${address} = shl nuw nsw i64 ${wide}, 3`); + raw = address; + } else { + // The slot bits of an int32 element's double, as the f64 path returns them. + const number = B.tmp(), + bits = B.tmp(); + B.line(`${number} = sitofp i32 ${raw} to double`); + B.line(`${bits} = bitcast double ${number} to i64`); + raw = bits; + } } B.line(`store ${slotTy} 0, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); // Only an array with states has a state byte to clear. @@ -755,8 +826,8 @@ export function emitDenseArrayPush( const B = host.B; const size = host.sizeType; const kind = element === null ? acc : slotKind(host, acc, element); - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; - const pusher = `scr_arr_push_${acc}`; + const ty = valueType(acc); + const pusher = `scr_arr_push_${runtimeAccess(acc)}`; host.declare(`declare double @${pusher}(ptr, ${acc === "bool" ? "i1 zeroext" : ty})`); const header = (field: number): string => { const ptr = B.tmp(); @@ -794,6 +865,10 @@ export function emitDenseArrayPush( const valuePtr = valuePointer(host, { offset: len, data }, kind); if (acc === "f64") { B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + } else if (acc === "i32") { + const int32 = B.tmp(); + B.line(`${int32} = fptosi double ${value} to i32`); + B.line(`store i32 ${int32}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); } else if (acc === "bool") { const bits = B.tmp(); B.line(`${bits} = zext i1 ${value} to i8`); diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 16e654393..2484bc38d 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -61,6 +61,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const B = new BlockBuilder(); emitter.B = B; emitter.currentFunctionName = fn.name; + emitter.findInt32Allocations(fn); emitter.currentTier = fn.tier; // Fiber switches restore the active exception cell before control // returns to a synchronous frame (emitPendingCheck). diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index b530f794d..11d375a55 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -411,7 +411,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const v = emitter.emitExpr(s.value); if (s.arr.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arraySet on non-array"); - const acc = elemAccess(s.arr.type.elem); + const acc = emitter.arrayAccess(s.arr, s.arr.type.elem); emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); if (acc === "ref") emitter.toHeap(v); emitDenseArraySet( @@ -478,7 +478,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.condBr(dropsLast, pop, call); B.startBlock(pop); const elem = s.arr.type.elem; - const acc = elemAccess(elem); + const acc = emitter.arrayAccess(s.arr, elem); const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call); if (acc === "ref" && isRefCounted(elem)) { const old = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9c42920b9..8c6c61953 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -79,6 +79,7 @@ import { } from "./emit-journal.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { versionLoopBoundParameters } from "../../ir/parameter-versions.js"; +import { allocatedLiteral, selectInt32Arrays } from "../../ir/int32-arrays.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { INT32_RANGE, type IntegerRange, type IntegerRanges } from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; @@ -147,7 +148,7 @@ import { } from "./inline-unions.js"; import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; import { CheckedNarrows } from "./checked-narrows.js"; -import { emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js"; +import { emitDenseArraySet, emitDenseSlotUnlink, type ArrayAccess } from "./dense-array-access.js"; import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js"; import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; @@ -483,6 +484,15 @@ function versionParameters(mod: IrModule): IrModule { return versionLoopBoundParameters(mod, mangleFunction); } +/** The int32 arrays the int32 slot analysis keeps in `mod`, under the + * inputs LlEmitter gives it (selectInt32Arrays). */ +function provenInt32Arrays(mod: IrModule): ReadonlySet { + return analyzeInt32Slots(mod, { + dynamicStores: dynamicFieldStores(mod), + initializerBindings: findInitializerBindings(mod), + }).int32Arrays; +} + export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. @@ -490,28 +500,31 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) // Keep source storage intact for debugger inspection in dev builds. return new LlEmitter( options.debugSources === undefined - ? versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ? selectInt32Arrays( + versionParameters( + addRecordResultEntries( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ), ), ), ), ), + mangleFunction, ), mangleFunction, ), - mangleFunction, ), ), ), + provenInt32Arrays, ) : mod, options, @@ -528,28 +541,31 @@ export function emitLlvmModuleSource( const mod = unshareNodes(input); const parts = new LlEmitter( options.debugSources === undefined - ? versionParameters( - addRecordResultEntries( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ? selectInt32Arrays( + versionParameters( + addRecordResultEntries( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)), + ), ), ), ), ), + mangleFunction, ), mangleFunction, ), - mangleFunction, ), ), ), + provenInt32Arrays, ) : mod, options, @@ -734,6 +750,53 @@ export class LlEmitter { return `, !tbaa !${tag}`; } + /** The current function's int32 array allocations: the array literals + * its int32 arrays are written with. They construct SCR_ELEM_I32 arrays. */ + int32Allocations = new Set(); + /** The fill temporaries (`new Array(n).fill(v)`) of those allocations: + * the same arrays, so int32 too. */ + int32ArrayTemps = new Set(); + + /** Collect the int32 allocations of `fn`, about to be emitted. */ + findInt32Allocations(fn: IrFunction): void { + this.int32Allocations.clear(); + this.int32ArrayTemps.clear(); + if (this.int32Slots.int32Arrays.size === 0) return; + everyStmtList(fn.body, { + stmt: (s) => { + if ( + (s.kind === "varDecl" || s.kind === "assign") && + this.int32Slots.int32Arrays.has(`${fn.name}\0${s.localId}`) + ) { + const value = s.kind === "varDecl" ? s.init : s.value; + const allocation = value && allocatedLiteral(value); + if (allocation) { + this.int32Allocations.add(allocation.literal); + if (allocation.temporary !== null) this.int32ArrayTemps.add(allocation.temporary); + } + } + return true; + }, + expr: () => true, + }); + } + + /** Whether `arr` is one of the current function's int32 arrays + * (ir/int32-arrays.ts, kept by the int32 slot analysis). */ + int32Array(arr: IrExpr): boolean { + if (arr.kind !== "varRef" || this.int32Slots.int32Arrays.size === 0) return false; + return ( + this.int32ArrayTemps.has(arr.localId) || + this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`) + ); + } + + /** The access an element operation on `arr` (of element type `elem`) + * takes: int32 arrays read and write their 4-byte slots. */ + arrayAccess(arr: IrExpr, elem: IrType): ArrayAccess { + return this.int32Array(arr) ? "i32" : elemAccess(elem); + } + /** Whether an element access at `index` of `arr` lies in the hole-free * prefix: the counter of a dense array loop's fast copy indexing one of * the arrays its entry test covered. */ @@ -6518,7 +6581,7 @@ export class LlEmitter { return tagInSet(this, uName, unionId, tags); } - arrPush(arr: string, acc: "f64" | "bool" | "ref", value: string, element: IrType | null): string { + arrPush(arr: string, acc: ArrayAccess, value: string, element: IrType | null): string { return arrPush(this, arr, acc, value, element); } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 2f4cd98e9..5ec780277 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -60,7 +60,10 @@ import { emitDenseArrayNumber, emitDenseArrayPop, emitDenseArrayPush, + runtimeAccess, + type ArrayAccess, } from "./dense-array-access.js"; +import { INT32_ARRAY_METHODS } from "../../ir/int32-arrays.js"; import { emitUnionWiden } from "./expr-records.js"; import { registerReplay } from "./emit-journal.js"; @@ -115,7 +118,7 @@ export function tagInSet( export function arrPush( host: LlvmEmitterContext, arr: string, - acc: "f64" | "bool" | "ref", + acc: ArrayAccess, value: string, element: IrType | null, ): string { @@ -126,7 +129,7 @@ export function arrPush( export function emitArrayValues( host: LlvmEmitterContext, arr: string, - acc: "f64" | "bool" | "ref", + acc: ArrayAccess, element: IrType, values: LlValue[], prepend = false, @@ -142,12 +145,15 @@ export function emitArrayValues( } if (values.length === 1) { if (!prepend) return arrPush(host, arr, acc, values[0]!.name, element); - const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; + const flat = runtimeAccess(acc); + const ty = flat === "f64" ? "double" : flat === "bool" ? "i1" : "ptr"; host.declare( - `declare double @scr_arr_unshift_${acc}(ptr, ${ty}${acc === "bool" ? " zeroext" : ""})`, + `declare double @scr_arr_unshift_${flat}(ptr, ${ty}${flat === "bool" ? " zeroext" : ""})`, ); const result = B.tmp(); - B.line(`${result} = call double @scr_arr_unshift_${acc}(ptr ${arr}, ${ty} ${values[0]!.name})`); + B.line( + `${result} = call double @scr_arr_unshift_${flat}(ptr ${arr}, ${ty} ${values[0]!.name})`, + ); return result; } const capacity = Math.min(values.length, 64); @@ -165,8 +171,9 @@ export function emitArrayValues( const slot = B.tmp(), pointer = B.tmp(); const value = values[start + i]!.name; + // The runtime takes double bits for int32 arrays too, and converts. const pack = - acc === "f64" + acc === "f64" || acc === "i32" ? `bitcast double ${value} to i64` : acc === "bool" ? `zext i1 ${value} to i64` @@ -610,7 +617,12 @@ export function emitArrIntrinsic( if (e.receiver.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arrIntrinsic on non-array"); const elem = e.receiver.type.elem; - const acc = elemAccess(elem); + // Int32 arrays (ir/int32-arrays.ts) reach only these methods; the others + // take the runtime family's flat access. + const access = host.arrayAccess(e.receiver, elem); + if (access === "i32" && !INT32_ARRAY_METHODS.has(e.method)) + throw new InternalCompilerError(`llvm emitter bug: ${e.method} on an int32 array`); + const acc = runtimeAccess(access); const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; const accArg = acc === "bool" ? "i1 zeroext" : accTy; const method = e.method; @@ -639,6 +651,7 @@ export function emitArrIntrinsic( index, e.args[0], host.provenDenseIndex(e.receiver, e.args[0]!), + access === "i32" ? "i32" : "f64", ), type: e.type, }; @@ -675,7 +688,7 @@ export function emitArrIntrinsic( // no-op zero-argument call. const vs = e.args.map((a) => host.emitExpr(a)); host.emitPublishedArrayGuard(r.name, e.receiver.type, 1, f64Lit(0), f64Lit(vs.length)); - return { name: emitArrayValues(host, r.name, acc, elem, vs), type: e.type }; + return { name: emitArrayValues(host, r.name, access, elem, vs), type: e.type }; } case "pushSpread": { // `a.push(...src)`: append src's elements in order (borrowed src, @@ -749,7 +762,7 @@ export function emitArrIntrinsic( let slowLabel = ""; if (method === "pop") { slowLabel = B.newLabel("remove.slow"); - denseRaw = emitDenseArrayPop(host, r.name, acc, elem, la, slowLabel); + denseRaw = emitDenseArrayPop(host, r.name, access, elem, la, slowLabel); denseLabel = B.currentLabel(); B.br(lp); B.startBlock(slowLabel); diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 72392956a..7d259f412 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -20,6 +20,7 @@ import { } from "./integer-values.js"; import { bitwiseIdentityOperand, + INT32_RANGE, integerArithmeticRange, isBitwiseOp, withinInt32, @@ -32,7 +33,15 @@ import { emitPowerOfTwoRemainder, emitSignedIntegerRemainder, } from "./integer-remainder.js"; -import { denseSlotWidth, emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js"; +import { + denseSlotWidth, + emitDenseArrayRead, + emitDenseArrayState, + runtimeAccess, + SCR_ELEM_I32, + slotWidth, + type ArrayAccess, +} from "./dense-array-access.js"; import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js"; export function emitLiteralExpr( @@ -886,9 +895,23 @@ export function emitContainerExpr( // the plain elements up to the next one (scr_arr_reserve_spread). const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i)); const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread)); - B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); + // An int32 array's allocation (ir/int32-arrays.ts) constructs + // SCR_ELEM_I32 storage; its elements are proven int32s. + const int32 = host.int32Allocations.has(e); + if (int32) { + host.declare( + small + ? `declare ptr @scr_arr_new_small(i32)` + : `declare ptr @scr_arr_new(i32, ${host.sizeType})`, + ); + B.line( + small + ? `${arr} = call ptr @scr_arr_new_small(i32 ${SCR_ELEM_I32})` + : `${arr} = call ptr @scr_arr_new(i32 ${SCR_ELEM_I32}, ${host.sizeType} ${capacity})`, + ); + } else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); const out = host.own({ name: arr, type: e.type }); - const acc = elemAccess(elem); + const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem); let pending: LlValue[] = []; let buffer: string | undefined; const flush = (): void => { @@ -909,7 +932,8 @@ export function emitContainerExpr( B.line( `call void @scr_arr_reserve_spread(ptr ${arr}, ptr ${v.name}, ${host.sizeType} ${plain})`, ); - host.emitArrayCopyLoop(arr, v.name, acc); + // A literal with spreads is never an int32 allocation. + host.emitArrayCopyLoop(arr, v.name, runtimeAccess(acc)); return; } pending.push(host.emitExpr(el)); @@ -954,19 +978,27 @@ export function emitContainerExpr( // Ref-element reads return +1 (the inline dense path retains like // the runtime getter); own registers the owned temp in the frame. const elem = e.arr.type.elem; - const acc = elemAccess(elem); - const t = emitDenseArrayGet( + const acc = host.arrayAccess(e.arr, elem); + const read = emitDenseArrayRead( host, arr.name, idx, e.index, acc, elem, - `scr_arr_get_${acc}`, + `scr_arr_get_${runtimeAccess(acc)}`, true, host.provenDenseIndex(e.arr, e.index), ); - return host.own({ name: t, type: e.type }); + if (read.int32 !== null) + // An int32 array's element: its i32 is exact for index consumers. + return { + name: read.value, + type: e.type, + uint32: read.int32, + integer: { name: read.int32, type: "i32", signed: true, range: INT32_RANGE }, + }; + return host.own({ name: read.value, type: e.type }); } case "arrayHas": { const arr = host.emitStableReceiver(e.arr, [e.index]); @@ -979,7 +1011,7 @@ export function emitContainerExpr( idx, e.index, "has", - denseSlotWidth(host, e.arr.type.elem), + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), ); return { name: t, type: e.type }; } @@ -994,7 +1026,7 @@ export function emitContainerExpr( idx, e.index, "state", - denseSlotWidth(host, e.arr.type.elem), + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), ); return { name: t, type: e.type }; } diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 8df187736..759d20927 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -476,7 +476,8 @@ function emitDenseReferenceArrayRead( no: string, slow: string, join: string, - loadType: "ptr" | "cref" | "i64" | "i8" = "ptr", + /** `i32`: an int32 array's slot, presented as the int32. */ + loadType: "ptr" | "cref" | "i64" | "i8" | "i32" = "ptr", ): void { const B = host.B; const header = (field: number): string => { @@ -567,7 +568,7 @@ function emitDenseReferenceArrayRead( statePtr, data, cap, - loadType === "i8" ? 1 : loadType === "cref" ? 4 : 8, + loadType === "i8" ? 1 : loadType === "cref" || loadType === "i32" ? 4 : 8, offset, ); host.markMemoryPointer(statePtr, "array:present"); @@ -578,9 +579,10 @@ function emitDenseReferenceArrayRead( B.startBlock(valueLabel); const valuePtr = B.tmp(), raw = B.tmp(); - // Boolean arrays store one byte per element, compact references four, - // every other kind an 8-byte slot. - const slotType = loadType === "i8" ? "i8" : loadType === "cref" ? "i32" : "i64"; + // Boolean arrays store one byte per element, compact references and int32 + // arrays four, every other kind an 8-byte slot. + const slotType = + loadType === "i8" ? "i8" : loadType === "cref" || loadType === "i32" ? "i32" : "i64"; B.line( `${valuePtr} = getelementptr inbounds ${slotType}, ptr ${data}, ${host.sizeType} ${offset}`, ); @@ -743,8 +745,15 @@ export function emitLocalArrayRead( const no = B.newLabel("local.array.missing"), join = B.newLabel("local.array.join"); const scalar = isScalarElement(read.element); + // An int32 array's arms join on the int32 element, converted to the + // payload's double once at the join, so a consumer converting the payload + // back to an integer folds to the element. + const int32 = scalar && read.element.kind === "f64" && host.int32Array(read.array); + const element = int32 ? B.slot() : null; + if (element) B.entryAllocas.push(`${element} = alloca i32`); const storeValue = (value: string): void => { - if (scalar) { + if (element) B.line(`store i32 ${value}, ptr ${element}`); + else if (scalar) { // Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool. let bits = value; if (read.element.kind === "bool") { @@ -772,7 +781,13 @@ export function emitLocalArrayRead( no, slow, join, - !scalar ? referenceLoad(host, read.array.type) : read.element.kind === "bool" ? "i8" : "i64", + !scalar + ? referenceLoad(host, read.array.type) + : read.element.kind === "bool" + ? "i8" + : int32 + ? "i32" + : "i64", ); else B.br(slow); B.startBlock(slow); @@ -799,7 +814,8 @@ export function emitLocalArrayRead( B.line( `${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`, ); - B.line(`${bits} = bitcast double ${value} to i64`); + if (element) B.line(`${bits} = fptosi double ${value} to i32`); + else B.line(`${bits} = bitcast double ${value} to i64`); } else { host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`); B.line( @@ -807,7 +823,8 @@ export function emitLocalArrayRead( ); B.line(`${bits} = zext i1 ${value} to i64`); } - B.line(`store i64 ${bits}, ptr ${payload}`); + if (element) B.line(`store i32 ${bits}, ptr ${element}`); + else B.line(`store i64 ${bits}, ptr ${payload}`); B.line(`store i32 ${read.presentTag}, ptr ${tag}`); } else { const value = B.tmp(), @@ -821,10 +838,20 @@ export function emitLocalArrayRead( } B.br(join); B.startBlock(no); - B.line(`store i64 0, ptr ${payload}`); + if (element) B.line(`store i32 0, ptr ${element}`); + else B.line(`store i64 0, ptr ${payload}`); B.line(`store i32 ${read.missingTag}, ptr ${tag}`); B.br(join); B.startBlock(join); + if (element) { + const value = B.tmp(), + number = B.tmp(), + bits = B.tmp(); + B.line(`${value} = load i32, ptr ${element}`); + B.line(`${number} = sitofp i32 ${value} to double`); + B.line(`${bits} = bitcast double ${number} to i64`); + B.line(`store i64 ${bits}, ptr ${payload}`); + } B.line(`store ptr ${box}, ptr ${localSlot}`); return read.borrow || scalar ? null : { slot: payload, type: read.element }; } diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index 7bef40aa6..45d046db9 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -208,6 +208,7 @@ const MODULE_FIELDS: Record>, Role> = { "contracts.int32Returns[]": "fact", "contracts.int32Locals[]": "other", "contracts.int32Elements[]": "other", + "int32ArrayCandidates[]": "other", "contracts.noThrow[]": "fact", "contracts.acyclic[]": "other", "contracts.immortalGlobals[]": "other", diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts new file mode 100644 index 000000000..82dcf4470 --- /dev/null +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -0,0 +1,442 @@ +import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; +import { reparseExpr } from "./serialize.js"; +import { + everyExprChild, + everyStmtChild, + transformExpr, + transformStmt, + transformStmtList, + type IrTransform, +} from "./traverse.js"; + +/** Local number arrays the backend may store as int32 slots. + * + * A candidate is a local `number[]` of a synchronous function that no + * other binding can hold and only compiled element operations reach: + * every write of the local stores a fresh array (an array literal, or + * `new Array(n).fill(v)`), and every read of it is the receiver of an + * element read, write, state test, `length`, `push` or `pop` (a discarded + * `pop` included), or the array of an element-reader helper call. The + * array never reaches a runtime function that reads its slots, a library + * call, a closure, a return or untyped code. + * + * Candidates are only proposed here. The int32 slot analysis + * (int32-slots.ts) keeps the ones whose every stored element is a proven + * int32 (never -0), assuming the candidates' own elements are int32s, at + * a fixpoint, and the LLVM backend stores those as SCR_ELEM_I32. Their + * element-reader helper calls are inlined first (rewriteCandidates), so + * no shared helper ever receives an int32 array. */ + +/** Array methods a candidate may be the receiver of: the runtime paths + * behind them read and write int32 slots through the double-valued slot + * accessors. */ +export const INT32_ARRAY_METHODS: ReadonlySet = new Set([ + "length", + "push", + "pop", + "getNumber", +]); + +/** An element-reader helper (`%arr.idxOr.*`): `(a, i) => a's state at i is + * a value ? the element : undefined`, as one returned ternary. */ +export function isElementReader(fn: IrFunction): boolean { + if (fn.params.length !== 2 || fn.body.length !== 1) return false; + const [array, index] = fn.params; + const ret = fn.body[0]!; + if (array!.type.kind !== "array" || array!.type.elem.kind !== "f64") return false; + if (index!.type.kind !== "f64" || ret.kind !== "return" || ret.value?.kind !== "ternary") + return false; + const isParam = (e: IrExpr, id: string): boolean => e.kind === "varRef" && e.localId === id; + const { cond, then, else_ } = ret.value; + return ( + cond.kind === "bin" && + cond.op === "===" && + cond.left.kind === "arrayState" && + isParam(cond.left.arr, array!.localId) && + isParam(cond.left.index, index!.localId) && + cond.right.kind === "numLit" && + cond.right.value === 1 && + then.kind === "unionWrap" && + then.value.kind === "arrayGet" && + isParam(then.value.arr, array!.localId) && + isParam(then.value.index, index!.localId) && + else_.kind === "unionWrap" && + else_.value.kind === "unitLit" + ); +} + +/** `new Array(n).fill(v)` as lowering spells it: `fill` over a sequence + * that declares a temporary empty array, sets its length and yields it. */ +function fillAllocation(e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null { + if (e.kind !== "arrIntrinsic" || e.method !== "fill" || e.receiver.kind !== "seqExpr") + return null; + const seq = e.receiver; + const [decl, length] = seq.stmts; + if ( + seq.stmts.length !== 2 || + decl?.kind !== "varDecl" || + decl.init?.kind !== "arrayLit" || + decl.init.elems.length !== 0 || + length?.kind !== "arraySetLength" || + length.arr.kind !== "varRef" || + length.arr.localId !== decl.localId || + seq.result.kind !== "varRef" || + seq.result.localId !== decl.localId + ) + return null; + return e; +} + +/** A fresh number array: a literal without spreads, or the fill form. */ +export function int32ArrayAllocation(e: IrExpr): boolean { + return (e.kind === "arrayLit" && (e.spreads?.length ?? 0) === 0) || fillAllocation(e) !== null; +} + +/** The array literal a candidate's allocation builds (the literal itself, + * or the fill form's temporary's), and the fill form's temporary. */ +export function allocatedLiteral( + e: IrExpr, +): { literal: IrExpr & { kind: "arrayLit" }; temporary: string | null } | null { + if (e.kind === "arrayLit") return { literal: e, temporary: null }; + const fill = fillAllocation(e); + if (!fill || fill.receiver.kind !== "seqExpr") return null; + const decl = fill.receiver.stmts[0]!; + return decl.kind === "varDecl" && decl.init?.kind === "arrayLit" + ? { literal: decl.init, temporary: decl.localId } + : null; +} + +/** `xs.pop()` whose value is unused, as lowering spells it: a sequence + * that binds the array to a hidden local, then only reads that local's + * length and truncates it with pop stores. Returns the hidden local and the + * array read it binds. */ +function discardedPop(e: IrExpr): { local: string; array: IrExpr & { kind: "varRef" } } | null { + if (e.kind !== "seqExpr") return null; + const [bind, ...rest] = e.stmts; + if (bind?.kind !== "varDecl" || bind.init?.kind !== "varRef") return null; + const local = bind.localId; + const isLocal = (x: IrExpr): boolean => x.kind === "varRef" && x.localId === local; + const expr = (x: IrExpr): boolean => { + if (x.kind === "arrIntrinsic" && x.method === "length" && isLocal(x.receiver)) return true; + return !isLocal(x) && everyExprChild(x, expr, stmt); + }; + const stmt = (x: IrStmt): boolean => { + if (x.kind === "arraySetLength" && x.pop === true && isLocal(x.arr)) return expr(x.length); + return ( + !((x.kind === "varDecl" || x.kind === "assign") && x.localId === local) && + everyStmtChild(x, expr, stmt) + ); + }; + return rest.every(stmt) && expr(e.result) ? { local, array: bind.init } : null; +} + +/** Whether evaluating `e` may assign the local `id`. */ +function writesLocal(e: IrExpr, id: string): boolean { + const expr = (x: IrExpr): boolean => + !(x.kind === "assignExpr" && x.localId === id) && everyExprChild(x, expr, stmt); + const stmt = (x: IrStmt): boolean => + !((x.kind === "varDecl" || x.kind === "assign") && x.localId === id) && + everyStmtChild(x, expr, stmt); + return !expr(e); +} + +/** The candidate locals of `fn` (see the module comment). */ +export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet): Set { + const result = new Set(); + if (fn.async || fn.generator) return result; + const captured = new Set( + [...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId), + ); + const params = new Set(fn.params.map((p) => p.localId)); + for (const local of fn.locals) + if ( + local.type.kind === "array" && + local.type.elem.kind === "f64" && + !local.boxed && + !local.tdz && + !captured.has(local.id) && + !params.has(local.id) + ) + result.add(local.id); + if (result.size === 0) return result; + const written = new Set(); + // A receiver position: the candidate is read there without escaping. + const receiver = (e: IrExpr): void => { + if (e.kind !== "varRef") expr(e); + }; + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": + result.delete(e.localId); + return true; + case "assignExpr": + result.delete(e.localId); + break; + case "arrayGet": + case "arrayState": + case "arrayHas": + receiver(e.arr); + expr(e.index); + return true; + case "arrIntrinsic": + if (!INT32_ARRAY_METHODS.has(e.method)) break; + receiver(e.receiver); + e.args.forEach(expr); + return true; + case "seqExpr": { + // The pop's hidden local is the array itself; every use of it is a + // length read or a pop store. + const pop = discardedPop(e); + if (!pop) break; + e.stmts.slice(1).every(stmt); + expr(e.result); + return true; + } + case "call": { + if (!readers.has(e.callee) || e.args.length !== 2) break; + const [array, index] = e.args; + // Inlining evaluates the index before the array read. + if (array!.kind === "varRef" && writesLocal(index!, array!.localId)) + result.delete(array!.localId); + receiver(array!); + expr(index!); + return true; + } + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "varDecl": + case "assign": { + const value = s.kind === "varDecl" ? s.init : s.value; + if (value && int32ArrayAllocation(value)) written.add(s.localId); + else if (value) result.delete(s.localId); + if (value) expr(value); + return true; + } + case "arraySet": + receiver(s.arr); + expr(s.index); + expr(s.value); + return true; + case "arraySetUndefined": + // Stores a present undefined: a state, and a cleared (+0) slot. + receiver(s.arr); + expr(s.index); + return true; + case "forOf": + result.delete(s.localId); + break; + case "tryCatch": + if (s.catchLocalId) result.delete(s.catchLocalId); + break; + } + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + for (const id of result) if (!written.has(id)) result.delete(id); + return result; +} + +/** The element values stored into each candidate: element writes, pushed + * values, literal elements and fill values. */ +export function int32ArrayStores( + fn: IrFunction, + candidates: ReadonlySet, +): Map { + const stores = new Map(); + const add = (id: string, value: IrExpr): void => { + const list = stores.get(id); + if (list) list.push(value); + else stores.set(id, [value]); + }; + const target = (e: IrExpr): string | null => + e.kind === "varRef" && candidates.has(e.localId) ? e.localId : null; + const expr = (e: IrExpr): boolean => { + if (e.kind === "arrIntrinsic" && e.method === "push") { + const id = target(e.receiver); + if (id !== null) for (const arg of e.args) add(id, arg); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if ((s.kind === "varDecl" || s.kind === "assign") && candidates.has(s.localId)) { + const value = s.kind === "varDecl" ? s.init : s.value; + if (value?.kind === "arrayLit") for (const el of value.elems) add(s.localId, el); + else if (value) { + const fill = fillAllocation(value); + if (fill) add(s.localId, fill.args[0]!); + } + } + if (s.kind === "arraySet") { + const id = target(s.arr); + if (id !== null) add(id, s.value); + } + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + return stores; +} + +/** Rewrite a function's candidates for the backend. A discarded pop uses + * the candidate in place of its hidden local. An element-reader helper call + * whose array is a candidate is inlined: the helper's returned ternary, with + * the candidate for its array and the index for its index, so the read means + * the same. An index that is not a plain read or literal is bound to a fresh + * local first: in its own declaration just before the statement when the + * call is the whole value of a declaration or assignment (the form lowering + * gives `a[i]!`, and the one the backend reads without a box), otherwise in a + * sequence around the ternary. Selection keeps out indexes that write the + * array, so evaluating the index before the array read is unobservable. */ +function rewriteCandidates( + fn: IrFunction, + candidates: ReadonlySet, + readers: ReadonlyMap, +): IrFunction { + const locals = [...fn.locals]; + let fresh = 0; + const bind = (index: IrExpr): { decl: IrStmt; ref: IrExpr } => { + const id = `%int32.read.${fresh++}`; + locals.push({ id, name: id, type: F64, mutable: false }); + return { + decl: { kind: "varDecl", localId: id, init: index, loc: index.loc }, + ref: { kind: "varRef", localId: id, type: F64, loc: index.loc }, + }; + }; + const readerCall = (e: IrExpr | null): (IrExpr & { kind: "call" }) | null => + e?.kind === "call" && + e.args.length === 2 && + readers.has(e.callee) && + e.args[0]!.kind === "varRef" && + candidates.has(e.args[0]!.localId) + ? e + : null; + const simple = (e: IrExpr): boolean => e.kind === "varRef" || e.kind === "numLit"; + // The statements of a list, with complex read indexes declared first. + const hoist = (list: IrStmt[]): IrStmt[] => + list.flatMap((s): IrStmt[] => { + if (s.kind !== "varDecl" && s.kind !== "assign") return [s]; + const value = s.kind === "varDecl" ? s.init : s.value; + const call = readerCall(value); + if (!call || simple(call.args[1]!)) return [s]; + const { decl, ref } = bind(call.args[1]!); + const read: IrExpr = { ...call, args: [call.args[0]!, ref] }; + return [decl, s.kind === "varDecl" ? { ...s, init: read } : { ...s, value: read }]; + }); + const lists = (s: IrStmt): IrStmt => { + switch (s.kind) { + case "if": + return { ...s, then: hoist(s.then), else_: s.else_ === null ? null : hoist(s.else_) }; + case "block": + case "while": + case "doWhile": + case "for": + case "forOf": + return { ...s, body: hoist(s.body) }; + case "switch": + return { ...s, cases: s.cases.map((c) => ({ ...c, body: hoist(c.body) })) }; + case "tryCatch": + return { + ...s, + tryBody: hoist(s.tryBody), + catchBody: s.catchBody === null ? null : hoist(s.catchBody), + finallyBody: s.finallyBody === null ? null : hoist(s.finallyBody), + }; + } + return s; + }; + // Hidden pop locals replaced by their candidate array. + const popLocals = new Set(); + const rewrite = (e: IrExpr): IrExpr => { + const pop = discardedPop(e); + if (e.kind === "seqExpr" && pop && candidates.has(pop.array.localId)) { + popLocals.add(pop.local); + const array = pop.array; + const substitute: IrTransform = { + expr: (x) => + x.kind === "varRef" && x.localId === pop.local ? { ...array, loc: x.loc } : x, + stmt: (x) => x, + }; + return { + ...e, + stmts: e.stmts.slice(1).map((x) => transformStmt(x, substitute)), + result: transformExpr(e.result, substitute), + }; + } + const call = readerCall(e); + if (!call) return e.kind === "seqExpr" ? { ...e, stmts: hoist(e.stmts) } : e; + const helper = readers.get(call.callee)!; + const array = call.args[0]!; + const [arrayParam, indexParam] = helper.params; + const ret = helper.body[0]!; + if (ret.kind !== "return" || !ret.value) return e; + let index = call.args[1]!; + const stmts: IrStmt[] = []; + if (!simple(index)) { + const { decl, ref } = bind(index); + stmts.push(decl); + index = ref; + } + const operand = index; + const body = transformExpr(reparseExpr(ret.value), { + expr: (x) => { + if (x.kind !== "varRef") return x; + if (x.localId === arrayParam!.localId) return reparseExpr(array); + if (x.localId === indexParam!.localId) return reparseExpr(operand); + return x; + }, + stmt: (x) => x, + }); + const typed: IrExpr = { ...body, loc: e.loc }; + return stmts.length === 0 + ? typed + : { kind: "seqExpr", stmts, result: typed, type: typed.type, loc: e.loc }; + }; + const body = transformStmtList(hoist(fn.body), { expr: rewrite, stmt: lists }); + if (fresh === 0 && popLocals.size === 0) return { ...fn, body }; + return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) }; +} + +/** Propose the module's int32 array candidates (`function\0local` keys in + * `int32ArrayCandidates`), only those in `keep` when given, and rewrite + * their functions. */ +export function markInt32ArrayCandidates( + mod: IrModule, + keep: ReadonlySet | null = null, +): IrModule { + if (mod.lib !== undefined) return mod; + const readers = new Map(); + for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); + const names = new Set(readers.keys()); + const keys: string[] = []; + const functions = mod.functions.map((fn) => { + const candidates = int32ArrayCandidates(fn, names); + for (const id of candidates) + if (keep === null || keep.has(`${fn.name}\0${id}`)) keys.push(`${fn.name}\0${id}`); + else candidates.delete(id); + return candidates.size === 0 ? fn : rewriteCandidates(fn, candidates, readers); + }); + return keys.length === 0 ? mod : { ...mod, functions, int32ArrayCandidates: keys }; +} + +/** The module the backend emits: rewritten for exactly the candidates the + * int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a + * module without int32 arrays is `mod` itself. A dropped candidate's + * rewrite is undone by marking again without it, until every candidate + * marked is kept. */ +export function selectInt32Arrays( + mod: IrModule, + prove: (marked: IrModule) => ReadonlySet, +): IrModule { + let keep: ReadonlySet | null = null; + for (let round = 0; round < 4; round++) { + const marked = markInt32ArrayCandidates(mod, keep); + if (marked === mod) return mod; + const kept = prove(marked); + const proposed = marked.int32ArrayCandidates ?? []; + if (proposed.every((key) => kept.has(key))) return marked; + keep = new Set(proposed.filter((key) => kept.has(key))); + } + return mod; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ac0747276..bb6a06867 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -10,6 +10,7 @@ import { } from "./integer-ranges.js"; import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; +import { int32ArrayStores } from "./int32-arrays.js"; import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an @@ -169,7 +170,10 @@ export class Int32Slots { this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`) ? INT32_RANGE : null; - if (this.int32Elements.size > 0) result.elements = (arr) => this.hasInt32Elements(fnName, arr); + if (this.int32Elements.size > 0) { + result.elements = (arr) => this.hasInt32Elements(fnName, arr); + if (this.narrowedReads) result.narrowedReads = true; + } const params = this.paramFacts.get(fnName); if (params) result.params = @@ -234,6 +238,19 @@ export class Int32Slots { addInt32Elements(key: string): void { this.int32Elements.add(key); } + dropInt32Elements(key: string): boolean { + return this.int32Elements.delete(key); + } + hasInt32ElementSlot(key: string): boolean { + return this.int32Elements.has(key); + } + /** Whether the module has int32 array candidates (the narrowedReads + * range fact). */ + narrowedReads = false; + /** Local number arrays (`function\0local`) stored as int32 slots: the + * module's int32 array candidates whose every stored element the + * fixpoint proved an int32. */ + readonly int32Arrays = new Set(); /** Whether every present element of the number array `arr` evaluates to * (read inside `fnName`) is an int32. */ hasInt32Elements(fnName: string, arr: IrExpr): boolean { @@ -752,6 +769,18 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): } for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key); for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key); + // Int32 array candidates (ir/int32-arrays.ts): assumed to hold int32 + // elements until a store the fixpoint cannot prove drops one. + const arrayCandidates = new Map>(); + slots.narrowedReads = (mod.int32ArrayCandidates?.length ?? 0) > 0; + for (const key of mod.int32ArrayCandidates ?? []) { + const cut = key.indexOf("\0"); + const fnName = key.slice(0, cut); + const set = arrayCandidates.get(fnName) ?? new Set(); + set.add(key.slice(cut + 1)); + arrayCandidates.set(fnName, set); + slots.addInt32Elements(`l:${key}`); + } /* ── candidate fields ── */ const children = new Map(); @@ -1031,6 +1060,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): return false; }; const hasCandidateWrite = (fn: IrFunction): boolean => { + if (arrayCandidates.has(fn.name)) return true; const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId)); const returns = slots.hasReturn(fn.name); return !everyStmtList(fn.body, { @@ -1076,6 +1106,25 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): continue; } const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); + // Every element stored into an int32 array candidate must be an int32 + // under the facts that assume the candidates' own elements are; a + // failing store drops its array, and the function is analyzed again + // without it. + const arrays = arrayCandidates.get(fn.name); + if (arrays) { + let dropped = false; + for (const [id, values] of int32ArrayStores(fn, arrays)) { + const key = `l:${fn.name}\0${id}`; + if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue; + slots.dropInt32Elements(key); + reason(`array:${fn.name}\0${id}`, "an element store is not int32"); + dropped = true; + } + if (dropped && !queued.has(fn.name)) { + queued.add(fn.name); + queue.push(fn.name); + } + } // An int32 written into a slot still assumed non-negative: widen the // assumption when the value may be negative, and reanalyze its readers. const settle = (slot: string, e: IrExpr): void => { @@ -1144,6 +1193,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): } for (const proven of provenByFunction.values()) for (const value of proven) slots.provenWrites.add(value); + for (const [fnName, ids] of arrayCandidates) + for (const id of ids) + if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`)) + slots.int32Arrays.add(`${fnName}\0${id}`); const ranged = rangedParameters(slots, functions, retry, numeric, (callee) => readers.get(`ret:${callee}`), ); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 1d295ca24..0e5ca426d 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -220,6 +220,11 @@ export interface IntegerSlotFacts { /** Number arrays whose every present element is an int32 (Int32[] * contracts): an element read that found a value reads an int32. */ elements?: (arr: IrExpr) => boolean; + /** Element reads narrowed out of a union (`xs[i]!`, also when a union + * local holds the read) keep the element's range, and a ternary arm that + * always throws yields no value. Only modules with int32 array candidates + * (ir/int32-arrays.ts) set it, so no other program's ranges change. */ + narrowedReads?: boolean; } /** A local read, or a numeric local plus or minus an integer literal @@ -240,6 +245,50 @@ function offsetRef(e: IrExpr): { localId: string; offset: number } | null { return null; } +/** Library calls that always throw. */ +const THROWING_LIB = /^error\.\w*Throw$/; + +function throws(e: IrExpr): boolean { + return e.kind === "libCall" && THROWING_LIB.test(e.fn); +} + +/** The values written to each union-typed local of `fn` by declarations + * and assignments; null for a local that another write reaches or that a + * closure captures. */ +function unionLocalWrites( + fn: IrFunction, + captures: ReadonlySet, +): Map { + const unionLocals = new Set( + fn.locals + .filter((l) => l.type.kind === "union" && !l.boxed && !captures.has(l.id)) + .map((l) => l.id), + ); + const result = new Map(); + const add = (id: string, value: IrExpr | null): void => { + if (!unionLocals.has(id)) return; + const list = result.get(id); + if (list === null) return; + if (value === null) result.set(id, null); + else if (list) list.push(value); + else result.set(id, [value]); + }; + everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "varDecl" && s.init) add(s.localId, s.init); + else if (s.kind === "assign") add(s.localId, s.value); + else if (s.kind === "forOf") add(s.localId, null); + else if (s.kind === "tryCatch" && s.catchLocalId) add(s.catchLocalId, null); + return true; + }, + expr: (e) => { + if (e.kind === "assignExpr") add(e.localId, null); + return true; + }, + }); + return result; +} + /** Structured local dataflow. Calls cannot write uncaptured local slots, * but argument expressions can; unknown evaluation forms invalidate all * syntactically written slots before their children are inspected. Loops @@ -272,6 +321,10 @@ export function analyzeIntegerRangeFacts( .map((l) => l.id), ); const loopLocals = new Map(fn.locals.filter((l) => !captures.has(l.id)).map((l) => [l.id, l])); + // With narrowed reads, the values written to union locals (an element + // read held before it is narrowed), or null for a local some other write + // (assignExpr, for-of, catch, capture) reaches. + const unionWrites = slots.narrowedReads ? unionLocalWrites(fn, captures) : null; // A captured binding has no local dataflow: only a proof that covers // every write of the shared box anywhere applies to its reads. const boxedRange = (localId: string): IntegerRange | null => { @@ -490,6 +543,45 @@ export function analyzeIntegerRangeFacts( return range; } + /** narrowedRange through a union local: every value written to it. */ + function narrowedValue( + value: IrExpr, + unionId: string, + tag: number, + ): IntegerRange | null | "never" { + if (value.kind !== "varRef") return narrowedRange(value, unionId, tag); + const written = unionWrites?.get(value.localId); + if (!written || written.length === 0) return null; + let result: IntegerRange | null | "never" = "never"; + for (const w of written) { + const arm = narrowedRange(w, unionId, tag); + if (arm === "never") continue; + if (arm === null) return null; + result = result === "never" ? arm : union(result, arm); + } + return result; + } + /** The range of the values of union tag `tag` that `value` (already + * analyzed) can hold: "never" when no arm holds that tag. */ + function narrowedRange( + value: IrExpr, + unionId: string, + tag: number, + ): IntegerRange | null | "never" { + if (value.kind === "seqExpr") return narrowedRange(value.result, unionId, tag); + if (value.kind === "ternary") { + const yes = narrowedRange(value.then, unionId, tag), + no = narrowedRange(value.else_, unionId, tag); + if (yes === "never") return no; + if (no === "never") return yes; + return yes && no ? union(yes, no) : null; + } + if (value.kind === "unionWrap" && value.unionId === unionId) { + if (value.tag !== tag) return "never"; + return value.value.kind === "arrayGet" ? (ranges.get(value.value) ?? null) : null; + } + return null; + } function remember(e: IrExpr, range: IntegerRange | null): IntegerRange | null { // Shared IR objects have to satisfy the proof at every occurrence. const previous = ranges.get(e); @@ -683,6 +775,10 @@ export function analyzeIntegerRangeFacts( right = expr(e.else_, no); merge(facts, yes, no); if (left && right) range = union(left, right); + // With narrowed reads: an arm that always throws yields no value, + // so the other arm's range is the result's. + else if (slots.narrowedReads && throws(e.then)) range = right; + else if (slots.narrowedReads && throws(e.else_)) range = left; break; } case "logical": { @@ -777,27 +873,45 @@ export function analyzeIntegerRangeFacts( expr(e.obj, facts); range = slotRange(e); break; + case "unionNarrow": + // Only modules with narrowed reads take this rule: the narrow + // evaluates its operand first, and throws for every arm of another + // tag, so an element read `xs[i]!` (a ternary of the wrapped + // element and a wrapped undefined) has the element's range. + if (!slots.narrowedReads || e.type.kind !== "f64") { + range = conservative(e, facts); + break; + } + expr(e.value, facts); + { + const arm = narrowedValue(e.value, e.unionId, e.tag); + range = arm === "never" ? null : arm; + } + break; default: - // Do not assume the traversal order is an evaluation order for an - // unknown node. Every child gets the same conservative entry state. - havoc(facts, [e], facts); - everyExprChild( - e, - (child) => { - expr(child, new Map(facts)); - return true; - }, - (child) => { - body([child], new Map(facts)); - return true; - }, - ); - // A direct call's result carries its whole-program return proof. - range = slotRange(e); + range = conservative(e, facts); break; } return remember(e, range); } + /** An unknown node: do not assume the traversal order is an evaluation + * order. Every child gets the same conservative entry state. */ + function conservative(e: IrExpr, facts: Facts): IntegerRange | null { + havoc(facts, [e], facts); + everyExprChild( + e, + (child) => { + expr(child, new Map(facts)); + return true; + }, + (child) => { + body([child], new Map(facts)); + return true; + }, + ); + // A direct call's result carries its whole-program return proof. + return slotRange(e); + } function induction( s: IrStmt & { kind: "for" }, facts: Facts, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 12400fee9..0fa873a16 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1073,6 +1073,11 @@ export interface IrModule { * write in the program (ir/contract-check.ts). Backends may rely on them * unconditionally. Absent when the program writes no contract. */ contracts?: IrContracts; + /** Local number arrays (`function\0local`) the backend may store as + * int32 slots, proposed by ir/int32-arrays.ts after their element-reader + * calls were inlined; the int32 slot analysis keeps the ones whose every + * stored element it proves an int32. Absent when there is none. */ + int32ArrayCandidates?: string[]; /** The WebAssembly modules translated into this program (wasm/ * translate.ts), present exactly when the program uses the WebAssembly * API (possibly empty). The backend appends each module's translation diff --git a/packages/compiler/test/int32-element-array-emission.test.ts b/packages/compiler/test/int32-element-array-emission.test.ts new file mode 100644 index 000000000..8e89c8562 --- /dev/null +++ b/packages/compiler/test/int32-element-array-emission.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; +import { markInt32ArrayCandidates, selectInt32Arrays } from "../src/ir/int32-arrays.js"; + +async function emitLlvm(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return await readFile(outPath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function lowerIr(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const source = ` +function compose(n: number, rounds: number): number { + const p: number[] = []; + const q: number[] = []; + for (let i = 0; i < n; i++) p.push((i * 7919 + 3) & (n - 1)); + for (let i = 0; i < n; i++) q.push(i); + let check = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i < n; i++) q[i] = p[q[i]!]!; + check = (check + q[r % n]!) | 0; + } + return check; +} +function counts(n: number): number { + const c = new Array(n).fill(0) as number[]; + for (let i = 0; i < n; i++) c[(i * 5) % n] = i; + while (c.length > 1) c.pop(); + return c[0]!; +} +function fraction(n: number): number { + const a = [1, 2, 3]; + a[1] = n / 3; + return a[1]!; +} +function negativeZero(): number { + const a = [1, -0]; + return 1 / a[1]!; +} +function counter(n: number): number { + const a = [0]; + for (let i = 0; i < n; i++) a[0] = a[0]! + 1; + return a[0]!; +} +function joined(n: number): string { + const a = [n | 0, 2]; + return a.join(","); +} +const { n } = JSON.parse('{"n": 8}') as { n: number }; +console.log(compose(n | 0, 3), counts(n | 0), fraction(n), negativeZero(), counter(n), joined(n)); +`; + +/** Every function emitted for the source function \`name\` (its int32 ABI + * body and its wrapper included). */ +function functions(llvm: string, name: string): string { + const pattern = new RegExp(`^define [^@]*@sc_b?f_${name}[.\\w]*\\([^]*?^}`, "gm"); + return [...llvm.matchAll(pattern)].map((m) => m[0]).join("\n"); +} + +test("arrays storing only int32 elements get 4-byte slots", async () => { + const llvm = await emitLlvm(source); + const compose = functions(llvm, "compose"); + expect(compose).not.toBe(""); + // Both arrays are allocated as SCR_ELEM_I32 (7). + expect(compose.match(/@scr_arr_new_small\(i32 7\)/g)?.length).toBe(2); + // Reads load an i32 slot; writes store one. + expect(compose).toMatch(/getelementptr inbounds i32, ptr %\w+, i64 %\w+\n\s+%\w+ = load i32/); + expect(compose).toMatch(/store i32 %\w+, ptr %\w+, !tbaa/); + // The element read through the helper's ternary stays in a stack box. + expect(compose).not.toContain("@scr_union_new_f64"); + expect(compose).not.toMatch(/call [^\n]*@sc_b?f__x25_arr_idxOr/); + // The fill form and a discarded pop. + const counts = functions(llvm, "counts"); + expect(counts).toMatch(/@scr_arr_new\(i32 7, i64 0\)/); + expect(counts).not.toContain("popArray"); +}); + +test("arrays with a store that may not be an int32, or that escape, keep doubles", async () => { + const llvm = await emitLlvm(source); + for (const name of ["fraction", "negativeZero", "counter", "joined"]) { + const fn = functions(llvm, name); + expect(fn, name).not.toBe(""); + expect(fn, name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/); + } +}); + +test("only the candidates the proof keeps are rewritten", async () => { + const mod = await lowerIr(source); + const proposed = markInt32ArrayCandidates(mod).int32ArrayCandidates ?? []; + expect(proposed).toEqual( + expect.arrayContaining(["compose\0p.0", "compose\0q.0", "counts\0c.0", "counter\0a.0"]), + ); + // An array that reaches `join` is never proposed. + expect(proposed.some((key) => key.startsWith("joined\0"))).toBe(false); + // Without a kept candidate, the module is the input itself. + expect(selectInt32Arrays(mod, () => new Set())).toBe(mod); + const kept = new Set(["compose\0p.0", "compose\0q.0"]); + const selected = selectInt32Arrays( + mod, + (marked) => new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), + ); + expect(selected.int32ArrayCandidates).toEqual([...kept]); + for (const fn of selected.functions) + if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name)); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json new file mode 100644 index 000000000..f00823a6e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7326-int32-element-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7326-int32-element-arrays.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index f3e216a36..bbe66e26e 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -277,6 +277,7 @@ typedef struct { static uint64_t scr_arr_storage_get(const ScrArrStorage *s, size_t index) { if (s->elem == SCR_ELEM_BOOL) return ((const uint8_t *)s->data)[index]; if (s->elem == SCR_ELEM_CREF) return scr_cref_decode(((const uint32_t *)s->data)[index]); + if (s->elem == SCR_ELEM_I32) return scr_arr_f64_slot((double)((const int32_t *)s->data)[index]); return s->data[index]; } @@ -621,7 +622,7 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode); * paths pick the same capacity. */ static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode) { if (need <= a->cap) return; - if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF || + if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF || a->elem == SCR_ELEM_I32 || (a->spare & (SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM | SCR_ARR_HAS_EXT)) || need > SCR_ARR_DENSE_LIMIT) { scr_arr_grow_dense_slow(a, need, mode); return; @@ -1904,6 +1905,10 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state, if (state != SCR_ARR_VALUE) scr_arr_cut(a, from); if (a->elem == SCR_ELEM_BOOL) memset(scr_arr_dense_at(a, from), (int)slot, until - from); else if (a->elem == SCR_ELEM_CREF) for (size_t i = from; i < until; i++) scr_arr_dense_put(a, i, slot); + else if (a->elem == SCR_ELEM_I32) { + int32_t v = scr_arr_i32_of(slot); + for (size_t i = from; i < until; i++) ((int32_t *)a->data)[i] = v; + } else for (size_t i = from; i < until; i++) a->data[i] = slot; if (scr_arr_has_states(a)) memset(scr_arr_state_run(a, from, until - from), state, until - from); } else { diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index e13acb233..6034462e9 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -48,6 +48,12 @@ typedef enum { * address over 8, 0 for NULL. Compact instances are never counted or * traced, so these arrays retain, release and trace nothing. */ SCR_ELEM_CREF, + /* Numbers that the compiler proves are always int32 (never -0), in + * 4-byte slots: arrays that only compiled element accesses ever reach + * (backend/llvm int32 arrays). Every runtime slot is the value's double + * bits, as for SCR_ELEM_F64: scr_arr_dense_get and scr_arr_dense_put + * convert, so the paths compiled code calls need no case of their own. */ + SCR_ELEM_I32, } ScrElemKind; typedef enum { @@ -134,11 +140,12 @@ typedef struct ScrArr { * 0 is always valid. */ uint32_t filled; /* Dense values: `cap` 8-byte slots, `cap` bytes for SCR_ELEM_BOOL or - * `cap` 4-byte slots for SCR_ELEM_CREF (scr_arr_slot_width), followed in - * the same allocation, when the array has states (SCR_ARR_STATES), by one - * state byte per slot in reverse slot order (scr_arr_states_end). Sparse - * entries and properties keep 8-byte slots for every kind; a bool slot is - * 0 or 1 wherever it lives, and a CREF slot's 8-byte form is the pointer + * `cap` 4-byte slots for SCR_ELEM_CREF and SCR_ELEM_I32 + * (scr_arr_slot_width), followed in the same allocation, when the array + * has states (SCR_ARR_STATES), by one state byte per slot in reverse slot + * order (scr_arr_states_end). Sparse entries and properties keep 8-byte + * slots for every kind; a bool slot is 0 or 1 wherever it lives, a CREF + * slot's 8-byte form is the pointer and an I32 slot's the double's bits * (scr_arr_dense_get). Rarely * used storage (sparse indices, noncanonical numeric properties, * match-result metadata) lives out of line, and an array that has it @@ -260,7 +267,7 @@ static inline const ScrArrOps *scr_arr_ops(const ScrArr *a) { /* Bytes per dense value slot of element kind `elem`. */ static inline size_t scr_elem_slot_width(uint8_t elem) { if (elem == SCR_ELEM_BOOL) return 1; - if (__builtin_expect(elem == SCR_ELEM_CREF, 0)) return sizeof(uint32_t); + if (__builtin_expect(elem == SCR_ELEM_CREF || elem == SCR_ELEM_I32, 0)) return sizeof(uint32_t); return sizeof(uint64_t); } @@ -325,11 +332,38 @@ static inline void scr_arr_need_states(ScrArr *a) { static inline uint64_t scr_cref_decode(uint32_t ref) { return (uint64_t)ref << 3; } static inline uint32_t scr_cref_encode(uint64_t slot) { return (uint32_t)(slot >> 3); } +/* The bits of `d` as a slot, and back. */ +static inline uint64_t scr_arr_f64_slot(double d) { + uint64_t slot; + memcpy(&slot, &d, sizeof slot); + return slot; +} + +static inline double scr_arr_slot_f64(uint64_t slot) { + double d; + memcpy(&d, &slot, sizeof d); + return d; +} + +/* An int32 array's slot for a double: its stores are proven int32s that + * are not -0 (and a cleared slot is +0), so anything else is a compiler + * bug. */ +static inline int32_t scr_arr_i32_of(uint64_t slot) { + double d = scr_arr_slot_f64(slot); + if (__builtin_expect(!(d >= -2147483648.0 && d <= 2147483647.0 && d == (double)(int32_t)d) || + slot == UINT64_C(0x8000000000000000), + 0)) + __builtin_trap(); + return (int32_t)d; +} + /* The dense value at `index` (< cap) as an 8-byte slot, and its store. */ static inline uint64_t scr_arr_dense_get(const ScrArr *a, size_t index) { if (a->elem == SCR_ELEM_BOOL) return ((const uint8_t *)a->data)[index]; if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0)) return scr_cref_decode(((const uint32_t *)a->data)[index]); + if (__builtin_expect(a->elem == SCR_ELEM_I32, 0)) + return scr_arr_f64_slot((double)((const int32_t *)a->data)[index]); return a->data[index]; } @@ -337,6 +371,8 @@ static inline void scr_arr_dense_put(ScrArr *a, size_t index, uint64_t slot) { if (a->elem == SCR_ELEM_BOOL) ((uint8_t *)a->data)[index] = (uint8_t)slot; else if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0)) ((uint32_t *)a->data)[index] = scr_cref_encode(slot); + else if (__builtin_expect(a->elem == SCR_ELEM_I32, 0)) + ((int32_t *)a->data)[index] = scr_arr_i32_of(slot); else a->data[index] = slot; } diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 769b8ef3a..8cbac7f57 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1614,6 +1614,64 @@ static bool inline_slots(const ScrArr *a) { return a->data == (const uint64_t *)((const char *)a + ((sizeof(ScrArr) + 7) & ~(size_t)7)); } +/* Int32 slots: 4-byte values, then the state bytes when the array has them. */ +static bool i32_layout_ok(const ScrArr *a) { + return a->elem == SCR_ELEM_I32 && scr_arr_slot_width(a) == sizeof(int32_t); +} + +/* SCR_ELEM_I32: 4-byte int32 slots behind the double-valued entry points. */ +static void test_i32_slots(void) { + ScrArr *a = scr_arr_new_small(SCR_ELEM_I32); + for (int i = 0; i < 1000; i++) scr_arr_push_f64(a, (double)(i * 7 - 3000)); + check(i32_layout_ok(a) && !scr_arr_has_states(a) && a->filled == 1000, + "int32 pushes grow four-byte storage without states"); + bool values_ok = true; + for (int i = 0; i < 1000; i++) { + values_ok &= ((const int32_t *)a->data)[i] == i * 7 - 3000; + values_ok &= scr_arr_get_f64(a, i) == (double)(i * 7 - 3000); + values_ok &= scr_arr_get_number(a, i) == (double)(i * 7 - 3000); + } + check(values_ok, "int32 values are stored as 4-byte integers and read as doubles"); + scr_arr_set_f64(a, 10, 2147483647); + scr_arr_set_f64(a, 11, -2147483648.0); + check(scr_arr_get_f64(a, 10) == 2147483647 && scr_arr_get_f64(a, 11) == -2147483648.0, + "int32 extremes round-trip"); + check(scr_arr_pop_f64(a) == 999 * 7 - 3000 && scr_arr_len(a) == 999, "pop reads the last int32"); + scr_arr_set_len(a, 5); + scr_arr_set_len(a, 8); + check(i32_layout_ok(a) && scr_arr_has_states(a) && scr_arr_state(a, 6) == SCR_ARR_HOLE && + isnan(scr_arr_get_number(a, 6)) && scr_arr_get_f64(a, 4) == 4 * 7 - 3000, + "int32 truncation clears slots and extension adds holes"); + scr_arr_set_f64(a, 20, 5); + check(scr_arr_len(a) == 21 && scr_arr_state(a, 15) == SCR_ARR_HOLE && scr_arr_get_f64(a, 20) == 5, + "an int32 store past the length leaves holes"); + scr_arr_release(scr_arr_fill_f64(a, -9, 0, 21)); + bool filled_ok = a->filled == 21; + for (int i = 0; i < 21; i++) filled_ok &= scr_arr_get_f64(a, i) == -9; + check(filled_ok, "int32 fill writes every slot"); + scr_arr_release(a); + + ScrArr *s = scr_arr_new(SCR_ELEM_I32, 0); + scr_arr_set_f64(s, 0, 1); + scr_arr_set_f64(s, 3000000, 3); + scr_arr_set_f64(s, 2000000, -2); + check(arr_sparse_len(s) == 2 && scr_arr_get_f64(s, 2000000) == -2 && scr_arr_get_f64(s, 3000000) == 3, + "int32 sparse entries keep double slots"); + scr_arr_set_len(s, 2000001); + scr_arr_release(scr_arr_fill_f64(s, 4, 1, 2000000)); + check(arr_sparse_len(s) == 0 && i32_layout_ok(s) && scr_arr_get_f64(s, 2000000) == -2 && + scr_arr_get_f64(s, 1999999) == 4 && scr_arr_get_f64(s, 0) == 1, + "densifying absorbs sparse entries into int32 slots"); + scr_arr_release(s); + + ScrArr *k = scr_arr_new(SCR_ELEM_I32, 6); + for (int i = 0; i < 6; i++) scr_arr_push_f64(k, i); + scr_arr_push_f64(k, 6); + check(i32_layout_ok(k) && scr_arr_get_f64(k, 6) == 6 && scr_arr_get_f64(k, 0) == 0, + "a known-count int32 array grows out of its block"); + scr_arr_release(k); +} + static void test_inline_slots(void) { #ifdef SCR_RC_AUDIT long strings0 = scr_str_live_count(); @@ -2472,6 +2530,7 @@ int main(int argc, char **argv) { test_splice_drop(); test_filled_prefix(); test_bool_bytes(); + test_i32_slots(); test_inline_slots(); test_head_room(); test_states_on_demand(); diff --git a/tests/corpus/7326-int32-element-arrays.ts b/tests/corpus/7326-int32-element-arrays.ts new file mode 100644 index 000000000..f5dc0d787 --- /dev/null +++ b/tests/corpus/7326-int32-element-arrays.ts @@ -0,0 +1,165 @@ +// Local number arrays whose every stored element is an int32: reads, writes, +// holes, present undefined, out-of-range and noncanonical indexes, growth, +// pop, the fill form, reassignment, and arrays that do not qualify. + +function compose(n: number, rounds: number): number { + const p: number[] = []; + const q: number[] = []; + for (let i = 0; i < n; i++) p.push((i * 7919 + 3) & (n - 1)); + for (let i = 0; i < n; i++) q.push(i); + let check = 0; + for (let r = 0; r < rounds; r++) { + for (let i = 0; i < n; i++) q[i] = p[q[i]!]!; + check = (check + q[r % n]!) | 0; + } + return check; +} + +function insertionSort(n: number): string { + const a: number[] = []; + let seed = 12345; + for (let i = 0; i < n; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + a.push((seed % 2001) - 1000); + } + for (let i = 1; i < n; i++) { + for (let j = i; j > 0 && a[j - 1]! > a[j]!; j--) { + const t = a[j]!; + a[j] = a[j - 1]!; + a[j - 1] = t; + } + } + let sorted = true; + for (let i = 1; i < n; i++) if (a[i - 1]! > a[i]!) sorted = false; + return `${sorted} ${a[0]} ${a[n >> 1]} ${a[n - 1]} ${a.length}`; +} + +function reads(): string[] { + const a = [7, -3, 2147483647, -2147483648, 0]; + const out: string[] = []; + out.push(`${a[0]} ${a[1]} ${a[2]} ${a[3]} ${a[4]} ${a[5]}`); + out.push(`${a[2]! + 1} ${a[3]! - 1} ${a[1]! / 2} ${a[2]! * 2}`); + const idx = [0, 4, 5, -1, 1.5, NaN, -0, 2 ** 32, Infinity]; + for (const i of idx) out.push(`${i}: ${a[i]} ${a[i] === undefined} ${i in a}`); + out.push(`len ${a.length}`); + return out; +} + +function holes(): string[] { + const a = [1, 2, 3]; + const out: string[] = []; + a[6] = 9; + out.push(`len ${a.length} ${a[3]} ${a[6]} ${4 in a} ${6 in a}`); + a[4] = -5; + let sum = 0, + present = 0; + for (let i = 0; i < a.length; i++) { + if (a[i] !== undefined) { + sum += a[i]!; + present++; + } + } + out.push(`sum ${sum} present ${present}`); + // A hole read through a non-null assertion stores a present undefined. + const q = [10, 20, 30]; + q[1] = a[3]!; + out.push(`q ${q[0]} ${q[1]} ${q[2]} ${1 in q} ${q.length}`); + q[1] = 21; + out.push(`q ${q[1]} ${(q[0]! + q[1]!) | 0}`); + return out; +} + +function sparse(): string[] { + const a = [1, 2]; + const out: string[] = []; + a[100000] = 42; + a[3] = 4; + out.push(`len ${a.length} ${a[100000]} ${a[3]} ${a[2]} ${a[99999]}`); + a[100000] = -42; + out.push(`${a[100000]} ${a[100001]}`); + return out; +} + +function growth(n: number): string[] { + const a: number[] = []; + const out: string[] = []; + for (let i = 0; i < n; i++) a.push((i * i) | 0); + out.push(`len ${a.length} ${a[0]} ${a[n - 1]} ${a[n]}`); + let sum = 0; + while (a.length > n / 2) sum = (sum + a.pop()!) | 0; + out.push(`popped ${sum} len ${a.length} last ${a[a.length - 1]}`); + for (let i = 0; i < 3; i++) a.push(i - 3); + out.push(`len ${a.length} ${a[a.length - 3]} ${a[a.length - 1]}`); + while (a.length > 0) a.pop(); + out.push(`empty ${a.length} ${a.pop()} ${a[0]}`); + a.push(5); + out.push(`again ${a.length} ${a[0]}`); + return out; +} + +function filled(n: number): string[] { + const counts = new Array(n).fill(0) as number[]; + const marks: number[] = new Array(n).fill(-1); + for (let i = 0; i < 3 * n; i++) { + const k = ((i * 37) % n) | 0; + counts[k] = (i * 3 - 7) | 0; + marks[(k + 1) % n] = k; + } + let a = 0, + b = 0; + for (let i = 0; i < n; i++) { + a = (a + counts[i]!) | 0; + b = (b + marks[i]!) | 0; + } + return [`fill ${counts.length} ${a} ${b} ${counts[n]} ${marks[0]}`]; +} + +function reassigned(rounds: number): string { + let a = [1, 2, 3]; + let total = 0; + for (let r = 0; r < rounds; r++) { + if (r % 3 === 0) a = [r, r + 1]; + else a.push(r); + total = (total + a[a.length - 1]! + a.length) | 0; + } + return `reassigned ${total} ${a.length} ${a[0]}`; +} + +// These arrays do not qualify; they print the same values as before. +function disqualified(): string[] { + const out: string[] = []; + const negZero = [0, -0]; + out.push(`${Object.is(negZero[1], -0)} ${1 / negZero[1]!}`); + const fraction = [1, 2]; + fraction[1] = 2.5; + out.push(`${fraction[1]}`); + const big = [1]; + big[0] = big[0]! + 2147483647; + out.push(`${big[0]}`); + const joined = [3, 1, 2]; + joined.sort((x, y) => x - y); + out.push(joined.join(",")); + const escaped = [4, 5]; + const alias = escaped; + alias[0] = 6; + out.push(`${escaped[0]} ${alias[1]}`); + const counter = [0]; + for (let i = 0; i < 10; i++) counter[0] = counter[0]! + 1; + out.push(`${counter[0]}`); + return out; +} + +const config = JSON.parse('{"n": 256, "rounds": 50, "sort": 300}') as { + n: number; + rounds: number; + sort: number; +}; +console.log(compose(config.n | 0, config.rounds | 0)); +console.log(insertionSort(config.sort | 0)); +for (const line of reads()) console.log(line); +for (const line of holes()) console.log(line); +for (const line of sparse()) console.log(line); +for (const line of growth(config.n | 0)) console.log(line); +for (const line of filled(17)) console.log(line); +console.log(reassigned(config.rounds | 0)); +for (const line of disqualified()) console.log(line); diff --git a/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json b/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json new file mode 100644 index 000000000..168ae57fb --- /dev/null +++ b/tests/harness/corpus-costs/7326-int32-element-arrays.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 4100 } From b025254f7995563e58daaf89f19b334d84d0ebb7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:53:28 -0500 Subject: [PATCH 16/39] Keep the charAt unit reads' origins on the emitter The charCodeAt reads that stand for rewritten charAt reads (one-unit tests and switches) recorded the charAt each stands for in a module-level WeakMap. Compiler sources keep such caches on their owners: the map now belongs to the emitter (charAtUnitReads), and charAtUnitRead takes it. --- .../compiler/src/backend/llvm/emit-statements.ts | 9 ++++++--- packages/compiler/src/backend/llvm/emitter.ts | 4 ++++ .../compiler/src/backend/llvm/expr-containers.ts | 13 +++++-------- .../compiler/src/backend/llvm/expr-primitives.ts | 4 ++-- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index b530f794d..333715b97 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -1826,7 +1826,7 @@ export function emitTryCatch(emitter: LlEmitter, s: IrStmt & { kind: "tryCatch" * case, refcounted/boxed case-body locals are NULL-reset up front and * the scope-exit releases rely on NULL tolerance. */ export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): void { - const unitSwitch = charAtUnitSwitch(s); + const unitSwitch = charAtUnitSwitch(emitter, s); if (unitSwitch !== null) return emitSwitch(emitter, unitSwitch); const B = emitter.B; const discKind = s.disc.type.kind; @@ -2518,7 +2518,10 @@ function nullableStringSwitch( * one-unit string literals, as a switch on `s.charCodeAt(i)` with the units * as cases: no character string is built. An index out of range answers * NaN, which matches no case, as "" matches no one-unit literal. */ -function charAtUnitSwitch(s: IrStmt & { kind: "switch" }): (IrStmt & { kind: "switch" }) | null { +function charAtUnitSwitch( + emitter: LlEmitter, + s: IrStmt & { kind: "switch" }, +): (IrStmt & { kind: "switch" }) | null { const disc = s.disc; if (disc.kind !== "strIntrinsic" || disc.method !== "charAt" || disc.args.length !== 1) return null; @@ -2531,7 +2534,7 @@ function charAtUnitSwitch(s: IrStmt & { kind: "switch" }): (IrStmt & { kind: "sw return null; return { ...s, - disc: charAtUnitRead(disc), + disc: charAtUnitRead(emitter, disc), cases: s.cases.map((c) => c.test?.kind === "strLit" ? { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 9c42920b9..5eecda3ec 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1201,6 +1201,10 @@ export class LlEmitter { * (findIntegerOrNaNLocals in wrapping-sums.ts). */ integerOrNaNLocals: ReadonlyMap = new Map(); bytesBounds: ReadonlySet = new Set(); + /** The charCodeAt reads standing for charAt reads the emitter rewrote + * (one-unit tests and switches, charAtUnitRead), with the charAt each + * stands for: a loop version's proof for the charAt covers its read. */ + readonly charAtUnitReads = new Map(); /** Worker programs: typed-array accesses whose views an enclosing version * guard proved thread-owned. */ bytesOwned: ReadonlySet = new Set(); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 2f4cd98e9..b84e3fda5 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -286,14 +286,11 @@ export function emitArrayCopyLoop( host.B.line(`call double @scr_arr_push_spread(ptr ${dst}, ptr ${src})`); } -/** The `charCodeAt` reads that stand for `charAt` reads the emitter - * rewrote, such as one-unit tests and switches: a loop version's proof - * for the charAt covers its read. */ -const unitReadOrigins = new WeakMap(); - /** `s.charCodeAt(i)` in place of `s.charAt(i)`, the same index read as a - * code unit. */ + * code unit. The emitter records the charAt it stands for + * (charAtUnitReads), so a loop version's proof for the charAt covers it. */ export function charAtUnitRead( + host: LlvmEmitterContext, charAt: IrExpr & { kind: "strIntrinsic" }, ): IrExpr & { kind: "strIntrinsic" } { const read: IrExpr & { kind: "strIntrinsic" } = { @@ -304,7 +301,7 @@ export function charAtUnitRead( type: F64, loc: charAt.loc, }; - unitReadOrigins.set(read, charAt); + host.charAtUnitReads.set(read, charAt); return read; } @@ -312,7 +309,7 @@ export function charAtUnitRead( * below the length (bytes-loop-versions.ts). */ function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean { if (host.bytesBounds.has(e)) return true; - const origin = unitReadOrigins.get(e); + const origin = host.charAtUnitReads.get(e); return origin !== undefined && host.bytesBounds.has(origin); } diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 72392956a..0d57d3f69 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -648,7 +648,7 @@ export function emitStringExpr( // equals no unit, as "" equals no one-unit literal. const unit = charAtUnitTest(e); if (unit !== null) { - const code = host.emitExpr(charAtUnitRead(unit.charAt)); + const code = host.emitExpr(charAtUnitRead(host, unit.charAt)); const t = B.tmp(); B.line( `${t} = fcmp ${e.negated ? "une" : "oeq"} double ${code.name}, ${f64Lit(unit.code)}`, @@ -682,7 +682,7 @@ export function emitStringExpr( // the code unit, as the equality above does. const unit = charAtUnitTest(e); if (unit !== null) { - const code = host.emitExpr(charAtUnitRead(unit.charAt)); + const code = host.emitExpr(charAtUnitRead(host, unit.charAt)); const t = B.tmp(); B.line( `${t} = fcmp ${charAtUnitPredicate(e.op, unit.left)} double ${code.name}, ${f64Lit(unit.code)}`, From f3bb5d2cb18fcd9bae91ccdd626a98472e5c022d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:57:07 -0500 Subject: [PATCH 17/39] Borrow the array of an inline Atomics operation from its local emitInlineAtomicsOp read its typed array with emitExpr, so every Atomics call on a local array retained the array before the atomic instruction and released it after, two out-of-line calls around one instruction. The array now goes through emitBorrowedInput: an unchanged, unboxed local owns it across the later operands and the operation, and the instruction and scr_atomics_op only read it. Fields, globals, captures and bindings an operand can reassign keep their own reference. --- .../compiler/src/backend/llvm/expr-bytes.ts | 4 ++- tests/harness/atomics-emission.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index c16ae856a..41ddf2b9d 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -1118,7 +1118,9 @@ export function emitInlineAtomicsOp( if (op !== 0 && op !== 1 && op !== 3 && ATOMIC_RMW[op] === undefined) return null; const elem = array.type.elem; const B = host.B; - const receiver = host.emitExpr(array).name; + // The instruction and scr_atomics_op borrow the array: an unchanged local + // owns it across the later operands and the operation. + const receiver = emitBorrowedInput(host, array).name; const idxValue = host.emitExpr(index); const operand = host.emitExpr(value); const swap = host.emitExpr(replacement); diff --git a/tests/harness/atomics-emission.test.ts b/tests/harness/atomics-emission.test.ts index 9ae06d904..2d708e382 100644 --- a/tests/harness/atomics-emission.test.ts +++ b/tests/harness/atomics-emission.test.ts @@ -61,3 +61,32 @@ console.log(work(new Int32Array(buffer), new Uint8Array(buffer), new Int16Array( expect(calls).toHaveLength(9); for (const call of calls) expect(call).toMatch(/\) cold$/); }); + +test("an unchanged local array is borrowed across Atomics operations", async () => { + const llvm = await emittedLlvm( + "borrow", + `function count(buffer: SharedArrayBuffer, n: number): number { + const cells = new Int32Array(buffer); + for (let i = 0; i < n; i++) { + Atomics.add(cells, 0, 1); + Atomics.compareExchange(cells, 1, Atomics.load(cells, 1), i); + } + return Atomics.load(cells, 0); +} +function swap(buffer: SharedArrayBuffer, other: SharedArrayBuffer): number { + let cells = new Int32Array(buffer); + return Atomics.add(cells, ((cells = new Int32Array(other)), 0), 1); +} +const buffer = new SharedArrayBuffer(16); +console.log(count(buffer, 3), swap(buffer, new SharedArrayBuffer(16))); +`, + ); + // The local owns the array across each operation: no reference count + // operations around the atomic instructions. + const counted = body(llvm, "sc_bf_count"); + expect(counted).toMatch(/atomicrmw add ptr/); + expect(counted).not.toMatch(/call ptr @scr_bytes_retain_v/); + // An operand that reassigns the binding releases the old array, so the + // operation keeps its own reference. + expect(body(llvm, "sc_bf_swap")).toMatch(/call ptr @scr_bytes_retain_v/); +}); From 7f7e11a1a749beb0f14d340fdb193e99e013c18b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:21:31 -0700 Subject: [PATCH 18/39] Read and write Error.stackTraceLimit through the Error constructor value zod 4 saves and restores the limit through an alias, `const E = "captureStackTrace" in Error ? Error : null`. The interned Error constructor value now answers `stackTraceLimit` from the same limit the static member and stack capture use, and numeric writes set it, so `E.stackTraceLimit` reads 10 instead of undefined and `E.stackTraceLimit = 0` reaches `Error.stackTraceLimit`. Co-Authored-By: Claude Opus 5.5 --- .../7602-error-stack-limit-value.js.json | 6 ++++++ packages/runtime/src/scr_json.c | 13 +++++++++++++ tests/corpus/7602-error-stack-limit-value.js | 18 ++++++++++++++++++ .../7602-error-stack-limit-value.js.json | 1 + 4 files changed, 38 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7602-error-stack-limit-value.js.json create mode 100644 tests/corpus/7602-error-stack-limit-value.js create mode 100644 tests/harness/corpus-costs/7602-error-stack-limit-value.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7602-error-stack-limit-value.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7602-error-stack-limit-value.js.json new file mode 100644 index 000000000..99e1b8904 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7602-error-stack-limit-value.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7602-error-stack-limit-value.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 324233028..074fe3eae 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1870,6 +1870,14 @@ ScrDyn *scr_dyn_error_constructor(const char *name) { return NULL; } +/* `Error.stackTraceLimit` read or written through the Error constructor + * held as a value (zod's `const E = Error; E.stackTraceLimit = 0`): the + * same limit the static member reads and stack capture honors. */ +static bool scr_dyn_is_stack_limit(const ScrDyn *d, const char *key, size_t key_len) { + return key_len == 15 && memcmp(key, "stackTraceLimit", 15) == 0 && d->kind == SCR_DYN_FUNC && + scr_error_ctor_values[0] && d->v.fn.clo == scr_error_ctor_values[0]->v.fn.clo; +} + ScrDyn *scr_dyn_object_prototype(void) { if (!scr_builtin_object_prototype) { scr_builtin_object_prototype = scr_dyn_alloc(SCR_DYN_OBJ); @@ -8992,6 +9000,10 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { } if (recv->kind == SCR_DYN_FUNC) { if (scr_dyn_fn_property_fence(key)) return; + if (value->kind == SCR_DYN_NUM && scr_dyn_is_stack_limit(recv, key->data, key->len)) { + scr_stack_limit_set(value->v.num); + return; + } ScrDyn *table = scr_dyn_fn_properties(recv); scr_dyn_object_key_set(table, key, value, recv); scr_dyn_release(table); @@ -11773,6 +11785,7 @@ static ScrDyn *scr_function_constructor(uint32_t kind) { /* Keyed read on a FUNC node (see scr_runtime.h): own props first, then * the function-instance built-ins name/length. +1 or NULL. */ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { + if (scr_dyn_is_stack_limit(d, key, key_len)) return scr_dyn_new_num(scr_stack_limit_get()); ScrClosure *owner = scr_closure_identity(d->v.fn.clo); if (d->v.fn.class_obj && d->v.fn.class_obj->static_data && !owner->props) owner->props = scr_box_retain(d->v.fn.class_obj->static_data); diff --git a/tests/corpus/7602-error-stack-limit-value.js b/tests/corpus/7602-error-stack-limit-value.js new file mode 100644 index 000000000..4b3f8cc7e --- /dev/null +++ b/tests/corpus/7602-error-stack-limit-value.js @@ -0,0 +1,18 @@ +// Error.stackTraceLimit through the Error constructor held as a value +// (zod 4's `const E = "captureStackTrace" in Error ? Error : null`): reads +// and writes reach the same limit as the static member. +const E = "captureStackTrace" in Error ? Error : null; +console.log(typeof E, E === Error, E.stackTraceLimit, Error.stackTraceLimit); +const saved = E.stackTraceLimit; +E.stackTraceLimit = 0; +console.log(Error.stackTraceLimit, E.stackTraceLimit, typeof saved === "number"); +console.log(new Error("no frames").stack); +try { + E.stackTraceLimit = 3; + console.log(Error.stackTraceLimit); +} finally { + E.stackTraceLimit = saved; +} +console.log(E.stackTraceLimit, Error.stackTraceLimit); +Error.stackTraceLimit = 4; +console.log(E.stackTraceLimit); diff --git a/tests/harness/corpus-costs/7602-error-stack-limit-value.js.json b/tests/harness/corpus-costs/7602-error-stack-limit-value.js.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/7602-error-stack-limit-value.js.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } From 3041df387d772511f0e8dbb04ca9aa4d20dd81f3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 17:43:57 -0700 Subject: [PATCH 19/39] 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 94173bebdc4480d661d5316e3ac96a6eb8374a33 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 19:12:16 -0700 Subject: [PATCH 20/39] Name the compiler's own shim files independently of the install path Programs that compile against a package shim (fs-extra, chalk, undici, hot-shots) produced IR and LLVM that depended on where the compiler was installed: lifted helper and closure names hashed the shim file's path relative to the entry, the shared-helper location pass ordered candidate sites by absolute path, and IR artifacts spelled every shim location as an absolute path. Two checkouts of the same sources therefore emitted different artifacts, and the zero-cost gate always reported fs-extra-package as differing. Files scriptc ships are now named by their place inside scriptc (scriptc:shims/...) in structural positions, in the shared-helper ordering, and in serialized IR artifacts. In-memory locations keep the real path, so diagnostics and source lookups are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/dts-paths.ts | 25 +++++++++++++ .../compiler/src/frontend/lowering/lowerer.ts | 30 +++++++++++----- packages/compiler/src/index.ts | 9 ++--- packages/compiler/src/ir/serialize.test.ts | 21 +++++++++++ packages/compiler/src/ir/serialize.ts | 36 +++++++++++++++++-- packages/compiler/src/native/prepare.ts | 7 ++-- 6 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/compiler/src/frontend/dts-paths.ts b/packages/compiler/src/frontend/dts-paths.ts index 3b2f32413..009d1c333 100644 --- a/packages/compiler/src/frontend/dts-paths.ts +++ b/packages/compiler/src/frontend/dts-paths.ts @@ -34,6 +34,31 @@ export function packageShimRoot(): string { : join(installedDeclarationRoot, "shims"); } +/** The directories of files scriptc itself ships, each with the prefix + * that names its files independently of where scriptc is installed + * (compilerOwnedFileName). IR artifacts spell source locations in them this + * way (serializeModuleParts). */ +export function compilerOwnedFileRoots(): readonly { from: string; to: string }[] { + return [{ from: tsgoPath(packageShimRoot()) + "/", to: "scriptc:shims/" }]; +} + +/** The name of a file scriptc itself ships (a package shim or one of the + * shipped declaration files) that does not depend on where scriptc is + * installed: `scriptc:shims/` or + * `scriptc:`. Null for every other file. Names and + * artifacts that would otherwise spell the installation's absolute path use + * it, so two installations of one compiler emit the same output. */ +export function compilerOwnedFileName(file: string): string | null { + const path = tsgoPath(file); + for (const { from, to } of compilerOwnedFileRoots()) { + if (path.startsWith(from)) return to + path.slice(from.length); + } + for (const declaration of [ambientDtsPath(), overridesDtsPath(), fallbackDtsPath()]) { + if (path === declaration) return `scriptc:${path.slice(path.lastIndexOf("/") + 1)}`; + } + return null; +} + /** Path of the shipped ambient declarations — the always-shipped CORE * (comptime, setTimeout). Part of EVERY program scriptc * builds, the project-world preflight program included. */ diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3ba81dd6b..60eba3107 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -184,7 +184,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js"; */ import { dirname, relative, resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; -import { tsgoPath } from "../dts-paths.js"; +import { compilerOwnedFileName, tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; import { isBundledShimForwarder } from "../package-shims.js"; import { erasedTypeOnlyImport } from "../type-only-imports.js"; @@ -3200,13 +3200,14 @@ export class Lowerer { /** Structural source positions (structural-positions.ts) for names that * stay the same when unrelated code moves. */ /** Files are named by their path relative to the entry's directory, so - * names do not depend on where the program sits, and default library - * files by their base name: two checkers install them in different - * directories. */ + * names do not depend on where the program sits, default library files + * by their base name (two checkers install them in different + * directories), and the files scriptc ships by their name inside scriptc, + * so names do not depend on where scriptc is installed either. */ readonly positions = new StructuralPositions((sf) => this.program.isSourceFileDefaultLibrary(sf) ? `lib:${sf.fileName.slice(sf.fileName.lastIndexOf("/") + 1)}` - : relative(dirname(this.entry.fileName), sf.fileName), + : (compilerOwnedFileName(sf.fileName) ?? relative(dirname(this.entry.fileName), sf.fileName)), ); /** The outermost node whose token span is exactly `loc`, or null. */ @@ -3238,7 +3239,10 @@ export class Lowerer { * before it. */ closureName(loc: SrcLoc, suffix = "", prefix = "%fn"): string { const node = this.nodeAtLoc(loc); - const where = node === null ? `${loc.file}:${loc.start}:${loc.end}` : this.positions.of(node); + const where = + node === null + ? `${compilerOwnedFileName(loc.file) ?? loc.file}:${loc.start}:${loc.end}` + : this.positions.of(node); const key = `${prefix}|${this.closureRoot}|${where}|${suffix}`; const first = keyedHelperName(this, prefix, key); const seen = this.closureNameCounts.get(first) ?? 0; @@ -3379,8 +3383,18 @@ export class Lowerer { if (interned.has(fn.name)) this.sharedHelpers.add(fn); if (this.sharedHelpers.size === 0) return; const shared = new Map([...this.sharedHelpers].map((fn) => [fn.name, fn])); - const earlier = (left: SrcLoc, right: SrcLoc): boolean => - left.file < right.file || (left.file === right.file && left.start < right.start); + // Files scriptc ships compare by their name inside scriptc, so the + // choice does not depend on where scriptc is installed. + const fileKeys = new Map(); + const fileKey = (file: string): string => { + let key = fileKeys.get(file); + if (key === undefined) fileKeys.set(file, (key = compilerOwnedFileName(file) ?? file)); + return key; + }; + const earlier = (left: SrcLoc, right: SrcLoc): boolean => { + if (left.file === right.file) return left.start < right.start; + return fileKey(left.file) < fileKey(right.file); + }; // The earliest use of each shared helper outside the shared helpers, and // which shared helpers each shared helper uses. const uses = new Map(); diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index f0f19a408..daa5af366 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -126,6 +126,7 @@ import { moduleRuntimeFeatures } from "./ir/runtime-features.js"; import { serializeModuleParts } from "./ir/serialize.js"; import { readTextParts, writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; +import { compilerOwnedFileRoots } from "./frontend/dts-paths.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; import { @@ -785,7 +786,7 @@ async function prepareExecutableInput( if (outputKind === "ir") { await mkdir(dirname(opts.outPath), { recursive: true }); - await writeTextParts(opts.outPath, serializeModuleParts(mod)); + await writeTextParts(opts.outPath, serializeModuleParts(mod, compilerOwnedFileRoots())); return { ok: true, artifact: { kind: "ir", path: opts.outPath } }; } @@ -885,7 +886,7 @@ async function prepareExecutableInput( if (opts.emitIr) { irPath = defaultSourcePaths.ir; irSourcePath = privateSiblingPath(irPath, "ir"); - await writeTextParts(irSourcePath, serializeModuleParts(mod)); + await writeTextParts(irSourcePath, serializeModuleParts(mod, compilerOwnedFileRoots())); } const nativeFeatures = executableNativeFeatures(mod, backend, opts.optimization ?? "release"); @@ -1986,7 +1987,7 @@ async function emitSemanticLibraryHit( let irPath: string | undefined; if (opts.emitIr) { irPath = join(opts.outDir, `${stem}.lib.ir.json`); - await writeTextParts(irPath, serializeModuleParts(mod)); + await writeTextParts(irPath, serializeModuleParts(mod, compilerOwnedFileRoots())); } await compileLibraryNative(profile, llvmPath, archivePath, opts.sanitize ?? false, hit.native); timing("native-archive"); @@ -2272,7 +2273,7 @@ async function compileLibraryTracked( let irPath: string | undefined; if (opts.emitIr) { irPath = join(opts.outDir, `${stem}.lib.ir.json`); - await writeTextParts(irPath, serializeModuleParts(mod)); + await writeTextParts(irPath, serializeModuleParts(mod, compilerOwnedFileRoots())); } const nativeFeatures = libraryNativeFeatures(mod, profile.emission); diff --git a/packages/compiler/src/ir/serialize.test.ts b/packages/compiler/src/ir/serialize.test.ts index 8345ab10a..c24e1f198 100644 --- a/packages/compiler/src/ir/serialize.test.ts +++ b/packages/compiler/src/ir/serialize.test.ts @@ -198,3 +198,24 @@ test("byte decoding keeps the whole-document errors and version refusal", () => const sentinel = text.replace('"nan"', '"future"'); expect(() => deserializeModuleBytes(Buffer.from(sentinel))).toThrow("Invalid IR number sentinel"); }); + +test("file roots respell the source locations under them and nothing else", () => { + const mod = numbers([1, 2]); + const shim = { file: '/opt/scriptc "a"/shims/pkg/index.ts', start: 3, end: 4 }; + const other = { file: "/opt/elsewhere/shims/pkg/index.ts", start: 5, end: 6 }; + const body = mod.functions[0]!.body as IrStmt[]; + body.push( + { kind: "exprStmt", expr: { kind: "numLit", value: 3, type: F64, loc: shim }, loc: shim }, + { kind: "exprStmt", expr: { kind: "numLit", value: 4, type: F64, loc: other }, loc: other }, + ); + const roots = [{ from: '/opt/scriptc "a"/shims/', to: "scriptc:shims/" }]; + const relocated = deserializeModule(serializeModuleParts(mod, roots).join("")); + const files = (relocated.functions[0]!.body as IrStmt[]).map((stmt) => stmt.loc.file); + expect(files).toEqual([ + "numbers.ts", + "numbers.ts", + "scriptc:shims/pkg/index.ts", + "/opt/elsewhere/shims/pkg/index.ts", + ]); + expect(serializeModuleParts(mod).join("")).toBe(serializeModule(mod, true)); +}); diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index ca5eb955e..314f258d6 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -46,11 +46,41 @@ function encodeFunction(fn: IrFunction): string | undefined { return ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber); } +/** A directory whose source location files an IR artifact names by a + * prefix of its own: a location file `from + rest` serializes as + * `to + rest`. */ +export interface SerializedFileRoot { + from: string; + to: string; +} + +/** Respells the source location files under `roots` in serialized parts. + * A location's file is the first member of its JSON object, and the + * escaped spelling of a path prefix is the prefix of the escaped path. */ +function relocateFiles(parts: string[], roots: readonly SerializedFileRoot[]): string[] { + const needles = roots.map(({ from, to }) => ({ + from: `"file":${JSON.stringify(from).slice(0, -1)}`, + to: `"file":${JSON.stringify(to).slice(0, -1)}`, + })); + return parts.map((part) => + needles.reduce( + (text, { from, to }) => (text.includes(from) ? text.replaceAll(from, to) : text), + part, + ), + ); +} + /** The compact encoding as ordered parts: the module header up to the * function array, one part per function (with separate comma parts), and the * header's remainder. Concatenating the parts yields exactly - * `serializeModule(mod, true)`; writers stream them to disk in batches. */ -export function serializeModuleParts(mod: IrModule): string[] { + * `serializeModule(mod, true)`; writers stream them to disk in batches. + * `fileRoots` respells the source locations in those directories, so + * artifacts do not depend on where the compiler that wrote them is + * installed. */ +export function serializeModuleParts( + mod: IrModule, + fileRoots: readonly SerializedFileRoot[] = [], +): string[] { // A replacer reads through live native views. Serializing the complete // function array through one view refreshes every function capsule for // each element, making large compiler artifacts quadratic. The sentinel @@ -65,7 +95,7 @@ export function serializeModuleParts(mod: IrModule): string[] { parts.push(encodeFunction(fn) ?? "null"); } parts.push("]" + header.slice(offset + slot.length)); - return parts; + return fileRoots.length === 0 ? parts : relocateFiles(parts, fileRoots); } /** Compiler artifacts use compact JSON; API consumers can retain the diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index a2c627edf..da90dc2ce 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -26,6 +26,7 @@ import { type FrontendInputExclusions, trackedReadFile, } from "../frontend/input-tracker.js"; +import { compilerOwnedFileRoots } from "../frontend/dts-paths.js"; import { provenanceSources } from "../frontend/provenance-registry.js"; import { processFork } from "../frontend/process-fork.js"; import { prepareLibrary } from "../library/prepare.js"; @@ -312,7 +313,7 @@ export function prepareNativeExecutable( if (irInput !== "") timing("ir-input"); const ir = outputKind === "ir" || options.emitIr - ? batchTextParts(serializeModuleParts(prepared.mod)) + ? batchTextParts(serializeModuleParts(prepared.mod, compilerOwnedFileRoots())) : null; timing("ir-serialize"); const llvm = @@ -445,7 +446,9 @@ export function prepareNativeLibrary( const input: NativeExecutableInput = { ok: true, llvm, - ir: options.emitIr ? batchTextParts(serializeModuleParts(prepared.mod)) : null, + ir: options.emitIr + ? batchTextParts(serializeModuleParts(prepared.mod, compilerOwnedFileRoots())) + : null, sidecarJson: prepared.sidecarJson, features: executableLinkFeatures(prepared.mod), sources: [...prepared.sourceTexts], From 107d773382a6f83189b2ff959e5de89de6b3537e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:14:00 -0500 Subject: [PATCH 21/39] Pass zlib's flush sentinels to _transform as Buffers flush() on a zlib stream writes a zero-length chunk that _transform recognizes by identity and compresses with that flush kind. Since writes of a typed array that is not a Buffer reach _write as a Buffer over the same bytes (as Node's Stream._uint8ArrayToBuffer does), the sentinel, created as a plain Uint8Array, reached _transform as a new Buffer. _transform then used the default NO_FLUSH, so flush() emitted nothing: corpus 5374-zlib-stream-flush-params decoded an empty prefix where Node decodes "hello ". The sentinels are Buffers, as Node's kFlushBuffers are (Buffer.alloc(0)), so write() passes them through unchanged. The npm fixture stream-shims, whose test expected a deferred SC1090, now runs to a later deferred refusal, SC2020 (write completion callbacks taking the error argument); its expectation names that code. --- packages/runtime/src/scr_zlib_stream.c | 7 ++++++- tests/harness/npm.test.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/scr_zlib_stream.c b/packages/runtime/src/scr_zlib_stream.c index 54ffacf02..40daa6c76 100644 --- a/packages/runtime/src/scr_zlib_stream.c +++ b/packages/runtime/src/scr_zlib_stream.c @@ -549,7 +549,12 @@ void scr_zlib_stream_flush(ScrStream *s, ScrDyn *kind, ScrClosure *cb) { return; } int ki = (int)k; - if (zs->flush_bufs[ki] == NULL) zs->flush_bufs[ki] = scr_bytes_new(SCR_BYTES_U8, 0); + if (zs->flush_bufs[ki] == NULL) { + /* a Buffer, as Node's kFlushBuffers are: write() passes a Buffer + * through unchanged, so _transform recognizes the sentinel */ + zs->flush_bufs[ki] = scr_bytes_new(SCR_BYTES_U8, 0); + zs->flush_bufs[ki]->is_buffer = true; + } scr_stream_write(s, zs->flush_bufs[ki], cb); } diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 795e42846..13ad3f438 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -102,7 +102,7 @@ const KNOWN: Record = { "package-imports": { build: "SC2013" }, "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, - "stream-shims": { run: "SC1090" }, + "stream-shims": { run: "SC2020" }, "util-shims": { run: "SC2020" }, "web-streams": { run: "SC1090" }, "workspace-copied": { build: "SC2013" }, From a9b1d15eeac58cb104cc1cdbb75c89b285248a58 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 15:57:55 -0700 Subject: [PATCH 22/39] Map unresolved JSDoc results of generic JavaScript calls as checked-dynamic A published bundle's JSDoc can name a type that only its .d.ts declares: hono's bearerAuth carries `@template E` and `@returns {MiddlewareHandler}` without importing MiddlewareHandler. The checker types that result as its error type, and a call of such a generic JavaScript function was refused with SC2001 ("values of type 'MiddlewareHandler' cannot be compiled"). The unresolved annotation is documentation, not a contract, so the call's result is now checked-dynamic, as an `any` result of a TypeScript generic already is. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/generic-functions.ts | 9 +++- .../main.ts.json | 6 +++ .../jsdoc-unresolved-generic-return/main.ts | 19 ++++++++ .../npm/node_modules/jsdocmw/index.d.ts | 18 ++++++++ .../npm/node_modules/jsdocmw/index.js | 43 +++++++++++++++++++ .../npm/node_modules/jsdocmw/package.json | 8 ++++ 6 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts.json create mode 100644 tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts create mode 100644 tests/fixtures/npm/node_modules/jsdocmw/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/jsdocmw/index.js create mode 100644 tests/fixtures/npm/node_modules/jsdocmw/package.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index e27ab7f5f..222cb89f9 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -342,10 +342,15 @@ export function genericCallInstance( const local = localClassResultInstance(lowerer, expr, info, rsig, params, retTs); if (local) return local; // A result instantiated at `any` (a type argument inferred from an - // untyped value, such as an untyped forwarding call) is checked-dynamic. + // untyped value, such as an untyped forwarding call) is checked-dynamic, + // and so is a JavaScript result whose JSDoc names a type the file never + // declares or imports (`@returns {MiddlewareHandler}` in a published + // bundle): the checker's error type is documentation it could not + // resolve, not a contract. const mappedReturn = lowerer.mapTypeOf(retTs) ?? - ((retTs.flags & ts.TypeFlags.Any) !== 0 && !isJsSourceFile(ts.getSourceFileOfNode(info.decl)) + ((retTs.flags & ts.TypeFlags.Any) !== 0 && + (!isJsSourceFile(ts.getSourceFileOfNode(info.decl)) || retTs.isErrorType()) ? DYN : null); if (!mappedReturn) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts.json new file mode 100644 index 000000000..5395e20be --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts b/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts new file mode 100644 index 000000000..ce6ef5a94 --- /dev/null +++ b/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts @@ -0,0 +1,19 @@ +// A published bundle's generic factory whose JSDoc names its result by a +// type that only the package's .d.ts declares (hono's `bearerAuth`: +// `@template E` + `@returns {MiddlewareHandler}`), passed straight into +// a generic registration method. The unresolved JSDoc result is not a +// contract, so the call's result is checked-dynamic. +import { guard, Router } from "jsdocmw"; + +const router = new Router(); +router.on("/admin", guard({ token: "s3cret" }), async (ctx, next) => { + ctx.log.push(`handler ${ctx.path}`); + await next(); +}); +const open = guard({ token: "" }); +router.on("/open", open, async (ctx) => { + ctx.log.push(`open handler ${ctx.path}`); +}); +console.log(await router.dispatch("/admin", "s3cret")); +console.log(await router.dispatch("/admin", "nope")); +console.log(await router.dispatch("/open", "")); diff --git a/tests/fixtures/npm/node_modules/jsdocmw/index.d.ts b/tests/fixtures/npm/node_modules/jsdocmw/index.d.ts new file mode 100644 index 000000000..1c93f3348 --- /dev/null +++ b/tests/fixtures/npm/node_modules/jsdocmw/index.d.ts @@ -0,0 +1,18 @@ +export interface Env { + Variables?: Record; +} +export interface Ctx { + path: string; + token: string; + log: string[]; + env?: E; +} +export type Middleware = (ctx: Ctx, next: () => Promise) => Promise; +export interface GuardOptions { + token: string; +} +export declare const guard: (options: GuardOptions) => Middleware; +export declare class Router { + on(path: string, ...handlers: Middleware[]): this; + dispatch(path: string, token: string): Promise; +} diff --git a/tests/fixtures/npm/node_modules/jsdocmw/index.js b/tests/fixtures/npm/node_modules/jsdocmw/index.js new file mode 100644 index 000000000..f1e38383b --- /dev/null +++ b/tests/fixtures/npm/node_modules/jsdocmw/index.js @@ -0,0 +1,43 @@ +/** + * Guard middleware. + * + * @template E - The environment type. + * @param {GuardOptions} options - The options. + * @returns {Middleware} The middleware. + */ +export const guard = (options) => { + return async function guard(ctx, next) { + if (ctx.token !== options.token) { + ctx.log.push(`denied ${ctx.path}`); + return; + } + ctx.log.push(`allowed ${ctx.path}`); + await next(); + }; +}; + +export class Router { + constructor() { + this.routes = []; + } + + on(path, ...handlers) { + this.routes.push({ path, handlers }); + return this; + } + + async dispatch(path, token) { + const log = []; + for (const route of this.routes) { + if (route.path !== path) continue; + const ctx = { path, token, log }; + let index = 0; + const next = async () => { + const handler = route.handlers[index++]; + if (handler) await handler(ctx, next); + }; + await next(); + } + return log; + } +} diff --git a/tests/fixtures/npm/node_modules/jsdocmw/package.json b/tests/fixtures/npm/node_modules/jsdocmw/package.json new file mode 100644 index 000000000..a3283642f --- /dev/null +++ b/tests/fixtures/npm/node_modules/jsdocmw/package.json @@ -0,0 +1,8 @@ +{ + "name": "jsdocmw", + "version": "1.0.0", + "description": "a bundled middleware factory whose JSDoc @template result names a type only its .d.ts declares (hono's bearerAuth shape)", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} From 74f2c9754b4540fe67703c1cda5d4b33adc033e4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:03:18 -0700 Subject: [PATCH 23/39] Dispatch String-only method names on untyped receivers by runtime kind A call of a String.prototype-only method name (trim, toLowerCase, startsWith, ...) on a checker-untyped receiver was lowered as a string call that throws unless the receiver is a string. A checked-dynamic object can carry its own member of that name: zod's schemas assign `trim` in their constructors, so `z.string().trim().min(1)` typed the `trim()` result as a string and refused `.min` with SC1090. Untyped receivers now take the runtime-kind dispatch that object-typed receivers missing the member already use: a string runs the string lowering, any other value calls its own member as Node does. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 23 ++++++----- ...d-receiver-own-string-method-names.ts.json | 6 +++ ...ntyped-receiver-own-string-method-names.ts | 38 +++++++++++++++++++ ...d-receiver-own-string-method-names.ts.json | 1 + 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-untyped-receiver-own-string-method-names.ts.json create mode 100644 tests/corpus/7441-untyped-receiver-own-string-method-names.ts create mode 100644 tests/harness/corpus-costs/7441-untyped-receiver-own-string-method-names.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index c1bc044ae..784efeb13 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3797,18 +3797,21 @@ function lowerDynReceiverMethodCall( } if (access.name.text === "flatMap") return lowerDynArrayFlatMapCall(lowerer, call, access, recv); // String methods claim only names NO other dyn-representable kind's - // prototype declares (Array carries includes/indexOf/slice too): for - // these, "the receiver is a string, or the call throws V8's TypeError" - // IS Node's semantics for every possible dyn value. Shared names would - // need a receiver-kind dispatch — they keep the fence. + // prototype declares (Array carries includes/indexOf/slice too), so no + // other kind inherits them. Shared names would need a receiver-kind + // dispatch — they keep the fence. if (DYN_STRING_ONLY_METHODS.has(access.name.text) && !ownCallable) { - // An object-typed receiver the member is missing from (a JavaScript - // module object whose members are assigned in a function body, - // `common.repeat(" ", n)`) holds a string or an object with that - // member: dispatch on the receiver's runtime kind. + // A checked-dynamic object can still carry its OWN member of that + // name: zod's schemas assign `trim`, `toLowerCase` and `startsWith` + // in their constructors (`z.string().trim().min(1)`), and a JavaScript + // module object can assign `repeat` in a function body + // (`common.repeat(" ", n)`, an object-typed receiver the member is + // missing from). An untyped receiver, or a member the checker cannot + // type, dispatches on the receiver's runtime kind: a string runs the + // string lowering, anything else calls its member as Node does. if ( - !checkerUntyped && - (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 && + (checkerUntyped || + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && !call.questionDotToken && !access.questionDotToken && !call.arguments.nodes.some(ts.isSpreadElement) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-untyped-receiver-own-string-method-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-untyped-receiver-own-string-method-names.ts.json new file mode 100644 index 000000000..7477193ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7441-untyped-receiver-own-string-method-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7441-untyped-receiver-own-string-method-names.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7441-untyped-receiver-own-string-method-names.ts b/tests/corpus/7441-untyped-receiver-own-string-method-names.ts new file mode 100644 index 000000000..632ceb16f --- /dev/null +++ b/tests/corpus/7441-untyped-receiver-own-string-method-names.ts @@ -0,0 +1,38 @@ +// An untyped receiver calling a String.prototype method name holds a +// string or a checked-dynamic object with its own method of that name +// (zod's schemas assign `trim`, `toLowerCase` and `startsWith` in their +// constructors, so `z.string().trim().min(1)` chains schema calls). +// Each call dispatches on the receiver's runtime kind. +interface Field { + value: string; + checks: string[]; +} + +function field(value: string, checks: string[]): any { + const self: Field & Record = { value, checks }; + self["trim"] = () => field(value.trim(), [...checks, "trim"]); + self["toLowerCase"] = () => field(value.toLowerCase(), [...checks, "lower"]); + self["startsWith"] = (prefix: string) => checks.length > 0 && value.startsWith(prefix); + self["min"] = (n: number) => field(value, [...checks, `min(${n})`]); + return self; +} + +const chained = field(" Hello World ", []).trim().toLowerCase().min(1); +console.log(JSON.stringify(chained.value), chained.checks.join(",")); +console.log(field("abc", ["x"]).startsWith("ab"), field("abc", []).startsWith("ab")); + +const text: any = " Mixed Case "; +console.log(JSON.stringify(text.trim()), text.toLowerCase(), text.startsWith(" M")); + +const parsed: any = JSON.parse('{"name":" padded "}'); +console.log(JSON.stringify(parsed.name.trim())); + +for (const value of [field(" a ", []), " b ", 42, null]) { + const v: any = value; + try { + const out = v.trim(); + console.log(typeof out === "string" ? JSON.stringify(out) : `field ${JSON.stringify(out.value)}`); + } catch (error) { + console.log(error instanceof TypeError, (error as Error).message); + } +} diff --git a/tests/harness/corpus-costs/7441-untyped-receiver-own-string-method-names.ts.json b/tests/harness/corpus-costs/7441-untyped-receiver-own-string-method-names.ts.json new file mode 100644 index 000000000..615c617ae --- /dev/null +++ b/tests/harness/corpus-costs/7441-untyped-receiver-own-string-method-names.ts.json @@ -0,0 +1 @@ +{ "plain": 5800 } From 6115135d96affbf01e4960f270e9e7eac2e22ed5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:18:48 -0500 Subject: [PATCH 24/39] Dispatch new.target over generic subclasses and own static names A constructor's new.target dispatch skipped generic class families, so a generic subclass instantiated only in a body lowered later answered with the base's name, and `new.target === Base` held for its instances. A family now has its own arm, tested by its instanceof interval, which holds every instantiation: `new.target.name` reads the family's name and comparisons with a program class are false. Under an instantiation, the generic subclasses of its family are candidates too. `new.target` as a value refuses generic subclasses, which have no common class value. `new.target.name` reads a class's own static `name` field, which replaces its name (a subclass has its own name); a static method `name` or a non-string field is refused. --- .../src/frontend/lowering/new-target.ts | 68 ++++++++++++++---- ...364-new-target-late-instantiations.ts.json | 6 ++ .../new-target-value-generic-subclass.ts.json | 6 ++ .../7364-new-target-late-instantiations.ts | 71 +++++++++++++++++++ .../new-target-value-generic-subclass.ts | 5 ++ .../new-target-value-generic-subclass.ts.txt | 6 ++ ...364-new-target-late-instantiations.ts.json | 1 + 7 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json create mode 100644 tests/corpus/7364-new-target-late-instantiations.ts create mode 100644 tests/diagnostics/new-target-value-generic-subclass.ts create mode 100644 tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt create mode 100644 tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json diff --git a/packages/compiler/src/frontend/lowering/new-target.ts b/packages/compiler/src/frontend/lowering/new-target.ts index 8b5f5858a..4cf271469 100644 --- a/packages/compiler/src/frontend/lowering/new-target.ts +++ b/packages/compiler/src/frontend/lowering/new-target.ts @@ -43,7 +43,9 @@ function bindingOf(node: ts.Node): Binding { interface Dispatch { info: ClassInfo; - /** The constructor's class and its descendants, deepest first. */ + /** The constructor's descendants, deepest first, then its class. A + * generic family stands for its instantiations, also those that later + * bodies instantiate: its interval holds them all. */ classes: ClassInfo[]; self: IrExpr | null; } @@ -69,11 +71,23 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { ); return null; } - const classes = [info]; + // An instantiation's subclasses can also be instantiations of generic + // subclasses of its family (`class G extends Box` under Box). + const family = info.genericInstance?.family; + const descendants: ClassInfo[] = []; for (const other of lowerer.classes.values()) { - if (other === info || other.generic) continue; - if (lowerer.isSubclassOf(other.def.name, info.def.name)) classes.push(other); + if (other === info) continue; + if ( + lowerer.isSubclassOf(other.def.name, info.def.name) || + (family !== undefined && + other.generic !== undefined && + other !== family && + lowerer.isSubclassOf(other.def.name, family.def.name)) + ) + descendants.push(other); } + descendants.sort((a, b) => depth(b) - depth(a)); + const classes = [...descendants, info]; for (const c of classes) { if (c.localClass) { lowerer.unsupported( @@ -84,7 +98,6 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { return null; } } - classes.sort((a, b) => depth(b) - depth(a)); let self: IrExpr | null = null; if (classes.length > 1) { const thisLocal = lowerer.resolveThis(expr); @@ -140,6 +153,13 @@ export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr const dispatch = dispatchOf(lowerer, expr)!; const type: IrType = { kind: "classval", className: dispatch.info.def.name }; for (const c of dispatch.classes) { + if (c.generic) { + lowerer.unsupported( + "SC1090", + expr, + `'new.target' as a value where '${c.def.jsName ?? c.def.name}' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead)`, + ); + } if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) { lowerer.unsupported( "SC1090", @@ -166,13 +186,37 @@ export function lowerNewTargetName( const dispatch = dispatchOf(lowerer, target); if (!dispatch) return null; const loc = locOf(access); - return select(dispatch, STRING, loc, (c) => ({ - kind: "libCall", - fn: "class.name", - args: [classValueRef(lowerer, c, target)], - type: STRING, - loc, - })); + return select(dispatch, STRING, loc, (c) => { + // An own static `name` field replaces the class's name; a subclass has + // its own name. An instantiation's statics are its family's. + const owner = c.genericInstance?.family ?? c; + const field = owner.staticFields.find((f) => f.name === "name"); + if (field !== undefined) { + if (field.type.kind !== "string") + lowerer.unsupported( + "SC1090", + access, + `'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static 'name' that is not a string`, + ); + return { kind: "varRef", localId: field.globalId, type: field.type, loc }; + } + if (owner.staticMethods?.has("name") || owner.genericStatics?.has("name")) + lowerer.unsupported( + "SC1090", + access, + `'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static method 'name'`, + ); + // A generic family's instantiations share its name. + if (c.generic) + return { kind: "strLit", value: c.def.jsName ?? c.generic.baseName, type: STRING, loc }; + return { + kind: "libCall", + fn: "class.name", + args: [classValueRef(lowerer, c, target)], + type: STRING, + loc, + }; + }); } /** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json new file mode 100644 index 000000000..20d761c6c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7364-new-target-late-instantiations.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7364-new-target-late-instantiations.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json new file mode 100644 index 000000000..1abcce3ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-value-generic-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-value-generic-subclass.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7364-new-target-late-instantiations.ts b/tests/corpus/7364-new-target-late-instantiations.ts new file mode 100644 index 000000000..3fa7ff94a --- /dev/null +++ b/tests/corpus/7364-new-target-late-instantiations.ts @@ -0,0 +1,71 @@ +// new.target in a constructor whose subclasses are generic classes +// instantiated only in bodies lowered later, and classes whose own static +// `name` field replaces their name. +class Plain { + readonly label: string; + readonly plain: boolean; + constructor() { + this.label = new.target.name; + this.plain = new.target === Plain; + } +} +class Sub extends Plain { + readonly v: T; + constructor(v: T) { + super(); + this.v = v; + } +} + +class Box { + readonly label: string; + readonly exact: boolean; + readonly value: T; + constructor(value: T) { + this.value = value; + this.label = new.target.name; + this.exact = new.target === Leaf; + } +} +class Tagged extends Box {} +class Leaf extends Tagged {} + +class Named { + static name = "custom"; + readonly label: string; + constructor() { + this.label = new.target.name; + } +} +class Child extends Named {} +class Other extends Named { + static name = "other"; +} +class Family { + static name = "family"; + readonly label: string; + readonly v: T; + constructor(v: T) { + this.label = new.target.name; + this.v = v; + } +} + +function later(): void { + const n = new Sub(1); + const p = new Plain(); + console.log(n.label, new Sub("s").label, p.label, n.plain, p.plain); + const t = new Tagged(1); + const s = new Tagged("a"); + const b = new Box(2); + const l = new Leaf(3); + console.log(t.label, s.label, b.label, l.label, t.exact, l.exact, b.exact); + console.log(new Family(true).label); +} + +// The bases construct here first; their subclasses only in later(). +console.log(new Plain().label, new Box(false).label); +console.log(new Named().label, new Child().label, new Other().label); +Named.name = "changed"; +console.log(new Named().label, new Child().label, new Other().label); +later(); diff --git a/tests/diagnostics/new-target-value-generic-subclass.ts b/tests/diagnostics/new-target-value-generic-subclass.ts new file mode 100644 index 000000000..2107a39a5 --- /dev/null +++ b/tests/diagnostics/new-target-value-generic-subclass.ts @@ -0,0 +1,5 @@ +// new.target as a class value where a subclass is generic: its +// instantiations have no common class value. +class A { readonly t: typeof A; constructor() { this.t = new.target; } } +class B extends A { readonly v: T | undefined = undefined; } +console.log(new B().t.name); diff --git a/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt b/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt new file mode 100644 index 000000000..0e5cf2e52 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-value-generic-subclass.ts.txt @@ -0,0 +1,6 @@ +new-target-value-generic-subclass.ts:3:58 - error SC1090: 'new.target' as a value where 'B' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead) is not supported yet + + 2 | // instantiations have no common class value. + 3 | class A { readonly t: typeof A; constructor() { this.t = new.target; } } + | ^~~~~~~~~~ + 4 | class B extends A { readonly v: T | undefined = undefined; } \ No newline at end of file diff --git a/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json b/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json new file mode 100644 index 000000000..725cf769c --- /dev/null +++ b/tests/harness/corpus-costs/7364-new-target-late-instantiations.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 17500 } From 5edf9df5ef666cde604fdc3b19c35c2cb8ac0049 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:19:05 -0500 Subject: [PATCH 25/39] Read only IR nodes as IR nodes in the code-unit locals pass --- packages/compiler/src/ir/char-unit-locals.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/compiler/src/ir/char-unit-locals.ts b/packages/compiler/src/ir/char-unit-locals.ts index 4008f4e1e..beeeffaf3 100644 --- a/packages/compiler/src/ir/char-unit-locals.ts +++ b/packages/compiler/src/ir/char-unit-locals.ts @@ -161,6 +161,10 @@ function unitLocal(local: IrLocal): IrLocal { * declaration from a one-argument charAt, a one-unit test, or a switch * over one-unit cases. */ function understoodMention(node: Record): string | null { + // count() visits every object in the body (types, locations, cases), so + // only the node kinds handled here are read as IR nodes + const kind = node.kind; + if (kind !== "varDecl" && kind !== "strEq" && kind !== "strCmp" && kind !== "switch") return null; const n = node as unknown as IrExpr | IrStmt; if (n.kind === "varDecl") return n.init && isCharAt(n.init) ? n.localId : null; if (n.kind === "strEq" || n.kind === "strCmp") return unitUse(n)?.id ?? null; From 33cf76d36e4fa971aff022c75df66eee16bef38a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:23:57 -0500 Subject: [PATCH 26/39] Keep object and array literals that hold an evolving empty array on that array An object or array literal that held an evolving `const xs = []` between writes of different element types (`const box = { items: xs }` or `const rows = [xs]` after `xs.push(1)`, before `xs.push("two")`) took the element type the checker gives the array at that point, so it held a copy. `box.items.length` and `box.items === xs` printed `1 false`, where Node prints `2 true`. Such a literal now builds as a checked-dynamic literal that keeps the array, and an unannotated binding of it takes the checked-dynamic type. This covers shorthand properties (`{ xs }`), nested literals, spreads of a literal holder, aliases of a holder and names destructured from one. Element reads through an alias or holder (`rows[0]`, `alias[1]`) read the checked-dynamic value instead of checking it against the checker's type, which lists only the elements written before the alias or holder was taken. A shorthand property now counts as a read of the array when its reads are compared, so `{ xs }` before a write of another element type makes the array checked-dynamic too. Fixes #1641. --- .../lowering/expressions/element-access.ts | 15 ++- .../lowering/expressions/object-literals.ts | 6 +- .../src/frontend/lowering/lower-stmts.ts | 126 +++++++++++++++--- .../compiler/src/frontend/lowering/lowerer.ts | 4 +- tests/corpus/7006-evolving-array-aliases.ts | 48 +++++++ 5 files changed, 175 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 045bebdf4..a9246ceec 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -65,6 +65,7 @@ import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js"; import { isClassCallback } from "../class-callbacks.js"; import { isKeyedFieldAccess } from "./keyed-field-targets.js"; import { lowerFieldCompoundValue } from "./updates.js"; +import { readsHeldEvolvingArray } from "../lower-stmts.js"; /** `a[i]` reads. Only f64 indices into array receivers are modeled; JS * string-key element access (`a["length"]`) and string indexing (`s[0]` @@ -751,12 +752,16 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre : lowerer.maybeNarrow(arr, expr.expression); } + // An alias or container of an evolving array whose reads disagree + // (`rows[0]` after `const rows = [xs]`) holds elements the checker's type + // does not list, so its element read stays checked-dynamic. + const heldEvolving = arr.type.kind === "dyn" && readsHeldEvolvingArray(lowerer, expr.expression); // The checker sees an array but the VALUE lowered checked-dynamic (a // jsdoc-typed default export from a .js module — signature 14): the // read rides the dynCheck boundary when the array shape is one the checked-dynamic tree // can validate, and fences by name when it is not — an arrayGet over a // dyn receiver is never emitted (the validator ICE). - if (arr.type.kind === "dyn") { + if (arr.type.kind === "dyn" && !heldEvolving) { if ( canDynCheckTo( receiverIr, @@ -782,6 +787,14 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre locOf(expr.argumentExpression), expr.argumentExpression, ); + if (heldEvolving) + return { + kind: "dynKeyGet", + value: arr, + key: lowerer.coerceToExpected(index, DYN), + type: DYN, + loc: locOf(expr), + }; if ( arr.type.kind === "array" && (index.type.kind === "dyn" || diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 4d04be30a..429abf793 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -37,6 +37,7 @@ import { fenceSymbolFieldCopy } from "../symbol-fields.js"; import { planUnionRetag } from "../union-retag.js"; import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; import { builtinGlobalValue } from "./property-access.js"; +import { holdsEvolvingArray } from "../lower-stmts.js"; /** `{ a: 1, b: "x" }` → recordLit. The record type comes from the * contextual type when tsc has one (annotated declarations, arguments, @@ -397,7 +398,10 @@ export function lowerDynObjectLiteral( ? lowerer.lowerLambda(prop, true) : ts.isShorthandPropertyAssignment(prop) && shorthandBuiltinValue(lowerer, prop) ? shorthandBuiltinValue(lowerer, prop)! - : !boxValue && ts.isObjectLiteralExpression(valueExpr) + : !boxValue && + (ts.isObjectLiteralExpression(valueExpr) || + (ts.isArrayLiteralExpression(valueExpr) && + holdsEvolvingArray(lowerer, valueExpr))) ? lowerer.lowerExprExpecting(valueExpr, DYN) : lowerer.lowerExpr(valueExpr as ts.Expression); raw = fenceClosureProbe(lowerer, valueExpr, undefined, lowerValue) ?? lowerValue(); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 62c2e3a0f..bb045ac91 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -4920,12 +4920,16 @@ export function bindPatternTarget( } // Preserve checked-value pattern bindings when the inferred type // cannot represent them without a refusal or an alias-breaking copy. + // A name destructured from a container of an evolving array + // (evolvingArrayHold) may hold the array, which the checker types by + // the elements written before it. const dynStays = value.type.kind === "dyn" && - (() => { + ((() => { const mapped = lowerer.mapTypeOf(lowerer.typeOf(name)); return mapped === null || jsBindingKeepsCheckedObject(lowerer, name, mapped); - })(); + })() || + readsHeldEvolvingArray(lowerer, name)); let type = dynStays ? DYN : (lowerer.runtimeOptionalBindingType(name, lowerer.irTypeOf(name)) ?? @@ -5054,13 +5058,16 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy // type once the reads settle it. const written: ts.Expression[] = []; // The file's identifiers are indexed by name once (identifiersNamed), so - // each binding resolves only the uses spelled like it. + // each binding resolves only the uses spelled like it. A shorthand + // property (`{ xs }`) is a use too. for (const node of identifiersNamed(lowerer, ts.getSourceFileOfNode(decl), decl.name.text)) { if ( node === decl.name || node.pos < scope.pos || node.end > scope.end || - lowerer.checker.getSymbolAtLocation(node) !== symbol + (ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node + ? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent) + : lowerer.checker.getSymbolAtLocation(node)) !== symbol ) continue; let target: ts.Node = node; @@ -5118,28 +5125,105 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy return result; } -/** An unannotated alias of an evolving array binding whose reads disagree - * (`const alias = xs` between `xs.push(1)` and `xs.push("two")`): the - * checker types the alias by the elements written before it, but both names - * hold one array, so the alias takes the binding's checked-dynamic type and - * sees the later writes. Aliases of such an alias follow it. Null for other - * bindings. */ -function evolvingArrayAliasType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null { - let current = decl; - for (let depth = 0; depth < 8; depth++) { - let initializer = current.initializer; - while (initializer !== undefined && ts.isParenthesizedExpression(initializer)) - initializer = initializer.expression; - if (initializer === undefined || !ts.isIdentifier(initializer)) return null; - const source = lowerer.checker.getSymbolAtLocation(initializer)?.valueDeclaration; +/** How a TypeScript expression holds an evolving array binding whose reads + * disagree (evolvingArrayType answers checked-dynamic): "evolving" for the + * binding itself, "alias" for an unannotated binding initialized with it or + * with an alias of it (`const alias = xs`), and "container" for an object or + * array literal with it as a property, element or spread source at any + * depth (`{ items: xs }`, `[xs]`), an unannotated binding initialized with + * or destructured from such a literal, and a property or element read of a + * container (`box.items`). The checker types an alias or container by the + * elements written before it was taken, but it holds the same array. Null + * for other expressions. */ +function evolvingArrayHold( + lowerer: Lowerer, + expr: ts.Expression, + depth = 0, +): "evolving" | "alias" | "container" | null { + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (depth >= 8) return null; + const binding = (symbol: ts.Symbol | undefined): "evolving" | "alias" | "container" | null => { + const source = symbol?.valueDeclaration; + // A name destructured from a container (`const { items } = box`) may + // be the array or another container. + if (source !== undefined && ts.isBindingElement(source) && ts.isIdentifier(source.name)) { + const host = hostVariableDeclarationOf(source.name); + return host !== null && + !host.type && + host.initializer !== undefined && + evolvingArrayHold(lowerer, host.initializer, depth + 1) === "container" + ? "container" + : null; + } if (source === undefined || !ts.isVariableDeclaration(source) || source.type) return null; const evolved = evolvingArrayType(lowerer, source); - if (evolved !== null) return evolved.kind === "dyn" ? evolved : null; - current = source; + if (evolved !== null) return evolved.kind === "dyn" ? "evolving" : null; + if (source.initializer === undefined) return null; + const held = evolvingArrayHold(lowerer, source.initializer, depth + 1); + return held === "evolving" ? "alias" : held; + }; + if (ts.isIdentifier(expr)) return binding(lowerer.checker.getSymbolAtLocation(expr)); + if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr)) + return evolvingArrayHold(lowerer, expr.expression, depth + 1) === "container" + ? "container" + : null; + // A spread of the array itself (`{ ...xs }`, `[...xs]`) copies its + // elements, as in Node; a spread of a container copies its references. + if (ts.isObjectLiteralExpression(expr)) { + for (const property of expr.properties.nodes) { + const held = ts.isPropertyAssignment(property) + ? evolvingArrayHold(lowerer, property.initializer, depth + 1) + : ts.isShorthandPropertyAssignment(property) + ? binding(lowerer.checker.getShorthandAssignmentValueSymbol(property)) + : ts.isSpreadAssignment(property) && + evolvingArrayHold(lowerer, property.expression, depth + 1) === "container" + ? "container" + : null; + if (held !== null) return "container"; + } + return null; + } + if (ts.isArrayLiteralExpression(expr)) { + for (const element of expr.elements.nodes) { + const held = ts.isSpreadElement(element) + ? evolvingArrayHold(lowerer, element.expression, depth + 1) === "container" + ? "container" + : null + : evolvingArrayHold(lowerer, element, depth + 1); + if (held !== null) return "container"; + } } return null; } +/** Whether a TypeScript expression is an alias or container of an evolving + * array binding whose reads disagree (evolvingArrayHold). Its value is + * checked-dynamic, and the checker's type for it lists only the elements + * written before it was taken, so element reads through it stay + * checked-dynamic instead of checking against that type. */ +export function readsHeldEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + const held = evolvingArrayHold(lowerer, expr); + return held === "alias" || held === "container"; +} + +/** Whether a TypeScript object or array literal holds an evolving array + * binding whose reads disagree (evolvingArrayHold), so it builds as a + * checked-dynamic literal that keeps the array. */ +export function holdsEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false; + return evolvingArrayHold(lowerer, expr) === "container"; +} + +/** An unannotated binding initialized with an evolving array binding whose + * reads disagree, or with an alias or container of one (evolvingArrayHold): + * it takes the array's checked-dynamic type, so it sees the later writes. + * Null for other bindings. */ +function evolvingArrayHolderType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null { + if (decl.initializer === undefined) return null; + return evolvingArrayHold(lowerer, decl.initializer) === null ? null : DYN; +} + /** A single static type for an initializer-less JavaScript `let` whose * later plain writes all agree. An evolving JS local reads as `undefined` * until control flow proves a write, so a scalar slot is sound only when @@ -5834,7 +5918,7 @@ export function lowerVarDecl( !decl.type && !isJsSourceFile(ts.getSourceFileOfNode(decl)) ) { - const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayAliasType(lowerer, decl); + const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayHolderType(lowerer, decl); if (evolved !== null) { type = evolved; init = lowerer.lowerExprExpecting(decl.initializer, evolved); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3ba81dd6b..80db45f3a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -614,6 +614,7 @@ import { lowerForStatement, matchGroupsPatternSource, isGuardedRecordRead, + holdsEvolvingArray, } from "./lower-stmts.js"; import { type FieldTarget, @@ -10655,7 +10656,8 @@ export class Lowerer { builtinOption("UrlObject") || builtinOption("URLFormatOptions") || !this.mapTypeOf(this.typeOf(x)) || - this.excessJsContextualProperty(x, parts) + this.excessJsContextualProperty(x, parts) || + holdsEvolvingArray(this, x) ) { return lowerDynObjectLiteral(this, x); } diff --git a/tests/corpus/7006-evolving-array-aliases.ts b/tests/corpus/7006-evolving-array-aliases.ts index 215b3f3c5..fce1d0946 100644 --- a/tests/corpus/7006-evolving-array-aliases.ts +++ b/tests/corpus/7006-evolving-array-aliases.ts @@ -77,3 +77,51 @@ function spreadSame(): number { return xs.reduce((sum, v) => sum + v, 0); } console.log(spreadSame()); + +// An object or array literal that holds the array between the writes +// (`{ items: xs }`, `[xs]`, a shorthand property, a nested literal), a +// binding of such a literal, and a name destructured from one hold the +// same array, so they see the later writes. +function objectHolder(): string { + const xs = []; + xs.push(1); + const box = { items: xs, label: "box" }; + xs.push("two"); + box.items.push(3); + return `${box.items.length} ${box.items === xs} ${box.items[1]} ${box.label} ${xs.join(",")}`; +} +function arrayHolder(): string { + const xs = []; + xs.push(1); + const rows = [xs, [5]]; + xs.push("two"); + let total = 0; + for (const row of rows) total += row.length; + return `${rows[0].length} ${rows[0] === xs} ${rows[0][1]} ${total}`; +} +function shorthandHolder(): string { + const xs = []; + xs.push(1); + const box = { xs, count: 3 }; + xs.push("two"); + return `${box.xs.length} ${box.xs === xs} ${box.count}`; +} +function nestedHolder(): string { + const xs = []; + xs.push(1); + const box = { inner: [xs], pair: [{ items: xs }] }; + const outer = { box }; + xs.push("two"); + return `${box.inner[0].length} ${box.pair[0].items === xs} ${outer.box.inner[0][1]}`; +} +function holderAliases(): string { + const xs = []; + xs.push(1); + const box = { items: xs }; + const other = box; + const { items } = box; + xs.push("two"); + return `${other.items.length} ${items.length} ${items === xs} ${JSON.stringify(other)}`; +} +console.log(objectHolder(), "|", arrayHolder(), "|", shorthandHolder()); +console.log(nestedHolder(), "|", holderAliases()); From 1501559d4a9ac8c2f4b6d86732b85d1cf15c8add Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:34:06 -0500 Subject: [PATCH 27/39] Read and write the "undefined" property for element indexes that are undefined An element index the checker types as a number can hold undefined at run time (an element read from a holey array). JavaScript then names the array's "undefined" property, but the index converted with ToNumber, so such reads and writes used the "NaN" property: Node prints `10 0,7,0,7` for a program that sets `values[NaN] = 10` and then writes through two holes, the compiled program printed `7 0,7,0,7`. The runtime's noncanonical property table, which holds the properties named by non-index numbers, now spells one NaN payload (scr_arr_undefined_key) "undefined". At array element reads, writes, compound and update writes, destructuring targets and writes in value position, an index that can be undefined is that key when it is undefined. The property stays a typed entry of the array, so the Int32[] contract checks cover it like any element store. A typed array cannot hold the property: a write through an undefined index throws the catchable SC1090 error where Node adds it. Fixes #1640. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../containers/array-callback-call.ts | 4 +- .../frontend/lowering/expressions/binary.ts | 43 ++++++-- .../lowering/expressions/element-access.ts | 98 ++++++++++++++--- .../frontend/lowering/expressions/updates.ts | 45 ++++++-- .../src/frontend/lowering/lower-stmts.ts | 4 +- .../src/frontend/lowering/undefined-index.ts | 101 ++++++++++++++++++ .../compiler/src/ir/builtin-signatures.ts | 3 + packages/compiler/src/ir/ir.ts | 5 +- .../7540-undefined-element-index-keys.ts.json | 6 ++ packages/runtime/src/scr_array.c | 21 +++- packages/runtime/src/scr_array.h | 10 +- .../7540-undefined-element-index-keys.ts | 53 +++++++++ .../7540-undefined-element-index-keys.ts.json | 1 + tests/harness/errors.test.ts | 20 ++++ 15 files changed, 374 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/undefined-index.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json create mode 100644 tests/corpus/7540-undefined-element-index-keys.ts create mode 100644 tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8e7c74cad..c3d54aac1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1583,6 +1583,7 @@ export const LIB_FN_SYMS: Record = { "regex.resultProp": "scr_regex_result_prop", "array.namedKeys": "scr_arr_named_keys", "array.hasNamedKey": "scr_arr_has_named_key", + "array.undefinedKey": "scr_arr_undefined_key", // queueMicrotask's checked-dynamic form (borrowed dyn; a non-function // throws synchronously), the minted setImmediate value, and the // timers/promises immediate — all mark the loop live. diff --git a/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts index a22a82bb1..b98000fa1 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callback-call.ts @@ -1,10 +1,10 @@ import * as ts from "../../ts7/adapter.js"; +import { lowerOptionalIndex } from "../undefined-index.js"; import { locOf } from "../../program.js"; import { varRef } from "../../../ir/build.js"; import { DYN, type IrExpr, type IrStmt, isUnitType } from "../../../ir/ir.js"; import type { Lowerer } from "../lowerer.js"; import { completeFuncValueArgs } from "../call-arguments.js"; -import { lowerOptionalNumber } from "../lower-exprs.js"; import { lowerSafeIndexRead } from "../array-values.js"; /** Retain native function slots during indexed calls. The array remains @@ -37,7 +37,7 @@ export function lowerArrayCallbackCall( return varRef(local.id, local.type, loc); }; const receiver = store(array, "%callbackArray"); - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(access.argumentExpression), locOf(access.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 9702a6dd9..58f6e16b5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -1,6 +1,11 @@ /* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */ import { keyedHelperName } from "../stable-names.js"; import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { cleanFuncAdaptable } from "../coercions/function-adapters.js"; import { type Lowerer, jsFuncNameOf } from "../lowerer.js"; import { @@ -1160,12 +1165,26 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr } if (recv?.type.kind === "array" || recv?.type.kind === "bytes") { const receiver = lowerer.declareHiddenLocal("%setArray", recv.type); - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(expr.left.argumentExpression), - locOf(expr.left.argumentExpression), - expr.left.argumentExpression, - ); + // An undefined index names the "undefined" property + // (undefined-index.ts), which a typed array cannot hold. + const rawIndex = lowerer.lowerExpr(expr.left.argumentExpression); + const split = + recv.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + rawIndex, + expr.left.argumentExpression, + locOf(expr.left.argumentExpression), + ) + : null; + const index = + split?.index ?? + lowerOptionalIndex( + lowerer, + rawIndex, + locOf(expr.left.argumentExpression), + expr.left.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported( "SC1090", @@ -1178,7 +1197,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr const result = varRef(value.id, raw.type, loc); const arr = varRef(receiver.id, recv.type, loc); const idx = varRef(key.id, F64, loc); - const write: IrStmt = + const store: IrStmt = recv.type.kind === "array" ? arrayValueStore(lowerer, arr, idx, result, recv.type.elem, loc) : { @@ -1188,10 +1207,20 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr value: lowerer.coerceInto(expr.right, result, F64), loc, }; + const write: IrStmt = split + ? { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, expr.left, loc)], + else_: [store], + loc, + } + : store; return { kind: "seqExpr", stmts: [ { kind: "varDecl", localId: receiver.id, init: recv, loc }, + ...(split?.stmts ?? []), { kind: "varDecl", localId: key.id, init: index, loc }, { kind: "varDecl", localId: value.id, init: raw, loc }, write, diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 045bebdf4..16cc8f845 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -1,5 +1,10 @@ /* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */ import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { type Lowerer, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js"; import { BOOL, @@ -774,9 +779,9 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre } } // Array reads can themselves supply an index. A missing numeric value - // is undefined, whose property lookup misses just like NaN; keep that - // value instead of rejecting a checker-number index with optional IR. - const index = lowerOptionalNumber( + // is undefined, which names the array's "undefined" property + // (undefined-index.ts). + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(expr.argumentExpression), locOf(expr.argumentExpression), @@ -1518,12 +1523,26 @@ export function lowerElementCompoundParts( "compound assignment through a non-native array or byte view", ); } - const index = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(target.argumentExpression), - locOf(target.argumentExpression), - target.argumentExpression, - ); + // An undefined index names the "undefined" property (undefined-index.ts), + // which a typed array cannot hold. + const rawIndex = lowerer.lowerExpr(target.argumentExpression); + const split = + receiver.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + rawIndex, + target.argumentExpression, + locOf(target.argumentExpression), + ) + : null; + const index = + split?.index ?? + lowerOptionalIndex( + lowerer, + rawIndex, + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind !== "f64") lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type); @@ -1632,15 +1651,25 @@ export function lowerElementCompoundParts( } const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type); const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc); - const write: IrStmt = + const store: IrStmt = receiver.type.kind === "array" ? arrayValueStore(lowerer, receiverRef(), indexRef(), resultRef(), receiver.type.elem, loc) : { kind: "bytesSet", arr: receiverRef(), index: indexRef(), value: resultRef(), loc }; + const write: IrStmt = split + ? { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, target, loc)], + else_: [store], + loc, + } + : store; return { expr: { kind: "seqExpr", stmts: [ { kind: "varDecl", localId: receiverLocal.id, init: receiver, loc }, + ...(split?.stmts ?? []), { kind: "varDecl", localId: indexLocal.id, init: index, loc }, { kind: "varDecl", localId: oldLocal.id, init: oldValue, loc }, { kind: "varDecl", localId: resultLocal.id, init: computed, loc }, @@ -1867,13 +1896,23 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): // The checker's flow type is a typed array, but the storage is wider. if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv); // An index the checker types as a number but whose storage may hold - // undefined (a hole read) converts with ToNumber, as the reads do. - const index = lowerOptionalNumber( + // undefined (a hole read) converts with ToNumber, as the reads do; an + // undefined index names a property the typed array cannot hold. + const rawIndex = lowerer.lowerExpr(target.argumentExpression); + const split = splitUndefinedIndex( lowerer, - lowerer.lowerExpr(target.argumentExpression), - locOf(target.argumentExpression), + rawIndex, target.argumentExpression, + locOf(target.argumentExpression), ); + const index = + split?.index ?? + lowerOptionalNumber( + lowerer, + rawIndex, + locOf(target.argumentExpression), + target.argumentExpression, + ); if (index.type.kind === "dyn" && recv.type.kind === "bytes") { return { kind: "exprStmt", @@ -1889,7 +1928,34 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys"); } const value = lowerer.lowerExprExpecting(expr.right, F64); - return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; + if (split === null) return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) }; + const loc = locOf(expr); + const receiver = lowerer.declareHiddenLocal("%indexedBytes", recv.type); + const valueLocal = lowerer.declareHiddenLocal("%elementValue", F64); + return { + kind: "block", + body: [ + { kind: "varDecl", localId: receiver.id, init: recv, loc }, + ...split.stmts, + { kind: "varDecl", localId: valueLocal.id, init: value, loc }, + { + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, target, loc)], + else_: [ + { + kind: "bytesSet", + arr: varRef(receiver.id, recv.type, loc), + index, + value: varRef(valueLocal.id, F64, loc), + loc, + }, + ], + loc, + }, + ], + loc, + }; } // Tuple element write `t[0] = v`: a positional-field write (recordSet) // — literal indices only, like the read. @@ -2184,7 +2250,7 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): "assignment through a possibly missing nested array receiver", ); } - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(target.argumentExpression), locOf(target.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 128093dad..04ca23a96 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -1,5 +1,10 @@ /* Increment, decrement and prefix unary operators, and compound field updates. */ import * as ts from "../../ts7/adapter.js"; +import { + lowerOptionalIndex, + splitUndefinedIndex, + undefinedTypedArrayKeyFence, +} from "../undefined-index.js"; import { isStrictCode } from "../strict-mode.js"; import { isPrivateWriteReceiver } from "../private-brands.js"; import type { Lowerer } from "../lowerer.js"; @@ -311,12 +316,27 @@ export function lowerIncDec( (raw.type.kind === "array" || raw.type.kind === "bytes") ) { const receiver = save(raw, "%incrementArray"); - const rawIndex = lowerOptionalNumber( - lowerer, - lowerer.lowerExpr(access.argumentExpression), - locOf(access.argumentExpression), - access.argumentExpression, - ); + // An undefined index names the "undefined" property + // (undefined-index.ts), which a typed array cannot hold. + const loweredIndex = lowerer.lowerExpr(access.argumentExpression); + const split = + raw.type.kind === "bytes" + ? splitUndefinedIndex( + lowerer, + loweredIndex, + access.argumentExpression, + locOf(access.argumentExpression), + ) + : null; + if (split) stmts.push(...split.stmts); + const rawIndex = + split?.index ?? + lowerOptionalIndex( + lowerer, + loweredIndex, + locOf(access.argumentExpression), + access.argumentExpression, + ); const index = save( rawIndex.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr)) ? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc } @@ -340,7 +360,7 @@ export function lowerIncDec( loc, invalidNaN: true, }; - return finish(read, (value) => + const store = (value: IrExpr): IrStmt => type.kind === "array" ? arrayValueStore( lowerer, @@ -356,8 +376,15 @@ export function lowerIncDec( index, value: lowerer.coerceInto(access, value, F64), loc, - }, - ); + }; + if (!split) return finish(read, store); + return finish(read, (value) => ({ + kind: "if", + cond: split.isUndefined, + then: [undefinedTypedArrayKeyFence(lowerer, access, loc)], + else_: [store(value)], + loc, + })); } // A TS member statically typed number on a checked-dynamic receiver (a // class-backed protocol view): the expanded form `o.v = o.v + 1`, its diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 62c2e3a0f..da1bd19ca 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -25,6 +25,7 @@ import { InternalCompilerError } from "../../errors.js"; * flow (if/while/for/for-of/do, switch, try/catch, abrupt completions), * explicit resource management, and blocked-binding poisoning. */ import * as ts from "../ts7/adapter.js"; +import { lowerOptionalIndex } from "./undefined-index.js"; import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; @@ -204,7 +205,6 @@ import { lowerElementCompoundStatement, lowerIncDec, lowerIncDecTarget, - lowerOptionalNumber, lowerGroupsProjection, matchResultNamedGroupsOf, runtimeOptionalGuardIds, @@ -9279,7 +9279,7 @@ function lowerAssignTargetInto( if (arr.type.kind === "array") { const recv = lowerer.declareHiddenLocal("%dtRecv", arr.type); out.push({ kind: "varDecl", localId: recv.id, init: arr, loc }); - const index = lowerOptionalNumber( + const index = lowerOptionalIndex( lowerer, lowerer.lowerExpr(t.argumentExpression), locOf(t.argumentExpression), diff --git a/packages/compiler/src/frontend/lowering/undefined-index.ts b/packages/compiler/src/frontend/lowering/undefined-index.ts new file mode 100644 index 000000000..5226d7008 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/undefined-index.ts @@ -0,0 +1,101 @@ +import * as ts from "../ts7/adapter.js"; +import { BOOL, DYN, F64, UNDEFINED_T, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import type { Lowerer } from "./lowerer.js"; +import { lowerOptionalNumber } from "./lower-exprs.js"; +import { keyedHelperName } from "./stable-names.js"; + +/** An element index the checker types as a number can be undefined at run + * time (an element read from a holey array). JavaScript then names the + * property "undefined", not "NaN". */ + +/** The array element index `operand` (the lowered index of `node`) as a + * number. An undefined index is the runtime's "undefined" key + * (`array.undefinedKey`, a NaN the array's property table spells + * "undefined"); other values convert with ToNumber, as lowerOptionalNumber + * does. */ +export function lowerOptionalIndex( + lowerer: Lowerer, + operand: IrExpr, + loc: SrcLoc, + node: ts.Expression, +): IrExpr { + const split = splitUndefinedIndex(lowerer, operand, node, loc); + if (split === null) return lowerOptionalNumber(lowerer, operand, loc, node); + return { + kind: "seqExpr", + stmts: split.stmts, + result: { + kind: "ternary", + cond: split.isUndefined, + then: { kind: "libCall", fn: "array.undefinedKey", args: [], type: F64, loc }, + else_: split.index, + type: F64, + loc, + }, + type: F64, + loc, + }; +} + +/** A typed-array element index that can be undefined: `stmts` evaluate it + * once, `isUndefined` tests it, and `index` is the number a defined index + * converts to. */ +export interface SplitIndex { + stmts: IrStmt[]; + isUndefined: IrExpr; + index: IrExpr; +} + +/** Splits `raw`, the lowered index of `node`, when it is a number that can + * be undefined; null otherwise. */ +export function splitUndefinedIndex( + lowerer: Lowerer, + raw: IrExpr, + node: ts.Expression, + loc: SrcLoc, +): SplitIndex | null { + if (raw.type.kind !== "union") return null; + const undefinedTag = lowerer.armTag(raw.type.unionId, UNDEFINED_T); + if (undefinedTag < 0) return null; + const local = lowerer.declareHiddenLocal("%elementIndex", raw.type); + const stable = varRef(local.id, raw.type, loc); + const index = lowerOptionalNumber(lowerer, stable, loc, node); + if (index.type.kind !== "f64") return null; + return { + stmts: [{ kind: "varDecl", localId: local.id, init: raw, loc }], + isUndefined: { + kind: "unionIsTag", + unionId: raw.type.unionId, + tag: undefinedTag, + negated: false, + value: stable, + type: BOOL, + loc, + }, + index, + }; +} + +/** A typed array has no property named "undefined" to store into: the + * store throws the catchable SC1090 error when it runs. A call, so that it + * can sit among an expression's statements. */ +export function undefinedTypedArrayKeyFence(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrStmt { + const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(node), loc.start).line + 1; + const message = `typed array properties named 'undefined' (an element index that is undefined) are not supported yet [SC1090 at ${loc.file}:${line}]`; + const key = `index.undefinedFence:${message}`; + let name = lowerer.arrHofHelpers.get(key); + if (name === undefined) { + name = keyedHelperName(lowerer, "%index.undefinedFence", key); + lowerer.arrHofHelpers.set(key, name); + lowerer.liftedFns.push({ + name, + params: [], + locals: [], + returnType: DYN, + body: [{ kind: "runtimeFence", code: "SC1090", message, loc }], + loc, + }); + } + return { kind: "exprStmt", expr: { kind: "call", callee: name, args: [], type: DYN, loc }, loc }; +} diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f5da56818..51a0ec778 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3813,6 +3813,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // Whether the array (args[0]) still has the key (args[1]) from its // namedKeys list. Borrowed; never throws. "array.hasNamedKey": { argTypes: [null, STRING], result: BOOL }, + // The key of an element access whose index is undefined (the property + // named "undefined"; frontend/lowering/undefined-index.ts). + "array.undefinedKey": { argTypes: [], result: F64 }, // node:events EventEmitter: receivers are emitter-hierarchy objects and // the chaining forms (on/off/removeAll/setMax) return the receiver's // own class — program-dependent object types, checked in the libCall diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 0fa873a16..8c3ead512 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -2099,8 +2099,9 @@ export type IrStmt = * and whose `code` carries the SC diagnostic code; unwinds exactly like * `throw`. * TypeScript sources produce it only as the tsc-unreachable fallthrough - * trap (appendImplicitUndefinedReturn's SC9002) — their construct fences - * stay compile errors. */ + * trap (appendImplicitUndefinedReturn's SC9002) and for a typed-array + * store through an element index that is undefined at run time + * (undefined-index.ts); their construct fences stay compile errors. */ | { kind: "runtimeFence"; code: string; message: string; loc: SrcLoc } /** Rethrow of a catch binding (`throw e` where e is the binding): * re-raises the SAVED exception exactly — kind and payload preserved, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json new file mode 100644 index 000000000..528758c4e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7540-undefined-element-index-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7540-undefined-element-index-keys.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index bbe66e26e..f4092e75b 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -176,7 +176,22 @@ static size_t scr_arr_prop_index(const ScrArr *a, const char *key, return SIZE_MAX; } +#define SCR_ARR_UNDEFINED_KEY_BITS UINT64_C(0x7ff8000000756e64) + +double scr_arr_undefined_key(void) { + uint64_t bits = SCR_ARR_UNDEFINED_KEY_BITS; + double key; + memcpy(&key, &bits, sizeof key); + return key; +} + static size_t scr_arr_prop_key(double i, char *buf) { + uint64_t bits; + memcpy(&bits, &i, sizeof bits); + if (bits == SCR_ARR_UNDEFINED_KEY_BITS) { + memcpy(buf, "undefined", sizeof "undefined"); + return sizeof "undefined" - 1; + } return scr_f64_to_str(i, buf); } @@ -2012,7 +2027,7 @@ static uint64_t scr_arr_require_slot(ScrArr *a, double i) { if (scr_arr_prop_get_state(a, i, &prop, &state)) { if (state == SCR_ARR_UNDEFINED) { char buf[32]; - scr_f64_to_str(i, buf); + scr_arr_prop_key(i, buf); scr_trap_fmt("scriptc: RangeError: numeric property %s is undefined\n", buf); } return prop; @@ -5121,6 +5136,10 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) { bool scr_arr_numeric_key(const char *key, size_t len, double *out) { char text[64]; + if (len == sizeof "undefined" - 1 && memcmp(key, "undefined", len) == 0) { + *out = scr_arr_undefined_key(); + return true; + } if (len == 0 || len >= sizeof text) return false; memcpy(text, key, len); text[len] = '\0'; diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 6034462e9..1750e8509 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -457,9 +457,15 @@ void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target); * properties of `target`, so neither is lost when the storage is swapped. */ void scr_arr_adopt_view_properties(ScrArr *a, struct ScrDyn *view, ScrArr *target); /* True when `key` is the string spelling of a number that is not an array - * index ("-1", "1.5", "NaN", "Infinity"), with the number in *out. Arrays - * keep such properties in their noncanonical property table. */ + * index ("-1", "1.5", "NaN", "Infinity"), or "undefined", with the number in + * *out (scr_arr_undefined_key for "undefined"). Arrays keep such properties + * in their noncanonical property table. */ bool scr_arr_numeric_key(const char *key, size_t len, double *out); +/* The number compiled code passes as the key of an element access whose + * index is undefined at run time: a NaN with its own payload, which the + * property table spells "undefined" (JavaScript names that property, not + * "NaN"). */ +double scr_arr_undefined_key(void); /* Whether the array still has the named or noncanonical numeric property * `key`: `for...in` skips a key deleted before its turn. Borrows both. */ bool scr_arr_has_named_key(ScrArr *a, ScrStr *key); diff --git a/tests/corpus/7540-undefined-element-index-keys.ts b/tests/corpus/7540-undefined-element-index-keys.ts new file mode 100644 index 000000000..8da1b591b --- /dev/null +++ b/tests/corpus/7540-undefined-element-index-keys.ts @@ -0,0 +1,53 @@ +// An element index the checker types as a number can be undefined at run +// time (an element read from a holey array). JavaScript then names the +// array's "undefined" property, not "NaN": plain, compound, update and +// destructuring writes store it, a write used as a value yields the value, +// and reads (number, string, record and array elements) read it back. +const holey: number[] = [1]; +holey[3] = 3; + +const values: number[] = [0, 0, 0, 0]; +values[NaN] = 10; +for (const i of holey) values[i] = 7; +console.log(values[NaN], values.join(",")); +let total = 0; +for (const i of holey) total += values[i]!; +console.log(total); + +const counts: number[] = [0, 0, 0, 0]; +counts[NaN] = 10; +let trace = ""; +for (const i of holey) trace += counts[i]++ + ","; +for (const i of holey) counts[i] += 2; +for (const i of holey) trace += --counts[i] + ";"; +console.log(counts[NaN], counts.join(","), trace); + +let last = 0; +for (const i of holey) last = values[i] = 5; +for (const i of holey) [values[i]] = [8]; +console.log(last, values[NaN], values.join(",")); + +const names: string[] = ["a", "b", "c", "d"]; +for (const i of holey) names[i] += "!"; +const seen: (string | undefined)[] = []; +for (const i of holey) seen.push(names[i]); +console.log(names.join("|"), seen.join("|")); + +interface Point { + x: number; +} +const points: Point[] = [{ x: 0 }, { x: 1 }]; +const shared: Point = { x: 9 }; +let same = ""; +for (const i of holey) { + points[i] = shared; + same += String(points[i] === shared) + " "; +} +shared.x = 10; +for (const i of holey) same += String(points[i]?.x) + " "; +console.log(same); + +const fresh: number[] = [1, 2]; +let missing = ""; +for (const i of holey) missing += String(fresh[i]) + " "; +console.log(missing); diff --git a/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json b/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json new file mode 100644 index 000000000..1da1b737f --- /dev/null +++ b/tests/harness/corpus-costs/7540-undefined-element-index-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 3700, "san": 5300 } diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index c8b0b51b8..566a28e96 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -305,6 +305,26 @@ describe("checked catch-binding casts", () => { ); }); + test("a typed-array write through an undefined index is a named runtime fence", async () => { + // Node stores an ordinary property named "undefined" on the typed + // array, which a compiled typed array cannot hold; defined indexes + // before it store as usual. + const r = await compileAndRun( + "typed-array-undefined-index", + `const holey: number[] = [1]; +holey[3] = 3; +const cells = new Float64Array(4); +for (const i of holey) cells[i] = 9; +console.log(cells.join(",")); +`, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch( + /^Uncaught Error: typed array properties named 'undefined' \(an element index that is undefined\) are not supported yet \[SC1090 at .*typed-array-undefined-index\.ts:4\]\n$/, + ); + }); + test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => { // Effect-free surplus args drop at compile time, JS-exact (corpus // 1760 — the test-string-decoder writeSequences shape). An effectful From fe406429eb9ee980bc926bab6acff61065e967cd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:37:12 -0500 Subject: [PATCH 28/39] Keep int32 slots for long number arrays with checked integer stores A local number[] candidate whose stores are exact integers but not all proven int32s (a distance array updated with dist[v] = dist[u]! + 1), and that is only ever allocated by new Array(n).fill(v), now holds SCR_ELEM_I32 slots when n is at least 4096. A shorter fill creates doubles, which fit a typical L1 data cache. The first store of a value that is not an int32 widens the array to doubles in place (scr_arr_widen_i32). An innermost loop that accesses such arrays tests their kinds once on entry (backend/llvm/int32-kind-loops.ts). Its fast copy reads and writes them as int32 arrays; a store that would widen one leaves for a copy that re-executes it with kind tests and finishes the loop on doubles. When the arrays already hold doubles, a copy accesses them as double arrays. Other accesses test the kind each time. Only these arrays take kind tests: every other array, and every program without one, emits the same code as before. --- .../src/backend/llvm/dense-array-access.ts | 9 +- .../src/backend/llvm/emit-function.ts | 2 + .../src/backend/llvm/emit-statements.ts | 186 ++++++++++++- packages/compiler/src/backend/llvm/emitter.ts | 246 ++++++++++++++++-- .../src/backend/llvm/expr-containers.ts | 47 +++- .../src/backend/llvm/expr-primitives.ts | 86 ++++-- .../src/backend/llvm/int32-kind-loops.ts | 135 ++++++++++ .../src/backend/llvm/local-array-reads.ts | 32 ++- .../src/backend/llvm/speculative-integers.ts | 4 +- packages/compiler/src/ir/int32-arrays.ts | 66 ++++- packages/compiler/src/ir/int32-slots.ts | 25 +- packages/compiler/src/ir/ir.ts | 3 +- .../test/int32-element-array-emission.test.ts | 74 +++++- .../corpus/7327-int32-array-widening.ts.json | 6 + packages/runtime/src/scr_array.c | 30 +++ packages/runtime/src/scr_array.h | 3 + packages/runtime/test/test_array.c | 38 +++ tests/corpus/7327-int32-array-widening.ts | 186 +++++++++++++ .../7327-int32-array-widening.ts.json | 1 + 19 files changed, 1108 insertions(+), 71 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/int32-kind-loops.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7327-int32-array-widening.ts.json create mode 100644 tests/corpus/7327-int32-array-widening.ts create mode 100644 tests/harness/corpus-costs/7327-int32-array-widening.ts.json diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 2663c47a0..95612e57c 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -542,7 +542,9 @@ export function emitDenseArrayState( * value's reference. A dense index stores the slot, marks it present, and * extends the length when it lands at or past it; a replaced present * reference is released after the new edge is published (a release can - * collect cycles). Every other index takes the unchanged runtime call. */ + * collect cycles). Every other index takes the unchanged runtime call. + * `int32`, for an int32 array, is the value as an i32 when the caller has + * it at hand. */ export function emitDenseArraySet( host: LlvmEmitterContext, array: string, @@ -552,6 +554,7 @@ export function emitDenseArraySet( element: IrType, value: string, proven = false, + int32: string | null = null, ): void { const B = host.B; const ty = valueType(acc); @@ -566,8 +569,8 @@ export function emitDenseArraySet( } // An int32 array's stores are proven int32s (ir/int32-arrays.ts); both // dense paths below store the converted value. - let int32Value: string | null = null; - if (acc === "i32") { + let int32Value: string | null = int32; + if (acc === "i32" && int32Value === null) { int32Value = B.tmp(); B.line(`${int32Value} = fptosi double ${value} to i32`); } diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 2484bc38d..b6df7bcca 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -139,6 +139,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.speculativeFunction = fn.name; emitter.speculation = null; emitter.speculationResume = null; + emitter.int32Kinds = null; + emitter.int32KindResume = null; emitter.finalFieldBorrows = !fn.async && !fn.generator && diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index c4ff602bf..c2846a9d4 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -34,9 +34,16 @@ import { dynHoleRead } from "./loop-array-borrows.js"; import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js"; import { charAtUnitRead, emitDiscardedSplice } from "./expr-containers.js"; import { InternalCompilerError } from "../../errors.js"; -import { emitDenseArrayGet, emitDenseArrayPop, emitDenseArraySet } from "./dense-array-access.js"; +import { + emitDenseArrayGet, + emitDenseArrayPop, + emitDenseArraySet, + SCR_ELEM_I32, +} from "./dense-array-access.js"; import { emitBorrowedInput } from "./borrowed-inputs.js"; -import { f64Lit, llvmCommentText, loadArrCount, sizeToDouble } from "./common.js"; +import { f64Lit, llvmCommentText, loadArrCount, SCR_ARR_FIELD, sizeToDouble } from "./common.js"; +import { planInt32KindLoop, type Int32KindLoopPlan } from "./int32-kind-loops.js"; +import { withinInt32 } from "../../ir/integer-ranges.js"; import { resultSlotStore } from "../result-slots.js"; import { matchIntegerArrayForLoop, @@ -411,7 +418,21 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const v = emitter.emitExpr(s.value); if (s.arr.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arraySet on non-array"); + if (emitter.checkedInt32Array(s.arr)) { + // An int32 array with checked stores: the int32 arm stores an int32 + // value, and widens the array for any other first. + emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); + const elem = s.arr.type.elem; + const proven = emitter.provenDenseIndex(s.arr, s.index); + emitter.emitByElementKind(arr.name, null, (acc, widened) => { + if (acc === "i32") emitter.emitCheckedInt32Store(arr.name, v.name, s.value, widened); + emitDenseArraySet(emitter, arr.name, idx, s.index, acc, elem, v.name, proven); + return null; + }); + break; + } const acc = emitter.arrayAccess(s.arr, s.arr.type.elem); + const int32 = emitter.emitInt32KindStoreCheck(s.arr, v, s.value); emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name); if (acc === "ref") emitter.toHeap(v); emitDenseArraySet( @@ -423,6 +444,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { s.arr.type.elem, v.name, emitter.provenDenseIndex(s.arr, s.index), + int32, ); break; } @@ -478,12 +500,19 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.condBr(dropsLast, pop, call); B.startBlock(pop); const elem = s.arr.type.elem; - const acc = emitter.arrayAccess(s.arr, elem); - const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call); - if (acc === "ref" && isRefCounted(elem)) { - const old = B.tmp(); - B.line(`${old} = inttoptr i64 ${raw} to ptr`); - emitter.releaseHeap(old, elem); + if (emitter.checkedInt32Array(s.arr)) { + emitter.emitByElementKind(arr.name, null, (acc) => { + emitDenseArrayPop(emitter, arr.name, acc, elem, call, call); + return null; + }); + } else { + const acc = emitter.arrayAccess(s.arr, elem); + const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call); + if (acc === "ref" && isRefCounted(elem)) { + const old = B.tmp(); + B.line(`${old} = inttoptr i64 ${raw} to ptr`); + emitter.releaseHeap(old, elem); + } } B.br(done); B.startBlock(call); @@ -2098,9 +2127,26 @@ export function emitLoopCopies( ) : null; if (!plan) { - const proven = allowed ? planProvenLoop(emitter, loop) : null; - if (proven) emitProvenLoop(emitter, proven, exit, emitCopy); - else emitCopy(exit); + const ordinary = (exit: string): void => { + const proven = allowed ? planProvenLoop(emitter, loop) : null; + if (proven) emitProvenLoop(emitter, proven, exit, emitCopy); + else emitCopy(exit); + }; + const kinds = + allowed && + emitter.speculativeLoopsEnabled && + emitter.int32Slots.checkedInt32Arrays.size > 0 && + emitter.int32Kinds === null && + emitter.speculation === null && + emitter.speculationResume === null + ? planInt32KindLoop( + loop, + (id) => emitter.checkedInt32Local(id), + (e) => withinInt32(emitter.integerRanges.get(e)), + ) + : null; + if (kinds) emitInt32KindLoop(emitter, kinds, exit, kinds.writes.size > 0 ? emitCopy : ordinary); + else ordinary(exit); return; } const B = emitter.B; @@ -2243,6 +2289,124 @@ export function emitLoopCopies( } } +/** An int32 kind loop (int32-kind-loops.ts). The entry test reads the kind + * of each of the plan's arrays: when every one holds int32 slots the fast + * copy runs, when every one holds doubles the wide copy, which reads and + * writes them as double arrays, and otherwise (a plan with several arrays) + * the mixed copy, which tests their kinds at each access. A failed store of + * the fast copy re-executes in the third copy, which finishes its + * iteration with kind tests and then enters the mixed copy, or the wide + * copy when the plan has one array (it just widened). Each copy is emitted + * by `ordinary`: the loop's plain copy when a store may fail, since a + * proven integer loop keeps locals in shadows of its own copy that another + * copy cannot resume from. */ +function emitInt32KindLoop( + emitter: LlEmitter, + plan: Int32KindLoopPlan, + exit: string, + ordinary: (exit: string) => void, +): void { + const B = emitter.B; + const fn = emitter.currentFunctionName; + const keys = new Set([...plan.arrays.keys()].map((id) => `${fn}\0${id}`)); + const several = keys.size > 1; + let allInt32 = "true", + allWide = "true"; + const and = (all: string, next: string): string => { + if (all === "true") return next; + const both = B.tmp(); + B.line(`${both} = and i1 ${all}, ${next}`); + return both; + }; + for (const ref of plan.arrays.values()) { + const arr = emitter.emitStableReceiver(ref, []); + const kindPtr = B.tmp(), + kind = B.tmp(), + isInt32 = B.tmp(); + B.line( + `${kindPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 ${SCR_ARR_FIELD.elem}`, + ); + emitter.markMemoryPointer(kindPtr, "array:header"); + B.line(`${kind} = load i8, ptr ${kindPtr}${emitter.fieldAliasAttachment(kindPtr)}`); + B.line(`${isInt32} = icmp eq i8 ${kind}, ${SCR_ELEM_I32}`); + allInt32 = and(allInt32, isInt32); + if (several) { + const isWide = B.tmp(); + B.line(`${isWide} = xor i1 ${isInt32}, true`); + allWide = and(allWide, isWide); + } + } + const fast = B.newLabel("kind.fast"), + wide = B.newLabel("kind.wide"), + mixed = several ? B.newLabel("kind.mixed") : null; + if (mixed === null) B.condBr(allInt32, fast, wide); + else { + const other = B.newLabel("kind.other"); + B.condBr(allInt32, fast, other); + B.startBlock(other); + B.condBr(allWide, wide, mixed); + } + const resume = new Map(); + for (const s of plan.writes) resume.set(s, { label: B.newLabel("kind.resume"), placed: false }); + // The header the third copy enters: the mixed copy's, or the wide copy's. + let header: string | null = null; + const capture = (label: string): void => { + header = label; + }; + B.startBlock(wide); + if (resume.size > 0 && mixed === null) emitter.speculationHeader = capture; + emitter.int32Kinds = { int32: new Set(), wide: keys, resume: new Map() }; + try { + ordinary(exit); + } finally { + emitter.speculationHeader = null; + emitter.int32Kinds = null; + } + if (mixed !== null) { + B.startBlock(mixed); + if (resume.size > 0) emitter.speculationHeader = capture; + try { + ordinary(exit); + } finally { + emitter.speculationHeader = null; + } + } + if (resume.size > 0) { + if (header === null) + throw new InternalCompilerError("llvm emitter bug: int32 kind loop copy without a header"); + // A failed store finishes its iteration here, as in a speculative + // integer loop's third copy. + B.startBlock(B.newLabel("kind.tail")); + emitter.speculationResume = resume; + emitter.speculationHeader = () => { + B.br(header!); + B.startBlock(B.newLabel("kind.unused")); + }; + try { + ordinary(exit); + } finally { + emitter.speculationResume = null; + emitter.speculationHeader = null; + } + } + B.startBlock(fast); + if (![...resume.values()].every((r) => r.placed)) { + // A write the third copy never reached: this copy tests the kinds. + ordinary(exit); + return; + } + emitter.int32Kinds = { + int32: keys, + wide: new Set(), + resume: new Map([...resume].map(([s, r]) => [s, r.label])), + }; + try { + ordinary(exit); + } finally { + emitter.int32Kinds = null; + } +} + /** At the top of a loop copy's header: lets a speculative loop find its * ordinary copy's header, and keeps its third copy out of the loop. */ function enterLoopHeader(emitter: LlEmitter, header: string): void { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 6dc35af7f..efe6097df 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -79,9 +79,18 @@ import { } from "./emit-journal.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { versionLoopBoundParameters } from "../../ir/parameter-versions.js"; -import { allocatedLiteral, selectInt32Arrays } from "../../ir/int32-arrays.js"; +import { + allocatedLiteral, + fillAllocationLength, + selectInt32Arrays, +} from "../../ir/int32-arrays.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; -import { INT32_RANGE, type IntegerRange, type IntegerRanges } from "../../ir/integer-ranges.js"; +import { + INT32_RANGE, + withinInt32, + type IntegerRange, + type IntegerRanges, +} from "../../ir/integer-ranges.js"; import { unshareNodes } from "../../ir/unshare.js"; import { copyStableCaptures } from "../../ir/capture-values.js"; import { resolveSelfCaptures } from "../../ir/self-captures.js"; @@ -101,7 +110,7 @@ import { } from "./int32-abi.js"; import { type Int32Local } from "./int32-locals.js"; import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js"; -import { exactInteger, narrowInteger } from "./integer-values.js"; +import { exactInteger, narrowInteger, widenInteger } from "./integer-values.js"; import { findInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; import { @@ -148,7 +157,12 @@ import { } from "./inline-unions.js"; import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js"; import { CheckedNarrows } from "./checked-narrows.js"; -import { emitDenseArraySet, emitDenseSlotUnlink, type ArrayAccess } from "./dense-array-access.js"; +import { + emitDenseArraySet, + emitDenseSlotUnlink, + SCR_ELEM_I32, + type ArrayAccess, +} from "./dense-array-access.js"; import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js"; import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; @@ -169,7 +183,7 @@ import { } from "./string-slices.js"; import { LazyCaptures } from "./lazy-captures.js"; import { StackCaptures } from "./stack-captures.js"; -import { emitFieldAbsentTest, f64Lit } from "./common.js"; +import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js"; import { ffiExtendsNarrowIntegers } from "../targets.js"; import { emitLiteralExpr, @@ -484,13 +498,14 @@ function versionParameters(mod: IrModule): IrModule { return versionLoopBoundParameters(mod, mangleFunction); } -/** The int32 arrays the int32 slot analysis keeps in `mod`, under the - * inputs LlEmitter gives it (selectInt32Arrays). */ +/** The int32 arrays the int32 slot analysis keeps in `mod` (proven and + * checked), under the inputs LlEmitter gives it (selectInt32Arrays). */ function provenInt32Arrays(mod: IrModule): ReadonlySet { - return analyzeInt32Slots(mod, { + const slots = analyzeInt32Slots(mod, { dynamicStores: dynamicFieldStores(mod), initializerBindings: findInitializerBindings(mod), - }).int32Arrays; + }); + return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]); } /** The emitter for `input` after the module passes every emission runs. */ @@ -738,25 +753,42 @@ export class LlEmitter { /** The fill temporaries (`new Array(n).fill(v)`) of those allocations: * the same arrays, so int32 too. */ int32ArrayTemps = new Set(); + /** The same for the current function's int32 arrays with checked stores, + * which are only allocated by fills (ir/int32-arrays.ts + * checkableInt32Allocations): their temporaries' literals, with the fill's + * length (a literal constructs SCR_ELEM_I32 storage when that length is at + * least CHECKED_INT32_MIN_LENGTH, SCR_ELEM_F64 otherwise), and their + * temporaries. */ + checkedAllocations = new Map(); + checkedArrayTemps = new Set(); + /** The fill calls (`.fill(v)`) of those allocations: a value that is not + * an int32 widens the new array first. */ + checkedFills = new Set(); /** Collect the int32 allocations of `fn`, about to be emitted. */ findInt32Allocations(fn: IrFunction): void { this.int32Allocations.clear(); this.int32ArrayTemps.clear(); - if (this.int32Slots.int32Arrays.size === 0) return; + this.checkedAllocations.clear(); + this.checkedArrayTemps.clear(); + this.checkedFills.clear(); + const { int32Arrays, checkedInt32Arrays } = this.int32Slots; + if (int32Arrays.size === 0 && checkedInt32Arrays.size === 0) return; everyStmtList(fn.body, { stmt: (s) => { - if ( - (s.kind === "varDecl" || s.kind === "assign") && - this.int32Slots.int32Arrays.has(`${fn.name}\0${s.localId}`) - ) { - const value = s.kind === "varDecl" ? s.init : s.value; - const allocation = value && allocatedLiteral(value); - if (allocation) { - this.int32Allocations.add(allocation.literal); - if (allocation.temporary !== null) this.int32ArrayTemps.add(allocation.temporary); - } - } + if (s.kind !== "varDecl" && s.kind !== "assign") return true; + const key = `${fn.name}\0${s.localId}`; + const checked = checkedInt32Arrays.has(key); + if (!checked && !int32Arrays.has(key)) return true; + const value = s.kind === "varDecl" ? s.init : s.value; + const allocation = value && allocatedLiteral(value); + if (!allocation) return true; + const length = checked ? fillAllocationLength(value) : null; + if (length !== null) this.checkedAllocations.set(allocation.literal, length); + else if (!checked) this.int32Allocations.add(allocation.literal); + if (allocation.temporary !== null) + (checked ? this.checkedArrayTemps : this.int32ArrayTemps).add(allocation.temporary); + if (checked && value !== allocation.literal) this.checkedFills.add(value!); return true; }, expr: () => true, @@ -766,16 +798,174 @@ export class LlEmitter { /** Whether `arr` is one of the current function's int32 arrays * (ir/int32-arrays.ts, kept by the int32 slot analysis). */ int32Array(arr: IrExpr): boolean { - if (arr.kind !== "varRef" || this.int32Slots.int32Arrays.size === 0) return false; + if (arr.kind !== "varRef") return false; + if (this.int32KindArray(arr)) return true; + if (this.int32Slots.int32Arrays.size === 0) return false; return ( this.int32ArrayTemps.has(arr.localId) || this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`) ); } + /** Whether `arr` is one of the current function's int32 arrays with + * checked stores: int32 slots until a store widens it to doubles. Every + * access tests its kind (emitByElementKind), except in the fast copy of + * an int32 kind loop, which reads and writes it as an int32 array. */ + checkedInt32Array(arr: IrExpr): boolean { + if (arr.kind !== "varRef" || this.int32KindArray(arr)) return false; + if (this.int32Kinds?.wide.has(`${this.currentFunctionName}\0${arr.localId}`)) return false; + return this.checkedInt32Local(arr.localId); + } + + /** Whether the local `localId` holds one of the current function's int32 + * arrays with checked stores. */ + checkedInt32Local(localId: string): boolean { + if (this.int32Slots.checkedInt32Arrays.size === 0) return false; + return ( + this.checkedArrayTemps.has(localId) || + this.int32Slots.checkedInt32Arrays.has(`${this.currentFunctionName}\0${localId}`) + ); + } + + /** Whether `arr` is an int32 array with checked stores that the fast copy + * of the int32 kind loop being emitted holds as an int32 array. */ + int32KindArray(arr: IrExpr): boolean { + return ( + this.int32Kinds !== null && + arr.kind === "varRef" && + this.int32Kinds.int32.has(`${this.currentFunctionName}\0${arr.localId}`) + ); + } + + /** Before a store of `value` (of `expr`) into `arr`, in the fast copy of + * an int32 kind loop: continue when it is an int32 that is not -0, and + * otherwise leave for the statement's resume point, where it re-executes + * with kind tests. Returns the value as an i32 when it is at hand as an + * integer, or null when the store converts the double itself. */ + emitInt32KindStoreCheck(arr: IrExpr, value: LlValue, expr: IrExpr): string | null { + if (!this.int32KindArray(arr) || withinInt32(this.integerRanges.get(expr))) return null; + const integer = exactInteger(this, value, expr); + if (integer !== null && withinInt32(integer.range)) return narrowInteger(this, integer); + const resume = this.int32KindResume; + if (resume === null) + throw new InternalCompilerError("llvm emitter bug: an int32 kind store without a resume"); + const B = this.B; + const ok = B.tmp(), + int = B.tmp(); + if (integer !== null) { + // An exact integer: a range test on its 64-bit form. + const wide = widenInteger(this, integer), + low = B.tmp(), + high = B.tmp(); + B.line(`${low} = icmp sge i64 ${wide}, -2147483648`); + B.line(`${high} = icmp sle i64 ${wide}, 2147483647`); + B.line(`${ok} = and i1 ${low}, ${high}`); + const store = B.newLabel("arr.store.i32"); + B.condBr(ok, store, resume); + B.startBlock(store); + B.line(`${int} = trunc i64 ${wide} to i32`); + return int; + } + this.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)"); + const back = B.tmp(), + exact = B.tmp(), + bits = B.tmp(), + notNegativeZero = B.tmp(); + B.line(`${int} = call i32 @llvm.fptosi.sat.i32.f64(double ${value.name})`); + B.line(`${back} = sitofp i32 ${int} to double`); + B.line(`${exact} = fcmp oeq double ${back}, ${value.name}`); + B.line(`${bits} = bitcast double ${value.name} to i64`); + B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`); + B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`); + const store = B.newLabel("arr.store.i32"); + B.condBr(ok, store, resume); + B.startBlock(store); + return int; + } + + /** Emit an access to the checked int32 array `array` once per element + * kind: `arm("i32", widened)` while it holds int32 slots and + * `arm("f64", widened)` after it widened, joined after both. `widened` is + * the label of the double arm, where an int32 arm's store of a value that + * is not an int32 continues after widening the array. Returns the phi of + * the arms' results (of LLVM type `ty`), or null when `ty` is null. */ + emitByElementKind( + array: string, + ty: string | null, + arm: (acc: "i32" | "f64", widened: string) => string | null, + ): string | null { + const B = this.B; + const narrow = B.newLabel("arr.kind.i32"), + wide = B.newLabel("arr.kind.f64"), + join = B.newLabel("arr.kind.join"); + const kindPtr = B.tmp(), + kind = B.tmp(), + isInt32 = B.tmp(); + B.line( + `${kindPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.elem}`, + ); + this.markMemoryPointer(kindPtr, "array:header"); + B.line(`${kind} = load i8, ptr ${kindPtr}${this.fieldAliasAttachment(kindPtr)}`); + B.line(`${isInt32} = icmp eq i8 ${kind}, ${SCR_ELEM_I32}`); + B.condBr(isInt32, narrow, wide); + B.startBlock(narrow); + const narrowValue = arm("i32", wide); + const narrowEnd = B.isTerminated() ? null : B.currentLabel(); + if (narrowEnd !== null) B.br(join); + B.startBlock(wide); + const wideValue = arm("f64", wide); + const wideEnd = B.isTerminated() ? null : B.currentLabel(); + if (wideEnd !== null) B.br(join); + B.startBlock(join); + // Arms that end in their own branches (a read's shared join) leave this + // join without predecessors. + if (narrowEnd === null && wideEnd === null) B.terminate("unreachable"); + if (ty === null) return null; + const arms: string[] = []; + if (narrowEnd !== null) arms.push(`[ ${narrowValue}, %${narrowEnd} ]`); + if (wideEnd !== null) arms.push(`[ ${wideValue}, %${wideEnd} ]`); + const result = B.tmp(); + B.line(`${result} = phi ${ty} ${arms.join(", ")}`); + return result; + } + + /** Before storing the double `value` (of `expr`) into an int32 arm + * (emitByElementKind) of the checked int32 array `array`: continue in this + * arm when it is an int32 that is not -0, or widen the array and continue + * at `widened`. A value proven an int32 needs no test. */ + emitCheckedInt32Store(array: string, value: string, expr: IrExpr, widened: string): void { + if (withinInt32(this.integerRanges.get(expr))) return; + const B = this.B; + this.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)"); + this.declare("declare void @scr_arr_widen_i32(ptr)"); + const int = B.tmp(), + back = B.tmp(), + exact = B.tmp(), + bits = B.tmp(), + notNegativeZero = B.tmp(), + ok = B.tmp(); + B.line(`${int} = call i32 @llvm.fptosi.sat.i32.f64(double ${value})`); + B.line(`${back} = sitofp i32 ${int} to double`); + B.line(`${exact} = fcmp oeq double ${back}, ${value}`); + B.line(`${bits} = bitcast double ${value} to i64`); + B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`); + B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`); + const store = B.newLabel("arr.store.i32"), + widen = B.newLabel("arr.widen"); + B.condBr(ok, store, widen); + B.startBlock(widen); + B.line(`call void @scr_arr_widen_i32(ptr ${array})`); + B.br(widened); + B.startBlock(store); + } + /** The access an element operation on `arr` (of element type `elem`) * takes: int32 arrays read and write their 4-byte slots. */ arrayAccess(arr: IrExpr, elem: IrType): ArrayAccess { + if (this.checkedInt32Array(arr)) + throw new InternalCompilerError( + "llvm emitter bug: a checked int32 array without its kind test", + ); return this.int32Array(arr) ? "i32" : elemAccess(elem); } @@ -1225,6 +1415,14 @@ export class LlEmitter { * (`enterLoopHeader`): records the ordinary copy's header, or sends the * third copy's header there. */ speculationHeader: ((header: string) => void) | null = null; + /** A copy of an int32 kind loop (int32-kind-loops.ts) being emitted: the + * int32 arrays with checked stores it reads and writes as int32 arrays + * (the fast copy) or as double arrays (an ordinary copy entered only + * after they widened), as `function\0local` keys, and where each of the + * fast copy's writes resumes in the third copy. */ + int32Kinds: { int32: Set; wide: Set; resume: Map } | null = null; + /** The resume label of the fast-copy write being emitted. */ + int32KindResume: string | null = null; /** The function being emitted, for speculative globals. */ speculativeFunction = ""; /** Every function that writes each number global (computed on demand). */ @@ -5317,10 +5515,14 @@ export class LlEmitter { const loop = s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf"; if (loop) this.loopDepth++; + // An int32 kind loop's fast-copy write leaves for its resume point. + const kindResume = this.int32KindResume; + this.int32KindResume = this.int32Kinds?.resume.get(s) ?? null; try { this.emitStmtBody(s); } finally { this.B.debugLocation = previous; + this.int32KindResume = kindResume; if (loop) this.loopDepth--; } } diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 8c4279846..1ba5da818 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -615,9 +615,12 @@ export function emitArrIntrinsic( throw new InternalCompilerError("llvm emitter bug: arrIntrinsic on non-array"); const elem = e.receiver.type.elem; // Int32 arrays (ir/int32-arrays.ts) reach only these methods; the others - // take the runtime family's flat access. - const access = host.arrayAccess(e.receiver, elem); - if (access === "i32" && !INT32_ARRAY_METHODS.has(e.method)) + // take the runtime family's flat access. One with checked stores tests + // its kind at each inline access (emitByElementKind) and otherwise takes + // the double entry points, which serve both kinds. + const checked = host.checkedInt32Array(e.receiver); + const access = checked ? "f64" : host.arrayAccess(e.receiver, elem); + if ((access === "i32" || checked) && !INT32_ARRAY_METHODS.has(e.method)) throw new InternalCompilerError(`llvm emitter bug: ${e.method} on an int32 array`); const acc = runtimeAccess(access); const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr"; @@ -640,6 +643,13 @@ export function emitArrIntrinsic( e.receiver.kind === "varRef" ? host.constantNumericTables.get(e.receiver.localId) : undefined; + if (!table && elem.kind === "f64" && checked) { + const proven = host.provenDenseIndex(e.receiver, e.args[0]!); + const value = host.emitByElementKind(r.name, "double", (kind) => + emitDenseArrayNumber(host, r.name, index, e.args[0], proven, kind), + )!; + return { name: value, type: e.type }; + } if (!table && elem.kind === "f64") return { name: emitDenseArrayNumber( @@ -685,6 +695,21 @@ export function emitArrIntrinsic( // no-op zero-argument call. const vs = e.args.map((a) => host.emitExpr(a)); host.emitPublishedArrayGuard(r.name, e.receiver.type, 1, f64Lit(0), f64Lit(vs.length)); + if (checked && vs.length > 0) { + // Each value appends in its own kind test: one that is not an int32 + // widens the array before it is pushed. + let length = ""; + vs.forEach((v, i) => { + length = host.emitByElementKind(r.name, "double", (kind, widened) => { + if (kind === "i32") host.emitCheckedInt32Store(r.name, v.name, e.args[i]!, widened); + return arrPush(host, r.name, kind, v.name, elem); + })!; + }); + return { name: length, type: e.type }; + } + // In an int32 kind loop's fast copy, every value is checked before + // the first appends. + vs.forEach((v, i) => host.emitInt32KindStoreCheck(e.receiver, v, e.args[i]!)); return { name: emitArrayValues(host, r.name, access, elem, vs), type: e.type }; } case "pushSpread": { @@ -759,7 +784,13 @@ export function emitArrIntrinsic( let slowLabel = ""; if (method === "pop") { slowLabel = B.newLabel("remove.slow"); - denseRaw = emitDenseArrayPop(host, r.name, access, elem, la, slowLabel); + const empty = la, + slow = slowLabel; + denseRaw = checked + ? host.emitByElementKind(r.name, "i64", (kind) => + emitDenseArrayPop(host, r.name, kind, elem, empty, slow), + )! + : emitDenseArrayPop(host, r.name, access, elem, la, slowLabel); denseLabel = B.currentLabel(); B.br(lp); B.startBlock(slowLabel); @@ -948,6 +979,14 @@ export function emitArrIntrinsic( case "fillUndefined": { const args = e.args.map((arg) => host.emitExpr(arg)); host.emitPublishedArrayGuard(r.name, e.receiver.type, 7); + if (host.checkedFills.has(e)) { + // A new int32 array with checked stores, filled with a value that + // may not be an int32: widen it first then. + const filled = B.newLabel("arr.fill.kind"); + host.emitCheckedInt32Store(r.name, args[0]!.name, e.args[0]!, filled); + B.br(filled); + B.startBlock(filled); + } const helper = e.method === "copyWithin" ? "scr_arr_copy_within" diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index f95b3adc2..1eec6a965 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -8,6 +8,7 @@ import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js"; import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js"; +import { CHECKED_INT32_MIN_LENGTH } from "../../ir/int32-arrays.js"; import { emitStringInputs } from "./string-lifetimes.js"; import { emitStringParts, numberPart, stringParts } from "./string-construction.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; @@ -904,7 +905,30 @@ export function emitContainerExpr( const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i)); const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread)); // An int32 array's allocation (ir/int32-arrays.ts) constructs - // SCR_ELEM_I32 storage; its elements are proven int32s. + // SCR_ELEM_I32 storage; its elements are proven int32s. The empty + // temporary of a fill that allocates an int32 array with checked + // stores takes SCR_ELEM_I32 storage when the fill's length (which + // evaluates without effects) is at least CHECKED_INT32_MIN_LENGTH, and + // SCR_ELEM_F64 storage otherwise. + const fillLength = host.checkedAllocations.get(e); + if (fillLength !== undefined && e.elems.length === 0) { + const length = host.emitExpr(fillLength); + const long = B.tmp(), + kind = B.tmp(); + B.line(`${long} = fcmp oge double ${length.name}, ${f64Lit(CHECKED_INT32_MIN_LENGTH)}`); + B.line(`${kind} = select i1 ${long}, i32 ${SCR_ELEM_I32}, i32 0`); + host.declare( + small + ? `declare ptr @scr_arr_new_small(i32)` + : `declare ptr @scr_arr_new(i32, ${host.sizeType})`, + ); + B.line( + small + ? `${arr} = call ptr @scr_arr_new_small(i32 ${kind})` + : `${arr} = call ptr @scr_arr_new(i32 ${kind}, ${host.sizeType} ${capacity})`, + ); + return host.own({ name: arr, type: e.type }); + } const int32 = host.int32Allocations.has(e); if (int32) { host.declare( @@ -986,6 +1010,26 @@ export function emitContainerExpr( // Ref-element reads return +1 (the inline dense path retains like // the runtime getter); own registers the owned temp in the frame. const elem = e.arr.type.elem; + if (host.checkedInt32Array(e.arr)) { + const proven = host.provenDenseIndex(e.arr, e.index); + const value = host.emitByElementKind( + arr.name, + "double", + (kind) => + emitDenseArrayRead( + host, + arr.name, + idx, + e.index, + kind, + elem, + "scr_arr_get_f64", + true, + proven, + ).value, + )!; + return { name: value, type: e.type }; + } const acc = host.arrayAccess(e.arr, elem); const read = emitDenseArrayRead( host, @@ -1013,14 +1057,18 @@ export function emitContainerExpr( const idx = host.emitExpr(e.index); if (e.arr.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arrayHas on non-array"); - const t = emitDenseArrayState( - host, - arr.name, - idx, - e.index, - "has", - host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), - ); + const t = host.checkedInt32Array(e.arr) + ? host.emitByElementKind(arr.name, "i1", (kind) => + emitDenseArrayState(host, arr.name, idx, e.index, "has", slotWidth(kind)), + )! + : emitDenseArrayState( + host, + arr.name, + idx, + e.index, + "has", + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), + ); return { name: t, type: e.type }; } case "arrayState": { @@ -1028,14 +1076,18 @@ export function emitContainerExpr( const idx = host.emitExpr(e.index); if (e.arr.type.kind !== "array") throw new InternalCompilerError("llvm emitter bug: arrayState on non-array"); - const t = emitDenseArrayState( - host, - arr.name, - idx, - e.index, - "state", - host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), - ); + const t = host.checkedInt32Array(e.arr) + ? host.emitByElementKind(arr.name, "double", (kind) => + emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind)), + )! + : emitDenseArrayState( + host, + arr.name, + idx, + e.index, + "state", + host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem), + ); return { name: t, type: e.type }; } case "arrIntrinsic": diff --git a/packages/compiler/src/backend/llvm/int32-kind-loops.ts b/packages/compiler/src/backend/llvm/int32-kind-loops.ts new file mode 100644 index 000000000..12e38c1f0 --- /dev/null +++ b/packages/compiler/src/backend/llvm/int32-kind-loops.ts @@ -0,0 +1,135 @@ +import type { IrExpr, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { MAX_LOOP_NODES, SIMPLE_STATEMENTS } from "./speculative-integers.js"; + +/** Int32 kind loops. + * + * An int32 array with checked stores (ir/int32-slots.ts) holds int32 slots + * (or doubles, when its fill was short) until a store of a value that is + * not an int32 widens it to doubles in place, so an access to it tests its + * element kind first (LlEmitter.emitByElementKind). An innermost loop that + * accesses such arrays tests their kinds once on entry instead + * (emitLoopCopies). The fast copy runs when every one of them holds int32 + * slots and reads and writes them as int32 arrays; the wide copy runs when + * every one holds doubles and accesses them as double arrays; a loop over + * several arrays whose kinds differ runs the mixed copy, which tests the + * kinds at each access. A store of the fast copy whose value is not an + * int32 branches to the same statement in a third copy, which re-executes + * it with kind tests (widening the array) and finishes the iteration, then + * enters the mixed copy, or the wide copy when the loop has one array. + * Re-execution is exact because such a store's statement evaluates nothing + * but locals, literals, number arithmetic and element reads before it + * writes. + * + * Nothing else can widen these arrays: they never reach code other than + * the compiled element operations of their own function + * (ir/int32-arrays.ts). */ +export interface Int32KindLoopPlan { + /** The arrays the fast copy reads and writes as int32 arrays, by local, + * with one of their references in the loop. */ + arrays: Map; + /** Element writes and pushes whose value may not be an int32: the + * statements the fast copy may leave at. */ + writes: Set; +} + +type KindLoop = Extract; + +/** A number or boolean expression whose evaluation has no effect: locals, + * literals, arithmetic, comparisons and element reads of local arrays. */ +function isPure(e: IrExpr): boolean { + if (e.type.kind !== "f64" && e.type.kind !== "bool") return false; + switch (e.kind) { + case "numLit": + case "boolLit": + case "varRef": + return true; + case "bin": + return ( + (e.left.type.kind === "f64" || e.left.type.kind === "bool") && + isPure(e.left) && + isPure(e.right) + ); + case "unary": + return isPure(e.operand); + case "ternary": + return isPure(e.cond) && isPure(e.then) && isPure(e.else_); + case "arrayGet": + case "arrayHas": + return e.arr.kind === "varRef" && isPure(e.index); + default: + return false; + } +} + +/** The plan for `loop`, or null. `checked` names the int32 array locals + * with checked stores of the function; `provenInt32` the values the static + * range analysis proves int32s (stores that never leave the fast copy). */ +export function planInt32KindLoop( + loop: KindLoop, + checked: (localId: string) => boolean, + provenInt32: (e: IrExpr) => boolean, +): Int32KindLoopPlan | null { + const arrays = new Map(); + const writes = new Set(); + const assigned = new Set(); + let nodes = 0; + let rejected = false; + const reject = (): false => { + rejected = true; + return false; + }; + const array = (e: IrExpr): string | null => + e.kind === "varRef" && checked(e.localId) ? e.localId : null; + const push = (e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null => + e.kind === "arrIntrinsic" && e.method === "push" && array(e.receiver) !== null ? e : null; + + const expr = (e: IrExpr): boolean => { + if (rejected) return false; + nodes++; + if (e.kind === "varRef" && checked(e.localId) && !arrays.has(e.localId)) + arrays.set(e.localId, e); + // A push the statement walk did not take as a write: its values must + // be proven int32s, since it cannot leave the fast copy. + const pushed = push(e); + if (pushed && !pushed.args.every(provenInt32)) return reject(); + return everyExprChild(e, expr, nested); + }; + // Statements inside expressions run mid-expression, where the fast copy + // cannot leave: they may not write these arrays. + const nested = (s: IrStmt): boolean => { + nodes++; + if (s.kind === "assign" || s.kind === "varDecl") assigned.add(s.localId); + if (s.kind === "arraySet" && array(s.arr) !== null && !provenInt32(s.value)) return reject(); + return everyStmtChild(s, expr, nested); + }; + const stmt = (s: IrStmt): boolean => { + if (rejected) return false; + nodes++; + if (!SIMPLE_STATEMENTS.has(s.kind)) return reject(); + if (s.kind === "assign" || s.kind === "varDecl") assigned.add(s.localId); + if (s.kind === "arraySet" && array(s.arr) !== null && !provenInt32(s.value)) { + if (!isPure(s.index) || !isPure(s.value)) return reject(); + writes.add(s); + return everyStmtChild(s, expr, stmt); + } + const pushed = s.kind === "exprStmt" ? push(s.expr) : null; + if (pushed && !pushed.args.every(provenInt32)) { + if (!pushed.args.every(isPure)) return reject(); + writes.add(s); + expr(pushed.receiver); + pushed.args.forEach(expr); + return !rejected; + } + return everyStmtChild(s, expr, stmt); + }; + + if (loop.cond) expr(loop.cond); + if (loop.kind === "for" && loop.update) stmt(loop.update); + for (const s of loop.body) stmt(s); + if (rejected || nodes > MAX_LOOP_NODES || arrays.size === 0) return null; + // A loop that rebinds one of the arrays could reach another array of + // either kind. + for (const id of arrays.keys()) if (assigned.has(id)) return null; + return { arrays, writes }; +} diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 4b7b57f6f..1c540295a 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -767,7 +767,37 @@ export function emitLocalArrayRead( } B.line(`store i32 ${read.presentTag}, ptr ${tag}`); }; - if (inline) + if (inline && scalar && host.checkedInt32Array(read.array)) + // An int32 array with checked stores: its int32 arm stores the element's + // double bits, as the double arm does. + host.emitByElementKind(array.name, null, (kind) => { + const present = + kind === "i32" + ? (value: string): void => { + const number = B.tmp(), + bits = B.tmp(); + B.line(`${number} = sitofp i32 ${value} to double`); + B.line(`${bits} = bitcast double ${number} to i64`); + storeValue(bits); + } + : storeValue; + const loadType = kind === "i32" ? "i32" : "i64"; + const induction = loopIndex !== null; + emitDenseReferenceArrayRead( + host, + array, + index, + integerIndex, + induction, + present, + no, + slow, + join, + loadType, + ); + return null; + }); + else if (inline) emitDenseReferenceArrayRead( host, array, diff --git a/packages/compiler/src/backend/llvm/speculative-integers.ts b/packages/compiler/src/backend/llvm/speculative-integers.ts index a309a9785..00fabb788 100644 --- a/packages/compiler/src/backend/llvm/speculative-integers.ts +++ b/packages/compiler/src/backend/llvm/speculative-integers.ts @@ -35,12 +35,12 @@ export interface SpeculativeLoopPlan { type SpeculativeLoop = Extract; /** The fast copy duplicates the loop; keep the duplicated code small. */ -const MAX_LOOP_NODES = 400; +export const MAX_LOOP_NODES = 400; /** Statements whose emission is plain control flow or a single write. * Nested loops, switches and try statements keep the loop on the ordinary * path (their emission keeps state that the two copies would not share). */ -const SIMPLE_STATEMENTS = new Set([ +export const SIMPLE_STATEMENTS: ReadonlySet = new Set([ "varDecl", "assign", "exprStmt", diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts index 82dcf4470..bdb3f21e2 100644 --- a/packages/compiler/src/ir/int32-arrays.ts +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -3,6 +3,7 @@ import { reparseExpr } from "./serialize.js"; import { everyExprChild, everyStmtChild, + everyStmtList, transformExpr, transformStmt, transformStmtList, @@ -23,9 +24,12 @@ import { * Candidates are only proposed here. The int32 slot analysis * (int32-slots.ts) keeps the ones whose every stored element is a proven * int32 (never -0), assuming the candidates' own elements are int32s, at - * a fixpoint, and the LLVM backend stores those as SCR_ELEM_I32. Their - * element-reader helper calls are inlined first (rewriteCandidates), so - * no shared helper ever receives an int32 array. */ + * a fixpoint, and the LLVM backend stores those as SCR_ELEM_I32. It keeps + * the ones whose stores are exact integers but not all proven int32s as + * int32 arrays with checked stores: SCR_ELEM_I32 until a store that is not + * an int32 widens them to doubles in place. Their element-reader helper + * calls are inlined first (rewriteCandidates), so no shared helper ever + * receives an int32 array. */ /** Array methods a candidate may be the receiver of: the runtime paths * behind them read and write int32 slots through the double-valued slot @@ -87,6 +91,62 @@ function fillAllocation(e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null { return e; } +/** Fill-form arrays shorter than this hold doubles even when they are int32 + * arrays with checked stores: their doubles fit a typical 32 KiB L1 data + * cache, so 4-byte slots save no memory traffic. */ +export const CHECKED_INT32_MIN_LENGTH = 4096; + +/** The length `n` of `new Array(n).fill(v)`, or null for any other + * allocation. */ +export function fillAllocationLength(e: IrExpr): IrExpr | null { + const fill = fillAllocation(e); + if (!fill || fill.receiver.kind !== "seqExpr") return null; + const set = fill.receiver.stmts[1]!; + return set.kind === "arraySetLength" ? set.length : null; +} + +/** A number expression whose evaluation has no effect: locals, literals and + * arithmetic. */ +function isPureLength(e: IrExpr): boolean { + if (e.type.kind !== "f64") return false; + switch (e.kind) { + case "numLit": + case "varRef": + return true; + case "bin": + return e.left.type.kind === "f64" && isPureLength(e.left) && isPureLength(e.right); + case "unary": + return isPureLength(e.operand); + default: + return false; + } +} + +/** Whether the candidate `id` of `fn` may hold int32 slots with checked + * stores: every array it is assigned is the fill form, with a length that + * may reach CHECKED_INT32_MIN_LENGTH and evaluates without effects (the + * backend evaluates it once more to pick the new array's kind). */ +export function checkableInt32Allocations(fn: IrFunction, id: string): boolean { + let ok = true; + everyStmtList(fn.body, { + stmt: (s) => { + if ((s.kind === "varDecl" || s.kind === "assign") && s.localId === id) { + const value = s.kind === "varDecl" ? s.init : s.value; + const length = value ? fillAllocationLength(value) : null; + if ( + length === null || + !isPureLength(length) || + (length.kind === "numLit" && length.value < CHECKED_INT32_MIN_LENGTH) + ) + ok = false; + } + return ok; + }, + expr: () => ok, + }); + return ok; +} + /** A fresh number array: a literal without spreads, or the fill form. */ export function int32ArrayAllocation(e: IrExpr): boolean { return (e.kind === "arrayLit" && (e.spreads?.length ?? 0) === 0) || fillAllocation(e) !== null; diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index bb6a06867..e6491ba80 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -10,7 +10,7 @@ import { } from "./integer-ranges.js"; import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; -import { int32ArrayStores } from "./int32-arrays.js"; +import { checkableInt32Allocations, int32ArrayStores } from "./int32-arrays.js"; import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an @@ -251,6 +251,15 @@ export class Int32Slots { * module's int32 array candidates whose every stored element the * fixpoint proved an int32. */ readonly int32Arrays = new Set(); + /** Local number arrays that may hold int32 slots with checked stores: the + * candidates whose element stores the fixpoint proved exact integers but + * not int32s, allocated only by fills that may be long. A fill of at least + * CHECKED_INT32_MIN_LENGTH elements creates one with int32 slots and a + * shorter one with doubles. Their elements give no int32 facts; compiled + * accesses test the array's kind (once per innermost loop where they can, + * backend/llvm/int32-kind-loops.ts), and a store that is not an int32 + * widens it first (scr_arr_widen_i32). */ + readonly checkedInt32Arrays = new Set(); /** Whether every present element of the number array `arr` evaluates to * (read inside `fnName`) is an int32. */ hasInt32Elements(fnName: string, arr: IrExpr): boolean { @@ -1108,8 +1117,13 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); // Every element stored into an int32 array candidate must be an int32 // under the facts that assume the candidates' own elements are; a - // failing store drops its array, and the function is analyzed again - // without it. + // failing store drops its array's element facts, and the function is + // analyzed again without them. When every failing store is still an + // exact integer under those facts (a counter, `a[i]! + 1`) and the array + // is only ever allocated by a fill that may be long + // (checkableInt32Allocations), it keeps int32 slots with checked stores + // (checkedInt32Arrays), which widen it to doubles in place at the first + // store that is not an int32. const arrays = arrayCandidates.get(fn.name); if (arrays) { let dropped = false; @@ -1117,7 +1131,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): const key = `l:${fn.name}\0${id}`; if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue; slots.dropInt32Elements(key); - reason(`array:${fn.name}\0${id}`, "an element store is not int32"); + const checkable = (value: IrExpr): boolean => int32(value) || ranges.get(value) != null; + if (values.every(checkable) && checkableInt32Allocations(fn, id)) + slots.checkedInt32Arrays.add(`${fn.name}\0${id}`); + else reason(`array:${fn.name}\0${id}`, "an element store is not int32"); dropped = true; } if (dropped && !queued.has(fn.name)) { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 0fa873a16..fa081c594 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1076,7 +1076,8 @@ export interface IrModule { /** Local number arrays (`function\0local`) the backend may store as * int32 slots, proposed by ir/int32-arrays.ts after their element-reader * calls were inlined; the int32 slot analysis keeps the ones whose every - * stored element it proves an int32. Absent when there is none. */ + * stored element it proves an int32, and those whose stores are exact + * integers with checked stores. Absent when there is none. */ int32ArrayCandidates?: string[]; /** The WebAssembly modules translated into this program (wasm/ * translate.ts), present exactly when the program uses the WebAssembly diff --git a/packages/compiler/test/int32-element-array-emission.test.ts b/packages/compiler/test/int32-element-array-emission.test.ts index 8e89c8562..0b68a5d3e 100644 --- a/packages/compiler/test/int32-element-array-emission.test.ts +++ b/packages/compiler/test/int32-element-array-emission.test.ts @@ -63,6 +63,16 @@ function negativeZero(): number { return 1 / a[1]!; } function counter(n: number): number { + const a = new Array(n).fill(0) as number[]; + for (let i = 0; i < 1000; i++) a[i % n] = a[i % n]! + 1; + return a[0]!; +} +function shortCounter(): number { + const a = new Array(16).fill(0) as number[]; + for (let i = 0; i < 1000; i++) a[i % 16] = a[i % 16]! + 1; + return a[0]!; +} +function literalCounter(n: number): number { const a = [0]; for (let i = 0; i < n; i++) a[0] = a[0]! + 1; return a[0]!; @@ -72,7 +82,16 @@ function joined(n: number): string { return a.join(","); } const { n } = JSON.parse('{"n": 8}') as { n: number }; -console.log(compose(n | 0, 3), counts(n | 0), fraction(n), negativeZero(), counter(n), joined(n)); +console.log( + compose(n | 0, 3), + counts(n | 0), + fraction(n), + negativeZero(), + counter(n), + shortCounter(), + literalCounter(n), + joined(n), +); `; /** Every function emitted for the source function \`name\` (its int32 ABI @@ -100,15 +119,64 @@ test("arrays storing only int32 elements get 4-byte slots", async () => { expect(counts).not.toContain("popArray"); }); -test("arrays with a store that may not be an int32, or that escape, keep doubles", async () => { +test("arrays with a store that has no exact integer range, or that escape, keep doubles", async () => { const llvm = await emitLlvm(source); - for (const name of ["fraction", "negativeZero", "counter", "joined"]) { + for (const name of ["fraction", "negativeZero", "joined"]) { const fn = functions(llvm, name); expect(fn, name).not.toBe(""); expect(fn, name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/); + expect(fn, name).not.toContain("arr.kind."); } }); +test("a counter array keeps int32 slots with checked stores", async () => { + const llvm = await emitLlvm(source); + const counter = functions(llvm, "counter"); + // `a[k] = a[k]! + 1` is an exact integer but no int32 proof: a fill of at + // least 4096 elements creates SCR_ELEM_I32 storage (SCR_ELEM_F64 for a + // shorter one), accesses test the kind, and a store widens the array + // before a value that is not an int32. + expect(counter).toMatch( + /fcmp oge double %\w+, [^\n]+\n\s+%\w+ = select i1 %\w+, i32 7, i32 0\n\s+%\w+ = call ptr @scr_arr_new(_small)?\(i32 %\w+/, + ); + expect(counter).toMatch(/load i8, ptr %\w+[^\n]*\n\s+%\w+ = icmp eq i8 %\w+, 7/); + expect(counter).toContain("@llvm.fptosi.sat.i32.f64"); + expect(counter).toContain("call void @scr_arr_widen_i32("); + // Arrays with proven stores take no kind test, and a fill of a constant + // short length or an array literal keeps doubles. + for (const name of ["compose", "counts", "shortCounter", "literalCounter"]) { + const fn = functions(llvm, name); + expect(fn, name).not.toContain("arr.kind."); + expect(fn, name).not.toContain("kind.fast"); + expect(fn, name).not.toContain("scr_arr_widen_i32"); + } + for (const name of ["shortCounter", "literalCounter"]) + expect(functions(llvm, name), name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/); +}); + +test("a loop over a counter array tests its kind once on entry", async () => { + const llvm = await emitLlvm(source); + const counter = functions(llvm, "counter"); + // The loop's fast copy runs while the array holds int32 slots, with no + // kind test of its own: the sum is an integer, and a sum past the int32 + // range leaves for the third copy, which re-executes the store with kind + // tests and widens the array. + expect(counter).toMatch( + /icmp eq i8 %\w+, 7\n\s+br i1 %\w+, label %kind\.fast\d+, label %kind\.wide/, + ); + const rest = counter.slice(counter.search(/^kind\.fast\d+:/m)); + // The fast copy is the last one, before the loop's exit. + const fast = rest.slice(0, rest.search(/^loop\.e\d+:/m)); + expect(fast).not.toBe(""); + expect(fast).not.toMatch(/icmp eq i8 %\w+, 7/); + expect(fast).toMatch(/add nsw i64 %\w+, 1/); + expect(fast).toMatch( + /icmp sle i64 %\w+, 2147483647\n[^\n]*\n\s+br i1 %\w+, label %arr\.store\.i32\d+, label %kind\.resume\d+/, + ); + expect(fast).toMatch(/store i32 %\w+, ptr %\w+, !tbaa/); + expect(counter).toMatch(/^kind\.resume\d+:/m); +}); + test("only the candidates the proof keeps are rewritten", async () => { const mod = await lowerIr(source); const proposed = markInt32ArrayCandidates(mod).int32ArrayCandidates ?? []; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7327-int32-array-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7327-int32-array-widening.ts.json new file mode 100644 index 000000000..050b6a9ac --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7327-int32-array-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7327-int32-array-widening.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index bbe66e26e..b6c016e29 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -579,6 +579,36 @@ static void scr_arr_move_inline(ScrArr *a, size_t cap) { a->spare &= (uint16_t)~SCR_ARR_INLINE_SLOTS; } +/* Widen an int32 array (SCR_ELEM_I32) to doubles in place, one way: its + * dense values move to 8-byte slots, with their states when it has them, and + * its kind becomes SCR_ELEM_F64. Compiled code calls it before storing a + * value that is not an int32 into an int32 array with checked stores; `data` + * may move. Sparse entries already hold doubles. */ +void scr_arr_widen_i32(ScrArr *a) { + if (a->elem != SCR_ELEM_I32) return; + size_t cap = a->cap; + if (cap > 0) { + /* Int32 arrays never shift, so they have no head room; inline slots + * have no ext prefix. */ + bool inline_slots = scr_arr_inline(a); + bool states = scr_arr_has_states(a); + size_t unit = sizeof(uint64_t) + (states ? 1 : 0); + size_t prefix = inline_slots ? 0 : scr_arr_prefix(a); + if (cap > (SIZE_MAX - prefix) / unit) scr_arr_oom(); + char *start = scr_mem_alloc(prefix + cap * unit); + if (!start) scr_arr_oom(); + if (prefix) memcpy(start, scr_arr_alloc_start(a), prefix); + uint64_t *data = (uint64_t *)(start + prefix); + const int32_t *old = (const int32_t *)a->data; + for (size_t i = 0; i < cap; i++) data[i] = scr_arr_f64_slot((double)old[i]); + if (states) memcpy((uint8_t *)data + cap * sizeof(uint64_t), scr_arr_states_end(a) - cap, cap); + if (!inline_slots && a->rc != SIZE_MAX) scr_mem_free(scr_arr_alloc_start(a)); + a->data = data; + a->spare &= (uint16_t)~SCR_ARR_INLINE_SLOTS; + } + a->elem = SCR_ELEM_F64; +} + /* How a dense allocation is sized. */ typedef enum { SCR_ARR_GROW_APPEND, /* geometric headroom for further appends */ diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 6034462e9..2ae366a71 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -433,6 +433,9 @@ ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap); * array plus five 8-byte slots). Otherwise exactly scr_arr_new(elem, 0) and * scr_arr_new_ops(ops, 0). Returns +1. */ ScrArr *scr_arr_new_small(ScrElemKind elem); +/* An int32 array's storage as doubles, in place (SCR_ELEM_I32 to + * SCR_ELEM_F64); no-op for any other kind. */ +void scr_arr_widen_i32(ScrArr *a); ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops); /* scr_arr_new(elem, cap) for a known final length: the `cap` slots share * the array's own block (one allocation). With `states`, the array starts diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 8cbac7f57..7ca0ebd1a 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1670,6 +1670,44 @@ static void test_i32_slots(void) { check(i32_layout_ok(k) && scr_arr_get_f64(k, 6) == 6 && scr_arr_get_f64(k, 0) == 0, "a known-count int32 array grows out of its block"); scr_arr_release(k); + + /* Widening: in place, to 8-byte double slots, keeping states. */ + ScrArr *w = scr_arr_new(SCR_ELEM_I32, 3); + for (int i = 0; i < 3; i++) scr_arr_push_f64(w, i - 1); + check(inline_slots(w), "a three-element int32 construction keeps inline slots"); + scr_arr_widen_i32(w); + check(w->elem == SCR_ELEM_F64 && !inline_slots(w) && scr_arr_len(w) == 3 && w->filled == 3 && + scr_arr_get_f64(w, 0) == -1 && scr_arr_get_f64(w, 2) == 1, + "widening moves inline int32 slots out as doubles"); + scr_arr_set_f64(w, 1, 2.5); + check(scr_arr_get_f64(w, 1) == 2.5, "a widened array stores fractions"); + scr_arr_widen_i32(w); + check(w->elem == SCR_ELEM_F64 && scr_arr_get_f64(w, 1) == 2.5, "widening twice is a no-op"); + scr_arr_release(w); + + ScrArr *h = scr_arr_new_small(SCR_ELEM_I32); + for (int i = 0; i < 100; i++) scr_arr_push_f64(h, i * 3); + scr_arr_set_len(h, 120); + scr_arr_set_undefined(h, 5); + scr_arr_set_f64(h, 4000000, -7); + size_t cap = h->cap; + scr_arr_widen_i32(h); + bool widened = h->elem == SCR_ELEM_F64 && h->cap == cap && scr_arr_len(h) == 4000001 && + scr_arr_states_end(h) == (const uint8_t *)h->data + 9 * (size_t)h->cap; + for (int i = 0; i < 100; i++) + if (i != 5) widened &= scr_arr_get_f64(h, i) == i * 3; + check(widened && scr_arr_state(h, 5) == SCR_ARR_UNDEFINED && scr_arr_state(h, 110) == SCR_ARR_HOLE && + scr_arr_get_f64(h, 4000000) == -7 && arr_sparse_len(h) == 1, + "widening keeps values, present undefined, holes and sparse entries"); + scr_arr_push_f64(h, 0.5); + check(scr_arr_get_f64(h, 4000001) == 0.5, "a widened array keeps growing as doubles"); + scr_arr_release(h); + + ScrArr *e = scr_arr_new_small(SCR_ELEM_I32); + scr_arr_widen_i32(e); + scr_arr_push_f64(e, -0.0); + check(e->elem == SCR_ELEM_F64 && signbit(scr_arr_get_f64(e, 0)), "an empty int32 array widens too"); + scr_arr_release(e); } static void test_inline_slots(void) { diff --git a/tests/corpus/7327-int32-array-widening.ts b/tests/corpus/7327-int32-array-widening.ts new file mode 100644 index 000000000..798292c11 --- /dev/null +++ b/tests/corpus/7327-int32-array-widening.ts @@ -0,0 +1,186 @@ +// Local number arrays with checked int32 stores: `new Array(n).fill(v)` +// arrays whose stores are exact integers but not proven int32s. A fill of +// at least 4096 elements creates int32 slots, and the first store past the +// int32 range widens the array to doubles in place; a shorter fill creates +// doubles. Each case runs at both sizes. Arrays with a store that has no +// exact integer range (a product of two int32s, a fraction, a value that may +// be -0), and arrays allocated as literals, keep doubles throughout. + +function counters(n: number, rounds: number): string { + const c: number[] = new Array(n).fill(0); + for (let r = 0; r < rounds; r++) + for (let i = 0; i < c.length; i++) c[i] = c[i]! + ((i & 3) + 1) * 1000000; + return `counters ${n} ${c[0]} ${c[1]} ${c[2]} ${c[3]} ${c[n - 1]} ${c.length}`; +} + +// The store at i = 45 passes 2^31 and widens the array partway through a +// loop; the remaining passes store doubles. +function denseLoop(n: number, k: number): string { + const a: number[] = new Array(n).fill(0); + for (let i = 0; i < a.length; i++) a[i] = i + k + 2147483600; + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + a[i]!) % 1000003; + return `dense ${n} ${a[44]} ${a[45]} ${a[n - 1]} ${s}`; +} + +// Checked stores whose values stay int32: no widening. +function bfs(n: number): string { + const dist: number[] = new Array(n).fill(-1); + const queue: number[] = [0]; + dist[0] = 0; + for (let h = 0; h < queue.length; h++) { + const u = queue[h]!; + const edges = [(u + 1) & (n - 1), (u * 5 + 1) & (n - 1)]; + for (let j = 0; j < edges.length; j++) { + const v = edges[j]!; + if (dist[v]! < 0) { + dist[v] = dist[u]! + 1; + queue.push(v); + } + } + } + let far = 0, + sum = 0; + for (let i = 0; i < n; i++) { + if (dist[i]! > far) far = dist[i]!; + sum += dist[i]!; + } + return `bfs ${n} ${far} ${sum} ${queue.length}`; +} + +// A fill value that is not an int32 widens the new array before the fill. +function filledPastInt32(n: number, k: number): string { + const a: number[] = new Array(n).fill(k * 1000000000); + a[1] = a[1]! + 1; + const b: number[] = new Array(n).fill(k); + b[2] = b[2]! * 2; + return `filled ${n} ${a[0]} ${a[1]} ${a.length} ${b[0]} ${b[2]}`; +} + +// A chain of sums crosses 2^31 partway through; the second loop runs after +// the array widened. +function chain(n: number, step: number): string { + const a: number[] = new Array(n).fill(0); + for (let i = 1; i < n; i++) a[i] = a[i - 1]! + (i < 16 ? step : 1); + let s = 0; + for (let i = 0; i < n; i++) s = (s + (a[i]! % 1000003)) % 1000003; + for (let i = 1; i < n; i++) a[i] = a[i]! - a[i - 1]!; + return `chain ${n} ${step} ${a[1]} ${a[5]} ${a[6]} ${a[n - 1]} ${s}`; +} + +// Two arrays in one loop: one widens partway through, the other keeps +// int32 slots; then both are read in another loop. +function twoArrays(n: number, m: number, k: number): string { + const small: number[] = new Array(n).fill(1); + const big: number[] = new Array(m).fill(k); + const count = Math.min(n, m); + for (let i = 1; i < count; i++) { + small[i] = small[i - 1]! + 1; + big[i] = big[i - 1]! + (i < 8 ? 700000000 : 1); + } + let s = 0, + t = 0; + for (let i = 0; i < count; i++) { + s += small[i]!; + t = (t + (big[i]! % 997)) % 997; + } + return `two ${n} ${m} ${small[count - 1]} ${big[2]} ${big[3]} ${big[count - 1]} ${s} ${t}`; +} + +// Pushes that pass 2^31 widen the array before they append. +function pushes(n: number, k: number): string { + const a: number[] = new Array(n).fill(k); + for (let i = 0; i < 8; i++) a.push(a[a.length - 1]! + 900000000); + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + (a[i]! % 10007)) % 10007; + return `pushes ${n} ${a.length} ${a[n]} ${a[n + 2]} ${a[n + 7]} ${s}`; +} + +function controlFlow(n: number, k: number): string { + const a: number[] = new Array(n).fill(k); + let stores = 0; + for (let i = 1; i < n; i++) { + if (i % 2 === 0) continue; + a[i] = a[i - 1]! + 1000000000 * (i % 4); + stores++; + if (a[i]! > 2500000000) break; + } + let i = 0; + while (i < n && a[i]! < 2000000000) i++; + let j = n; + do j--; + while (j > 0 && a[j] === k); + return `flow ${n} ${stores} ${a[1]} ${a[2]} ${a[3]} ${i} ${j}`; +} + +function readOnly(n: number, wide: boolean): string { + const a: number[] = new Array(n).fill(2); + if (wide) a[0] = a[0]! + 3000000000; + let s = 0, + present = 0; + for (let i = 0; i < n + 2; i++) { + s += a[i] ?? 0; + if (i in a) present++; + } + return `read ${n} ${wide} ${s} ${present}`; +} + +function holes(n: number): string { + // Holes, present undefined and sparse entries survive widening. + const a: number[] = new Array(n).fill(3); + a[n + 3] = a[1]! + 1; + a[n + 2] = a[n + 3]! - 1; + a[2 * n + 100000] = a[0]! + 1; + a[n + 1] = a[n + 2]! + 3000000000; + return `holes ${n} ${a.length} ${a[n]} ${n in a} ${a[n + 1]} ${a[n + 2]} ${a[2 * n + 100000]}`; +} + +// These arrays never take int32 slots: a fractional store, a product of +// two int32s, -0, an escape, and arrays allocated as literals. +function neverInt32(n: number, x: number, y: number): string[] { + const f: number[] = new Array(n).fill(1); + f[0] = f[0]! / x; + const m: number[] = new Array(n).fill(3); + m[1] = m[1]! * y; + const z: number[] = new Array(n).fill(4); + z[1] = z[1]! % 2; + z[2] = -z[0]! % 2; + const j: number[] = new Array(n).fill(3); + j[0] = j[0]! + 1; + const lit = [1, 2, 3]; + lit[0] = lit[0]! + 3000000000; + const pushed: number[] = []; + for (let i = 0; i < 4; i++) pushed.push(i * 1000000000); + pushed[0] = pushed[3]! + pushed[2]!; + return [ + `never ${f[0]} ${m[1]} ${z[1]} ${Object.is(z[2], -0)} ${j.slice(0, 2).join("|")}`, + `literals ${lit[0]} ${lit[2]} ${pushed[0]} ${pushed.length}`, + ]; +} + +const config = JSON.parse('{"small": 64, "large": 8192, "k": 3, "y": 2000000000}') as { + small: number; + large: number; + k: number; + y: number; +}; +const lines: string[] = []; +for (const n of [config.small | 0, config.large | 0]) { + lines.push(counters(n, 1000)); + lines.push(denseLoop(n, config.k | 0)); + lines.push(bfs(n)); + lines.push(filledPastInt32(n, config.k | 0)); + lines.push(chain(n, 400000000)); + lines.push(chain(n, 100000000)); + lines.push(twoArrays(n, n, config.k | 0)); + lines.push(pushes(n, config.k | 0)); + lines.push(controlFlow(n, config.k | 0)); + lines.push(readOnly(n, false)); + lines.push(readOnly(n, true)); + lines.push(holes(n)); +} +// One long array and one short: their kinds differ on entry to the loop. +lines.push(twoArrays(config.large | 0, config.small | 0, config.k | 0)); +lines.push(twoArrays(config.small | 0, config.large | 0, config.k | 0)); +lines.push(...neverInt32(config.small | 0, config.k, config.y | 0)); +for (const line of lines) console.log(line); diff --git a/tests/harness/corpus-costs/7327-int32-array-widening.ts.json b/tests/harness/corpus-costs/7327-int32-array-widening.ts.json new file mode 100644 index 000000000..ac0e8497d --- /dev/null +++ b/tests/harness/corpus-costs/7327-int32-array-widening.ts.json @@ -0,0 +1 @@ +{ "plain": 4500, "san": 6100 } From 17826afb23604de181a3f1bcbca10041f2bed49a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:37:39 -0500 Subject: [PATCH 29/39] Renumber the new.target corpus programs to 7560 and 7561 7362 and 7363 are taken on main by other programs. The costs and the order-parity baselines move with them. --- .../tests/corpus/7362-new-target-constructors.ts.json | 6 ------ .../tests/corpus/7363-new-target-generic-registry.ts.json | 6 ------ .../tests/corpus/7560-new-target-constructors.ts.json | 6 ++++++ .../tests/corpus/7561-new-target-generic-registry.ts.json | 6 ++++++ ...rget-constructors.ts => 7560-new-target-constructors.ts} | 0 ...eric-registry.ts => 7561-new-target-generic-registry.ts} | 0 ...ructors.ts.json => 7560-new-target-constructors.ts.json} | 0 ...try.ts.json => 7561-new-target-generic-registry.ts.json} | 0 8 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7560-new-target-constructors.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7561-new-target-generic-registry.ts.json rename tests/corpus/{7362-new-target-constructors.ts => 7560-new-target-constructors.ts} (100%) rename tests/corpus/{7363-new-target-generic-registry.ts => 7561-new-target-generic-registry.ts} (100%) rename tests/harness/corpus-costs/{7362-new-target-constructors.ts.json => 7560-new-target-constructors.ts.json} (100%) rename tests/harness/corpus-costs/{7363-new-target-generic-registry.ts.json => 7561-new-target-generic-registry.ts.json} (100%) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json deleted file mode 100644 index f38c901e0..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7362-new-target-constructors.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/7362-new-target-constructors.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json deleted file mode 100644 index a1c8cb535..000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7363-new-target-generic-registry.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order": [ - "/tests/corpus/7363-new-target-generic-registry.ts" - ], - "diags": [] -} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7560-new-target-constructors.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7560-new-target-constructors.ts.json new file mode 100644 index 000000000..b4426af2c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7560-new-target-constructors.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7560-new-target-constructors.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7561-new-target-generic-registry.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7561-new-target-generic-registry.ts.json new file mode 100644 index 000000000..dc4eab819 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7561-new-target-generic-registry.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7561-new-target-generic-registry.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7362-new-target-constructors.ts b/tests/corpus/7560-new-target-constructors.ts similarity index 100% rename from tests/corpus/7362-new-target-constructors.ts rename to tests/corpus/7560-new-target-constructors.ts diff --git a/tests/corpus/7363-new-target-generic-registry.ts b/tests/corpus/7561-new-target-generic-registry.ts similarity index 100% rename from tests/corpus/7363-new-target-generic-registry.ts rename to tests/corpus/7561-new-target-generic-registry.ts diff --git a/tests/harness/corpus-costs/7362-new-target-constructors.ts.json b/tests/harness/corpus-costs/7560-new-target-constructors.ts.json similarity index 100% rename from tests/harness/corpus-costs/7362-new-target-constructors.ts.json rename to tests/harness/corpus-costs/7560-new-target-constructors.ts.json diff --git a/tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json b/tests/harness/corpus-costs/7561-new-target-generic-registry.ts.json similarity index 100% rename from tests/harness/corpus-costs/7363-new-target-generic-registry.ts.json rename to tests/harness/corpus-costs/7561-new-target-generic-registry.ts.json From e6659132006c5198903c30910ca03838e797e9fa Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:39:37 -0500 Subject: [PATCH 30/39] Print accessor properties of arrays and fence number keys in defineProperty - An enumerable getter or setter on an array printed as undefined: the property loop rendered the accessor's unused data slot. It now prints [Getter], [Setter] or [Getter/Setter], as util.inspect does. - Object.defineProperty on a live native array with a number key that is not an array index (-3, 1.5) skipped the numeric-property fence and lost the property on commit. A number key now takes the same fence as its string spelling. --- packages/runtime/src/scr_inspect.c | 11 +++++++++- packages/runtime/src/scr_json.c | 13 ++++++++---- tests/corpus/7062-inspect-array-properties.ts | 7 +++++++ tests/harness/errors.test.ts | 21 +++++++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index bca2b88fe..1dce8d3c2 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -992,6 +992,15 @@ ScrStr *scr_insp_error(ScrError *e, double recurse, double depth) { /* Property-name rendering: bare when the key matches Node's keyStrRegExp * (/^[a-zA-Z_][a-zA-Z_0-9]*$/, '__proto__' excepted), quoted otherwise. */ +/* util.inspect's text for an accessor property: [Getter], [Setter] or + * [Getter/Setter], by which of the two are functions. +1. */ +static ScrStr *insp_accessor_tag(const ScrDynEntry *ent) { + bool get = ent->getter && ent->getter->kind == SCR_DYN_FUNC; + bool set = ent->setter && ent->setter->kind == SCR_DYN_FUNC; + const char *text = get && set ? "[Getter/Setter]" : get ? "[Getter]" : set ? "[Setter]" : "undefined"; + return scr_str_new(text, strlen(text)); +} + static void insp_key_into(InspBuf *b, const char *key, size_t len) { if (len == 9 && memcmp(key, "__proto__", 9) == 0) { ib_cstr(b, "['__proto__']"); @@ -1539,7 +1548,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { for (size_t k = 0; props && k < props->v.obj.len; k++) { const ScrDynEntry *ent = &props->v.obj.entries[k]; if (!ent->enumerable) continue; - ScrStr *val = scr_insp_dyn(ent->value, recurse + 1, depth); + ScrStr *val = ent->accessor ? insp_accessor_tag(ent) : scr_insp_dyn(ent->value, recurse + 1, depth); InspBuf eb = {0}; insp_key_into(&eb, ent->key, ent->key_len); ib_cstr(&eb, ": "); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 46d3495ff..e3470854e 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -6759,11 +6759,16 @@ static bool scr_dyn_fn_property_fence(const ScrStr *key) { ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) { if (scr_dyn_error_prototype_fence(target)) return NULL; - if (target->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(target) && key->kind == SCR_DYN_STR) { + if (target->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(target) && + (key->kind == SCR_DYN_STR || key->kind == SCR_DYN_NUM)) { /* A native array's numeric properties ("-1", "1.5") live in its - * property table, which holds values without attributes. */ - double number; - if (scr_arr_numeric_key(key->v.str->data, key->v.str->len, &number)) { + * property table, which holds values without attributes. A number key + * names one unless it is an array index. */ + double number = key->kind == SCR_DYN_NUM ? key->v.num : 0; + bool numeric = key->kind == SCR_DYN_NUM + ? !(number >= 0 && number < 4294967295.0 && number == trunc(number)) + : scr_arr_numeric_key(key->v.str->data, key->v.str->len, &number); + if (numeric) { static const char message[] = "Object.defineProperty on a numeric property of a native array is not supported yet"; scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1); return NULL; diff --git a/tests/corpus/7062-inspect-array-properties.ts b/tests/corpus/7062-inspect-array-properties.ts index aa18cfca9..7cd5ca984 100644 --- a/tests/corpus/7062-inspect-array-properties.ts +++ b/tests/corpus/7062-inspect-array-properties.ts @@ -52,3 +52,10 @@ console.log(parsed); const plain: number[] = [1, 2, 3]; console.log(plain, [] as number[]); + +const accessed: number[] = [1, 2]; +const accessedBag = accessed as unknown as Bag; +Object.defineProperty(accessedBag, "both", { get: () => 1, set: (_v: unknown) => {}, enumerable: true }); +Object.defineProperty(accessedBag, "getter", { get: () => 2, enumerable: true }); +Object.defineProperty(accessedBag, "setter", { set: (_v: unknown) => {}, enumerable: true }); +console.log(accessed); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 6afa1780d..5a1c3a336 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -2006,4 +2006,25 @@ console.log("unreachable"); /^Uncaught Error: Object\.defineProperty on a numeric property of a native array is not supported yet\n$/, ); }); + + test("a number key that is not an array index takes the same fence", async () => { + const r = await compileAndRun( + "array-numeric-define-property-number", + `const words: string[] = ["a"]; +for (const key of [-3, 1.5]) { + try { + Object.defineProperty(words as unknown as Record, key, { value: "kept" }); + } catch (e) { + console.log((e as Error).message); + } +} +console.log(words.length); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + "Object.defineProperty on a numeric property of a native array is not supported yet\n".repeat(2) + + "1\n", + ); + }); }); From df9f83bbc1b863f400b9ced701fcf7f7398cbd69 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:47:37 -0500 Subject: [PATCH 31/39] Refuse new.target over function-local subclasses lowered later Lowering collects a function-local class with the body declaring it, so a local subclass whose function lowers after its base's constructor was missing from new.target's dispatch, which then answered with the base for its instances. The refusal no longer depends on that order: the program's function-local classes are found once, and one that extends the constructor's class, directly or through other classes its extends clauses name, refuses new.target as a collected one does. --- .../src/frontend/lowering/lower-classes.ts | 2 +- .../compiler/src/frontend/lowering/lowerer.ts | 3 + .../src/frontend/lowering/new-target.ts | 96 ++++++++++++++++++- .../new-target-late-local-subclass.ts.json | 6 ++ .../new-target-late-local-subclass.ts | 15 +++ .../new-target-late-local-subclass.ts.txt | 6 ++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json create mode 100644 tests/diagnostics/new-target-late-local-subclass.ts create mode 100644 tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index b23697cab..1b8941e3e 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -238,7 +238,7 @@ export function returnsOnlyThisValue(member: ts.MethodDeclaration): boolean { return valid; } -function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { +export function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean { for ( let parent: ts.Node | undefined = decl.parent; parent && !ts.isSourceFile(parent); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 93c6246d9..9d7c83810 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2184,6 +2184,9 @@ export class Lowerer { * and erased instances). */ erasingDepth = 0; localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = []; + /** The function-local classes of the program with an `extends` clause, whether lowering collected + * them yet or not (new-target.ts); found on first use. */ + functionLocalSubclasses: ts.ClassLikeDeclaration[] | undefined = undefined; /** Instance types (by checker type id) of the local classes generic * factory calls return, and the per-instance class each names. */ readonly localClassInstanceTypes = new Map(); diff --git a/packages/compiler/src/frontend/lowering/new-target.ts b/packages/compiler/src/frontend/lowering/new-target.ts index 4cf271469..83994ca6f 100644 --- a/packages/compiler/src/frontend/lowering/new-target.ts +++ b/packages/compiler/src/frontend/lowering/new-target.ts @@ -16,7 +16,7 @@ import { varRef } from "../../ir/build.js"; import { locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import type { ClassInfo } from "./lower-classes.js"; -import { classValueRef } from "./lower-classes.js"; +import { classValueRef, functionLocalClass } from "./lower-classes.js"; export function isNewTarget(node: ts.Node): node is ts.MetaProperty { return ( @@ -56,6 +56,90 @@ function depth(info: ClassInfo): number { return d; } +/** The function-local classes of the program that extend a class. */ +function functionLocalSubclasses(lowerer: Lowerer): ts.ClassLikeDeclaration[] { + let found = lowerer.functionLocalSubclasses; + if (found === undefined) { + const classes: ts.ClassLikeDeclaration[] = []; + for (const sf of lowerer.moduleOrder) + ts.walkPreorder(sf, (node) => { + if ( + (ts.isClassDeclaration(node) || ts.isClassExpression(node)) && + baseExpressionOf(node) !== undefined && + functionLocalClass(node) + ) + classes.push(node); + }); + lowerer.functionLocalSubclasses = found = classes; + } + return found; +} + +function baseExpressionOf(node: ts.ClassLikeDeclaration): ts.Expression | undefined { + let base = node.heritageClauses?.nodes.find( + (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, + )?.types?.nodes[0]?.expression; + while (base !== undefined && ts.isParenthesizedExpression(base)) base = base.expression; + return base; +} + +/** Whether the class `node` extends the class of `info` (for an + * instantiation, its family), directly or through other classes, as far as + * its `extends` clauses name classes. */ +function derivesFrom( + lowerer: Lowerer, + node: ts.ClassLikeDeclaration, + info: ClassInfo, + seen: Set, +): boolean { + if (seen.has(node)) return false; + seen.add(node); + const base = baseExpressionOf(node); + const name = + base === undefined + ? undefined + : ts.isIdentifier(base) + ? base + : ts.isPropertyAccessExpression(base) + ? base.name + : undefined; + let symbol = name === undefined ? undefined : lowerer.checker.getSymbolAtLocation(name); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + if (symbol === undefined) return false; + const named = lowerer.classBySymbol.get(symbol); + if (named !== undefined) + return ( + named === info || + named === info.genericInstance?.family || + lowerer.isSubclassOf(named.def.name, info.def.name) + ); + // A class lowering has not collected: through its declaration. + for (const declaration of symbol.declarations) { + let cls: ts.Node | undefined = declaration; + if (ts.isVariableDeclaration(cls)) { + cls = cls.initializer; + while (cls !== undefined && ts.isParenthesizedExpression(cls)) cls = cls.expression; + } + if ( + cls !== undefined && + (ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) && + derivesFrom(lowerer, cls, info, seen) + ) + return true; + } + return false; +} + +/** `node`'s name, quoted, for diagnostics. */ +function localClassName(node: ts.ClassLikeDeclaration): string { + if (node.name) return `'${node.name.text}'`; + const parent = node.parent; + return parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name) + ? `'${parent.name.text}'` + : "a class expression"; +} + /** The classes `new.target` can denote in the constructor being lowered, * or null after a diagnostic. */ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { @@ -98,6 +182,16 @@ function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null { return null; } } + // Lowering collects a function-local class with the body declaring it, + // which may come after this constructor. + for (const local of functionLocalSubclasses(lowerer)) { + if (!derivesFrom(lowerer, local, info, new Set())) continue; + lowerer.unsupported( + "SC1090", + expr, + `'new.target' where ${localClassName(local)} is a function-local class (each evaluation creates a new class)`, + ); + } let self: IrExpr | null = null; if (classes.length > 1) { const thisLocal = lowerer.resolveThis(expr); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json new file mode 100644 index 000000000..7046e28d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/new-target-late-local-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/new-target-late-local-subclass.ts" + ], + "diags": [] +} diff --git a/tests/diagnostics/new-target-late-local-subclass.ts b/tests/diagnostics/new-target-late-local-subclass.ts new file mode 100644 index 000000000..658a7148e --- /dev/null +++ b/tests/diagnostics/new-target-late-local-subclass.ts @@ -0,0 +1,15 @@ +// A function-local subclass refuses new.target in its base's constructor +// also when lowering collects it after that constructor: make() is lowered +// after the top-level code that constructs Base. +class Base { + readonly label: string; + constructor() { + this.label = new.target.name; + } +} +console.log(new Base().label); +function make(): Base { + const Local = class extends Base {}; + return new Local(); +} +console.log(make().label); diff --git a/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt b/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt new file mode 100644 index 000000000..feb663813 --- /dev/null +++ b/tests/harness/__snapshots__/new-target-late-local-subclass.ts.txt @@ -0,0 +1,6 @@ +new-target-late-local-subclass.ts:7:18 - error SC1090: 'new.target' where 'Local' is a function-local class (each evaluation creates a new class) is not supported yet + + 6 | constructor() { + 7 | this.label = new.target.name; + | ^~~~~~~~~~ + 8 | } \ No newline at end of file From ee79ce16e3880c4e23a1895dd29fa586ad15aa0a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:49:03 -0500 Subject: [PATCH 32/39] Give the zod3-infer npm case the long per-case timeout A sanitized build of zod3-infer compiles zod 3's types module, as zod3's does, and takes about as long as the default 120 s on a CI runner. --- tests/harness/npm.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 13ad3f438..9a13c0968 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -112,10 +112,15 @@ const KNOWN: Record = { const cases = shardSelect(npmCases(fixturesRoot), (c) => c.name); /** Per-case timeouts past the default 120 s: the undici cases compile the - * built-in undici implementation's module graph, and zod3 compiles zod 3's - * 3,700-line types module. A sanitized build of either takes minutes on a - * CI runner. */ -const TIMEOUTS: Record = { undici5: 600_000, undici7: 600_000, zod3: 600_000 }; + * built-in undici implementation's module graph, and zod3 and zod3-infer + * compile zod 3's 3,700-line types module. A sanitized build of either + * takes minutes on a CI runner. */ +const TIMEOUTS: Record = { + undici5: 600_000, + undici7: 600_000, + zod3: 600_000, + "zod3-infer": 600_000, +}; interface RunResult { stdout: Buffer; From 12166956b8e1d3e6fc752be5585832fb72ab700e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:57:29 -0500 Subject: [PATCH 33/39] 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 77c063682675520bb5804eb27db6e48bcdc71b80 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:00:01 -0500 Subject: [PATCH 34/39] Include star re-exports in namespace objects, and read causes and instanceof Error like Node under a runtime Error parent - A module namespace converted to an untyped object, and a computed namespace read, use the module's resolved exports, which include the members of `export *` declarations. - `super(message, options)` through a runtime Error parent installs the cause as Error does: an inherited `cause` counts, the value is read through its getter, and the getter's exception propagates. - `value instanceof Error` on an instance of a class whose heritage is evaluated at run time tests that class's prototype chain for Error.prototype. A program with such a class checks prototype chains for `instanceof Error` on untyped values. - The cache of names written on the global object is a Lowerer field instead of a WeakMap keyed by the Lowerer. - errors.test.ts: `"prototype" in Map` answers from the builtin constructor's keys now, so it leaves the refused reads and joins a check of `in` probes against Node's answers. --- .../lowering/class-dynamic-dispatch.ts | 81 +++++++++++++++++++ .../lowering/class-method-mutations.ts | 26 ++++++ .../src/frontend/lowering/dispatch-sites.ts | 1 + .../lowering/expressions/property-access.ts | 6 +- .../compiler/src/frontend/lowering/lowerer.ts | 9 ++- .../lowering/module-namespace-elements.ts | 21 ++--- packages/compiler/src/frontend/ts7/checker.ts | 8 ++ .../main.js.json | 1 + packages/runtime/src/scr_json.c | 13 ++- ...481-class-runtime-heritage-object-error.js | 27 +++++++ .../7487-namespace-spread-untyped/all.js | 2 + .../7487-namespace-spread-untyped/main.js | 5 ++ tests/harness/errors.test.ts | 9 ++- 13 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 tests/corpus/7487-namespace-spread-untyped/all.js diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 8a4a1e417..e394718d1 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -908,6 +908,11 @@ export class ClassDynamicDispatch { private readonly prototypeClasses = new Set(); private prototypeIdentityHelper: IrFunction | null = null; private readonly prototypeIdentityClasses = new Set(); + /** `value instanceof Error` through Error.prototype: boxed instances of + * classes with a heritage evaluated at run time test their prototype + * data's chain. */ + private errorPrototypeHelper: IrFunction | null = null; + private readonly errorPrototypeClasses = new Set(); private readonly instancePrototypes = new Map }>(); private readonly checkedCasts = new Map }>(); /** The for-in key helpers, by libCall (dyn and native string[] keys). */ @@ -1114,6 +1119,49 @@ export class ClassDynamicDispatch { }); changed = true; } + if ( + this.errorPrototypeHelper && + !this.errorPrototypeClasses.has(className) && + hasRuntimeHeritage(info) + ) { + this.errorPrototypeClasses.add(className); + const loc = this.errorPrototypeHelper.loc; + const type: IrType = { kind: "object", className }; + const value = varRef("value", DYN, loc); + const prototype = classPrototypeData(lowerer, info, loc, { + kind: "dynCheck", + value, + type, + loc, + }); + if (prototype) + this.errorPrototypeHelper.body.unshift({ + kind: "if", + cond: { + kind: "libCall", + fn: "dyn.typedRefIs", + args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }], + type: BOOL, + loc, + }, + then: [ + { + kind: "return", + value: { + kind: "libCall", + fn: "dyn.inheritsErrorPrototype", + args: [prototype], + type: BOOL, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }); + changed = true; + } if ( this.serializesClasses && !this.jsonPrototypes.has(className) && @@ -1516,6 +1564,31 @@ export class ClassDynamicDispatch { } return { kind: "call", callee: cast.fn.name, args: [expr.value], type, loc: expr.loc }; } + if (expr.kind === "libCall" && expr.fn === "dyn.inheritsErrorPrototype") { + if (!this.errorPrototypeHelper) { + const loc = expr.loc; + this.errorPrototypeHelper = { + name: "%dyn.class.inheritsErrorPrototype", + params: [{ localId: "value", name: "value", type: DYN }], + locals: [{ id: "value", name: "value", type: DYN, mutable: false }], + returnType: BOOL, + loc, + body: [ + { kind: "return", value: { ...expr, args: [varRef("value", DYN, loc)] }, loc }, + ], + }; + lowerer.liftedFns.push(this.errorPrototypeHelper); + this.generated.add(this.errorPrototypeHelper); + changed = true; + } + return { + kind: "call", + callee: this.errorPrototypeHelper.name, + args: expr.args, + type: BOOL, + loc: expr.loc, + }; + } if ( expr.kind === "libCall" && expr.fn === "dyn.getPrototype" && @@ -5942,3 +6015,11 @@ const INHERITED_OBJECT_METHODS: ReadonlySet = new Set([ "hasOwnProperty", "propertyIsEnumerable", ]); + +/** Whether a class or one of its bases has a heritage evaluated at run + * time, whose prototype chain may reach Error.prototype. */ +function hasRuntimeHeritage(info: ClassInfo): boolean { + for (let current: ClassInfo | null = info; current; current = current.base) + if (current.callableBase) return true; + return false; +} diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index abbaf3a75..fd23b6db8 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -380,6 +380,13 @@ export function collectClassMethodMutations( markTarget(node.initializer, node); if (ts.isPropertyAccessExpression(node) && isErrorPrototypeValue(lowerer, node)) lowerer.errorPrototypeValues = true; + // A heritage evaluated at run time may be Error, which links the + // class's instances to Error.prototype. + if ( + (ts.isClassDeclaration(node) || ts.isClassExpression(node)) && + runtimeHeritage(lowerer, node) + ) + lowerer.errorPrototypeValues = true; if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { const base = unwrap(node.expression); const prototype = @@ -621,3 +628,22 @@ export function collectClassMethodMutations( if (!receiverFields.has(field)) lowerer.receiverFreeCallbackFields.add(field); } } + +/** Whether a class's `extends` expression is a value computed at run time: + * anything other than a name of a class declaration or of a builtin + * constructor (`extends Parent` over `const Parent = params.Parent ?? Object`, + * `extends make()`). */ +function runtimeHeritage( + lowerer: Lowerer, + node: ts.ClassDeclaration | ts.ClassExpression, +): boolean { + const heritage = node.heritageClauses?.nodes.find( + (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, + )?.types?.nodes[0]?.expression; + if (heritage === undefined) return false; + if (!ts.isIdentifier(heritage) && !ts.isPropertyAccessExpression(heritage)) return true; + let symbol = lowerer.checker.getSymbolAtLocation(heritage); + if (symbol === undefined) return true; + if (symbol.flags & ts.SymbolFlags.Alias) symbol = lowerer.checker.getAliasedSymbol(symbol); + return !(symbol.flags & ts.SymbolFlags.Class) && !lowerer.isStdlibSymbol(symbol); +} diff --git a/packages/compiler/src/frontend/lowering/dispatch-sites.ts b/packages/compiler/src/frontend/lowering/dispatch-sites.ts index c8a8d6ba6..32cff333e 100644 --- a/packages/compiler/src/frontend/lowering/dispatch-sites.ts +++ b/packages/compiler/src/frontend/lowering/dispatch-sites.ts @@ -80,6 +80,7 @@ const DISPATCH_LIB_CALLS = new Set([ "dyn.propertyIsEnumerableComputed", "dyn.defineProperty", "dyn.getPrototype", + "dyn.inheritsErrorPrototype", "dyn.reflectGet", "dyn.reflectSet", ]); diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index d1b9795d5..043ee7d48 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -4504,15 +4504,13 @@ function contextualArgumentCount(lowerer: Lowerer, fn: ts.Node): number | null { return count; } -const globalThisWrites = new WeakMap>(); - /** The property names the program assigns on the global object, through * `globalThis` or a binding of its type (`(_a = globalThis).x = v`). */ function globalThisWrittenNames(lowerer: Lowerer, globalType: ts.Type): Set { - const known = globalThisWrites.get(lowerer); + const known = lowerer.globalThisWrittenNames; if (known) return known; const names = new Set(); - globalThisWrites.set(lowerer, names); + lowerer.globalThisWrittenNames = names; for (const sf of lowerer.program.getImplementationSourceFiles()) { if (sf.isDeclarationFile || !sf.text.includes("globalThis")) continue; ts.walkPreorder(sf, (node) => { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 83ac404d4..c3b555d09 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2969,9 +2969,14 @@ export class Lowerer { * ABI and shared prototype descriptors instead of call-site specialization. */ readonly prototypeMethodAccesses = new Map(); /** Whether the program reads `Error.prototype`, the checked-dynamic - * object an ES5 constructor function can inherit from: only then can a - * value that is not an Error instance be `instanceof Error`. */ + * object an ES5 constructor function can inherit from, or has a class + * whose heritage is evaluated at run time (`extends (Base ?? Error)`): + * only then can a value that is not an Error instance be + * `instanceof Error`. */ errorPrototypeValues = false; + /** The property names the program assigns on the global object + * (property-access.ts), collected on first use. */ + globalThisWrittenNames: Set | null = null; /** Well-known symbol methods (`sym:iterator`) the program may replace * through a symbol-keyed write: an instance or prototype property, or a * reflection call. */ diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index df40573da..cf828c6f0 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -32,12 +32,13 @@ export function lowerModuleNamespaceElement( try { const body: IrStmt[] = []; const symbol = lowerer.checker.getSymbolAtLocation(source); - symbol?.getExports().forEach((exported, key) => { + for (const exported of symbol ? lowerer.checker.getExportsOfModule(symbol) : []) { + const key = ts.symbolName(exported); const target = exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; - if (!(target.flags & ts.SymbolFlags.Value)) return; + if (!(target.flags & ts.SymbolFlags.Value)) continue; const declaration = target.valueDeclaration; const identifier = declaration?.name as ts.Node | undefined; if (!identifier || !ts.isIdentifier(identifier)) { @@ -50,7 +51,7 @@ export function lowerModuleNamespaceElement( kind: "strEq", negated: false, left: varRef("key", STRING, loc), - right: strLit(String(key), loc), + right: strLit(key, loc), type: { kind: "bool" }, loc, }, @@ -58,7 +59,7 @@ export function lowerModuleNamespaceElement( else_: null, loc, }); - }); + } body.push({ kind: "return", value: dynUndefinedExpr(loc), loc }); const fn: IrFunction = { name, @@ -111,10 +112,12 @@ export function moduleNamespaceObject( if (!symbol) return null; const exports: { key: string; identifier: ts.Identifier }[] = []; let exact = true; - symbol.getExports().forEach((exported, key) => { + // The module's resolved exports, which include `export *` members. + for (const exported of lowerer.checker.getExportsOfModule(symbol)) { + const key = ts.symbolName(exported); const target = exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; - if (!(target.flags & ts.SymbolFlags.Value)) return; + if (!(target.flags & ts.SymbolFlags.Value)) continue; const declaration = target.valueDeclaration; const identifier = declaration?.name as ts.Node | undefined; const immutable = @@ -125,10 +128,10 @@ export function moduleNamespaceObject( (ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) !== 0)); if (!identifier || !ts.isIdentifier(identifier) || !immutable) { exact = false; - return; + break; } - exports.push({ key: String(key), identifier }); - }); + exports.push({ key, identifier }); + } if (!exact) return null; exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); const name = `%module.object:${ns.type.moduleId}`; diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index 9550d1dc2..eb2b9e20b 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -631,6 +631,14 @@ export class CheckerFacade { return aliased; } + /** A module symbol's exports, with the members of its `export *` + * declarations resolved (a symbol's own export table lists only the + * module's own declarations). */ + getExportsOfModule(symbol: Ts7Symbol): readonly Ts7Symbol[] { + this.ensureActive(); + return this.raw.getExportsOfModule(symbol); + } + getDeclaredTypeOfSymbol(symbol: Ts7Symbol): Type { this.ensureActive(); let type = this.cache.declaredTypeOfSymbol.get(symbol); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json index 0c768ef66..b8a66c735 100644 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7487-namespace-spread-untyped/main.js.json @@ -2,6 +2,7 @@ "order": [ "/tests/corpus/7487-namespace-spread-untyped/shapes.js", "/tests/corpus/7487-namespace-spread-untyped/extra.js", + "/tests/corpus/7487-namespace-spread-untyped/all.js", "/tests/corpus/7487-namespace-spread-untyped/main.js" ], "diags": [] diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 57c9e4df4..b0abe20ef 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5146,10 +5146,17 @@ void scr_dyn_class_super(ScrDyn *constructor, ScrDyn *receiver, ScrDyn *argument scr_dyn_release(value); if (scr_exc_pending()) return; } - if (argc > 1 && argv[1]->kind == SCR_DYN_OBJ && scr_dyn_find_entry(argv[1], "cause", 5)) { - ScrDyn *cause = scr_dyn_obj_get(argv[1], "cause", 5); + /* InstallErrorCause: an options object with a `cause` property, own or + * inherited, defines the value its getter or data property reads. */ + ScrDyn *options = argc > 1 ? argv[1] : NULL; + if (options && options->kind != SCR_DYN_NULL && + (options->kind == SCR_DYN_FUNC || scr_dyn_is_object(options))) { ScrStr *key = scr_str_new("cause", 5); - scr_dyn_define_hidden(receiver, key, cause ? cause : scr_dyn_undefined()); + if (scr_dyn_in(options, key) && !scr_exc_pending()) { + ScrDyn *cause = scr_dyn_obj_read(options, "cause", 5); + if (cause && !scr_exc_pending()) scr_dyn_define_hidden(receiver, key, cause); + scr_dyn_release(cause); + } scr_str_release(key); if (scr_exc_pending()) return; } diff --git a/tests/corpus/7481-class-runtime-heritage-object-error.js b/tests/corpus/7481-class-runtime-heritage-object-error.js index 13d59dc63..32e17c640 100644 --- a/tests/corpus/7481-class-runtime-heritage-object-error.js +++ b/tests/corpus/7481-class-runtime-heritage-object-error.js @@ -22,3 +22,30 @@ try { } catch (err) { console.log(err instanceof E, err.message); } +console.log(e instanceof Error, new A() instanceof Error); +try { + throw new E("caught"); +} catch (err) { + console.log(err instanceof Error, err.message); +} +// The cause option is read like Error's: inherited and accessor causes +// count, and a throwing getter's exception propagates. +const inherited = new E("inherited", Object.create({ cause: "from prototype" })); +console.log(inherited.cause, Object.prototype.hasOwnProperty.call(inherited, "cause")); +let reads = 0; +const accessor = new E("accessor", { + get cause() { + reads++; + return "from getter"; + }, +}); +console.log(accessor.cause, reads, "cause" in new E("none", {}), "cause" in new E("undef", { cause: undefined })); +try { + new E("throws", { + get cause() { + throw new Error("getter failed"); + }, + }); +} catch (err) { + console.log(err.message); +} diff --git a/tests/corpus/7487-namespace-spread-untyped/all.js b/tests/corpus/7487-namespace-spread-untyped/all.js new file mode 100644 index 000000000..e317afdab --- /dev/null +++ b/tests/corpus/7487-namespace-spread-untyped/all.js @@ -0,0 +1,2 @@ +export * from "./shapes.js"; +export const own = "own"; diff --git a/tests/corpus/7487-namespace-spread-untyped/main.js b/tests/corpus/7487-namespace-spread-untyped/main.js index 021c00ac9..2352e844d 100644 --- a/tests/corpus/7487-namespace-spread-untyped/main.js +++ b/tests/corpus/7487-namespace-spread-untyped/main.js @@ -9,3 +9,8 @@ const z = untyped({ ...shapes, ...extra, extra }); console.log(Object.keys(z).join(",")); for (const key of ["circle", "square"]) console.log(key, z[key](2).area.toFixed(2)); console.log(z.VERSION, typeof z.extra.tag, z.extra.tag("x"), new z.Box(4).size); +// A namespace with star re-exports holds the re-exported members too. +import * as all from "./all.js"; +const w = untyped({ ...all }); +console.log(Object.keys(w).join(","), w.circle(1).area.toFixed(2), w.own, new w.Box(2).size); +for (const key of ["circle", "own", "VERSION", "missing"]) console.log(key, typeof all[key]); diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index c8b0b51b8..d6bee86d6 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1711,9 +1711,10 @@ describe("builtin constructor values (JS lane)", () => { test("members without a lowering stay refused instead of reading undefined", async () => { // Builtin constructors read as interned function values that model only // name, length and constructor. Every other static member or prototype - // read (direct, bracketed, `in`, through an alias or globalThis), and + // read (direct, bracketed, through an alias or globalThis), and // reflection on or redefinition of their members, must stay a located // SC2020/SC1090 refusal, never an undefined or otherwise wrong value. + // `key in Ctor` answers from the constructor's fixed keys. // Test262 lost 769 refusals to undefined reads before this was pinned. const reads = [ ["Map.prototype", "Map.prototype"], @@ -1731,7 +1732,6 @@ describe("builtin constructor values (JS lane)", () => { ], ["isExtensible(Date.prototype)", "Object.isExtensible(Date.prototype)"], ["Map['prototype']", 'Map["prototype"]'], - ["'prototype' in Map", '"prototype" in Map'], ["globalThis.Map.prototype", "globalThis.Map.prototype"], ["Map alias prototype", "MapAlias.prototype"], ["Date alias prototype", "DateAlias.prototype"], @@ -1754,6 +1754,7 @@ console.log(Date.length, RegExp.length, Promise.length, Object.length, Map.lengt console.log(Date.name, Map.name, Map.constructor === Function, Object.constructor === Function); const promisePrototype = Promise.prototype; console.log(typeof promisePrototype, typeof promisePrototype.then, promisePrototype.constructor === Promise); +console.log("prototype" in Map, "groupBy" in Map, "captureStackTrace" in Error, "nope" in Map); `; const r = await compileAndRun("builtin-constructor-members", source, "cjs"); expect(r.stderr).toBe(""); @@ -1768,11 +1769,13 @@ console.log(typeof promisePrototype, typeof promisePrototype.then, promiseProtot ), ), ); - // Promise.prototype reads as an object holding the runtime's methods. + // Promise.prototype reads as an object holding the runtime's methods, + // and `in` on a builtin constructor answers from its fixed keys. expect(lines.slice(reads.length)).toEqual([ "7 2 1 1 0 0", "Date Map true true", "object function true", + "true true true false", ]); }); }); From 143f2e11e9c11dada9f75a75fff4b80978196bd7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:15:57 -0500 Subject: [PATCH 35/39] Keep an own stackTraceLimit on the Error value ahead of the limit --- .../corpus/7603-error-stack-limit-own.js.json | 6 +++++ .../7604-error-stack-limit-frozen.js.json | 6 +++++ packages/runtime/src/scr_json.c | 23 +++++++++++++++++-- tests/corpus/7603-error-stack-limit-own.js | 13 +++++++++++ tests/corpus/7604-error-stack-limit-frozen.js | 9 ++++++++ .../7603-error-stack-limit-own.js.json | 1 + .../7604-error-stack-limit-frozen.js.json | 1 + 7 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7603-error-stack-limit-own.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7604-error-stack-limit-frozen.js.json create mode 100644 tests/corpus/7603-error-stack-limit-own.js create mode 100644 tests/corpus/7604-error-stack-limit-frozen.js create mode 100644 tests/harness/corpus-costs/7603-error-stack-limit-own.js.json create mode 100644 tests/harness/corpus-costs/7604-error-stack-limit-frozen.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7603-error-stack-limit-own.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7603-error-stack-limit-own.js.json new file mode 100644 index 000000000..5ed656fa9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7603-error-stack-limit-own.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7603-error-stack-limit-own.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7604-error-stack-limit-frozen.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7604-error-stack-limit-frozen.js.json new file mode 100644 index 000000000..a437ff57e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7604-error-stack-limit-frozen.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7604-error-stack-limit-frozen.js" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 074fe3eae..bc6c9338a 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1878,6 +1878,17 @@ static bool scr_dyn_is_stack_limit(const ScrDyn *d, const char *key, size_t key_ scr_error_ctor_values[0] && d->v.fn.clo == scr_error_ctor_values[0]->v.fn.clo; } +/* An own `stackTraceLimit` on the Error value, assigned a non-number or + * defined with Object.defineProperty, replaces the limit for reads and + * writes through the value; writes to it keep its descriptor. */ +static bool scr_dyn_stack_limit_shadowed(const ScrDyn *d) { + ScrClosure *owner = scr_closure_identity(d->v.fn.clo); + ScrDyn *table = owner->props ? (ScrDyn *)scr_box_get_ref(owner->props) : NULL; + bool own = table && scr_dyn_find_entry(table, "stackTraceLimit", 15); + scr_dyn_release(table); + return own; +} + ScrDyn *scr_dyn_object_prototype(void) { if (!scr_builtin_object_prototype) { scr_builtin_object_prototype = scr_dyn_alloc(SCR_DYN_OBJ); @@ -9000,12 +9011,19 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { } if (recv->kind == SCR_DYN_FUNC) { if (scr_dyn_fn_property_fence(key)) return; - if (value->kind == SCR_DYN_NUM && scr_dyn_is_stack_limit(recv, key->data, key->len)) { + bool limit = scr_dyn_is_stack_limit(recv, key->data, key->len); + /* A frozen Error keeps the limit read-only. */ + if (limit && value->kind == SCR_DYN_NUM && !scr_dyn_stack_limit_shadowed(recv) && !scr_dyn_is_frozen(recv)) { scr_stack_limit_set(value->v.num); return; } ScrDyn *table = scr_dyn_fn_properties(recv); scr_dyn_object_key_set(table, key, value, recv); + /* A number the own data property takes also sets the limit. */ + ScrDynEntry *own = limit && value->kind == SCR_DYN_NUM && !scr_exc_pending() + ? scr_dyn_find_entry(table, key->data, key->len) + : NULL; + if (own && own->value == value) scr_stack_limit_set(value->v.num); scr_dyn_release(table); return; } @@ -11785,7 +11803,8 @@ static ScrDyn *scr_function_constructor(uint32_t kind) { /* Keyed read on a FUNC node (see scr_runtime.h): own props first, then * the function-instance built-ins name/length. +1 or NULL. */ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) { - if (scr_dyn_is_stack_limit(d, key, key_len)) return scr_dyn_new_num(scr_stack_limit_get()); + if (scr_dyn_is_stack_limit(d, key, key_len) && !scr_dyn_stack_limit_shadowed(d)) + return scr_dyn_new_num(scr_stack_limit_get()); ScrClosure *owner = scr_closure_identity(d->v.fn.clo); if (d->v.fn.class_obj && d->v.fn.class_obj->static_data && !owner->props) owner->props = scr_box_retain(d->v.fn.class_obj->static_data); diff --git a/tests/corpus/7603-error-stack-limit-own.js b/tests/corpus/7603-error-stack-limit-own.js new file mode 100644 index 000000000..dfd4b7cdf --- /dev/null +++ b/tests/corpus/7603-error-stack-limit-own.js @@ -0,0 +1,13 @@ +// An own stackTraceLimit on the Error constructor held as a value (a +// non-number assigned, or one defined with Object.defineProperty) answers +// reads through the value, and writes to it keep its descriptor. +const E = /** @type {any} */ (Error); +E.stackTraceLimit = "deep"; +console.log(E.stackTraceLimit, typeof E.stackTraceLimit); +E.stackTraceLimit = 2; +console.log(E.stackTraceLimit, Error.stackTraceLimit); +Object.defineProperty(E, "stackTraceLimit", { value: 6, writable: false, configurable: true }); +try { + E.stackTraceLimit = 1; +} catch {} +console.log(E.stackTraceLimit); diff --git a/tests/corpus/7604-error-stack-limit-frozen.js b/tests/corpus/7604-error-stack-limit-frozen.js new file mode 100644 index 000000000..9c73d1403 --- /dev/null +++ b/tests/corpus/7604-error-stack-limit-frozen.js @@ -0,0 +1,9 @@ +// A frozen Error keeps stackTraceLimit read-only through the constructor +// held as a value. +const E = /** @type {any} */ (Error); +E.stackTraceLimit = 3; +Object.freeze(E); +try { + E.stackTraceLimit = 1; +} catch {} +console.log(E.stackTraceLimit, Error.stackTraceLimit); diff --git a/tests/harness/corpus-costs/7603-error-stack-limit-own.js.json b/tests/harness/corpus-costs/7603-error-stack-limit-own.js.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/7603-error-stack-limit-own.js.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } diff --git a/tests/harness/corpus-costs/7604-error-stack-limit-frozen.js.json b/tests/harness/corpus-costs/7604-error-stack-limit-frozen.js.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/7604-error-stack-limit-frozen.js.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } From f030e5ad44418e398dbfc395a054d3ae04bd3a28 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:36:11 -0500 Subject: [PATCH 36/39] Check with tsc-ts 0.0.8 on every platform tsc-ts 0.0.8's API server serves on the named pipe the Node host connects over on Windows (tsc-ts --api --pipe), and the native compiler already starts it over its standard streams there, so tsc-ts is the default checker on every platform. SCRIPTC_CHECKER=tsgo still selects tsgo. The compiler depends on tsc-ts 0.0.8, which the release-age exclusions admit with its platform packages and @scriptc/threads 0.4.2. The Windows CLI smoke test runs with both checkers, and verify-native-cli.mjs --run checks that a distribution's tsc-ts runs on its platform (tsc-ts.exe on Windows). --- .github/workflows/ci.yml | 19 +++-- docs/content/docs/cli.mdx | 2 +- packages/compiler/package.json | 2 +- .../src/frontend/ts7/checker-backend.test.ts | 14 ++-- .../src/frontend/ts7/checker-backend.ts | 12 +-- .../compiler/src/native/toolchain.test.ts | 6 +- packages/compiler/src/native/toolchain.ts | 8 +- pnpm-lock.yaml | 74 +++++++++---------- pnpm-workspace.yaml | 18 ++--- scripts/verify-native-cli.mjs | 10 +++ 10 files changed, 86 insertions(+), 79 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a100e374..f131747fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -773,17 +773,22 @@ jobs: run: >- pnpm exec vitest run tests/harness/worktree-files.test.ts --testNamePattern "workspace reset keeps Linux Sandbox paths POSIX" + # The Node host checks with tsc-ts, the default, over the named pipe it + # serves on Windows, and with tsgo. - name: Windows scriptc run smoke shell: pwsh run: | zig version - $output = & node packages/cli/dist/main.js run tests/corpus/001-hello.ts --backend llvm - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - $actual = ($output -join "`n").Trim() - if ($actual -ne "hello world") { - throw "unexpected scriptc output: $actual" + foreach ($checker in @("tsc-ts", "tsgo")) { + $env:SCRIPTC_CHECKER = $checker + $output = & node packages/cli/dist/main.js run tests/corpus/001-hello.ts --backend llvm + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $actual = ($output -join "`n").Trim() + if ($actual -ne "hello world") { + throw "unexpected scriptc output with ${checker}: $actual" + } } - name: Windows PE subsystem selection env: diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 8c57d2e2a..8b0353620 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -206,7 +206,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
A runtime pack directory to use instead of an installed @scriptc/runtime-<target> package.
SCRIPTC_CHECKER
-
The TypeScript 7 API server that checks the program: tsc-ts (the default), which scriptc includes, or tsgo, from the typescript package scriptc also includes. Both give the same diagnostics and generated code. On Windows the default is tsgo, because tsc-ts's API server does not yet accept the named pipe scriptc connects over there.
+
The TypeScript 7 API server that checks the program: tsc-ts (the default), which scriptc includes, or tsgo, from the typescript package scriptc also includes. Both give the same diagnostics and generated code.
```console diff --git a/packages/compiler/package.json b/packages/compiler/package.json index a1d66963d..a358ae545 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@scriptc/runtime": "workspace:*", - "tsc-ts": "0.0.7", + "tsc-ts": "0.0.8", "typescript": "7.0.2", "typescript5": "npm:typescript@5.9.3" }, diff --git a/packages/compiler/src/frontend/ts7/checker-backend.test.ts b/packages/compiler/src/frontend/ts7/checker-backend.test.ts index e37357afa..cd37bafff 100644 --- a/packages/compiler/src/frontend/ts7/checker-backend.test.ts +++ b/packages/compiler/src/frontend/ts7/checker-backend.test.ts @@ -24,15 +24,11 @@ function writePackage(directory: string, name: string, executable?: string): voi writeFileSync(join(directory, "lib", executable), ""); } -test("tsc-ts is the default checker except on Windows, and SCRIPTC_CHECKER selects one", () => { - expect(checkerBackend({}, "linux")).toBe("tsc-ts"); - expect(checkerBackend({ SCRIPTC_CHECKER: "" }, "linux")).toBe("tsc-ts"); - expect(checkerBackend({}, "darwin")).toBe("tsc-ts"); - expect(checkerBackend({}, "win32")).toBe("tsgo"); - expect(checkerBackend({ SCRIPTC_CHECKER: "" }, "win32")).toBe("tsgo"); - expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" }, "linux")).toBe("tsc-ts"); - expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" }, "win32")).toBe("tsc-ts"); - expect(checkerBackend({ SCRIPTC_CHECKER: "tsgo" }, "linux")).toBe("tsgo"); +test("tsc-ts is the default checker, and SCRIPTC_CHECKER selects one", () => { + expect(checkerBackend({})).toBe("tsc-ts"); + expect(checkerBackend({ SCRIPTC_CHECKER: "" })).toBe("tsc-ts"); + expect(checkerBackend({ SCRIPTC_CHECKER: "tsc-ts" })).toBe("tsc-ts"); + expect(checkerBackend({ SCRIPTC_CHECKER: "tsgo" })).toBe("tsgo"); expect(() => checkerBackend({ SCRIPTC_CHECKER: "tsc" })).toThrow( 'SCRIPTC_CHECKER must be tsgo or tsc-ts, not "tsc"', ); diff --git a/packages/compiler/src/frontend/ts7/checker-backend.ts b/packages/compiler/src/frontend/ts7/checker-backend.ts index 14418cc94..0b32f24f6 100644 --- a/packages/compiler/src/frontend/ts7/checker-backend.ts +++ b/packages/compiler/src/frontend/ts7/checker-backend.ts @@ -4,17 +4,13 @@ import { fileURLToPath } from "node:url"; /** The TypeScript 7 API servers a compile can check with: tsc-ts, the default, * and tsgo from the `typescript` package, selected with `SCRIPTC_CHECKER=tsgo`. - * Both serve the same API and give the same results. On Windows the default is - * tsgo: the compiler connects to the server over a named pipe there, and tsc-ts's - * API server has no named-pipe transport yet. */ + * Both serve the same API and give the same results. On Windows the Node host + * connects to either over a named pipe the server creates (rpc-process.ts). */ export type CheckerBackend = "tsgo" | "tsc-ts"; -export function checkerBackend( - env: NodeJS.ProcessEnv = process.env, - platform: string = process.platform, -): CheckerBackend { +export function checkerBackend(env: NodeJS.ProcessEnv = process.env): CheckerBackend { const value = env["SCRIPTC_CHECKER"]; - if (value === undefined || value === "") return platform === "win32" ? "tsgo" : "tsc-ts"; + if (value === undefined || value === "") return "tsc-ts"; if (value === "tsc-ts") return "tsc-ts"; if (value === "tsgo") return "tsgo"; throw new Error(`SCRIPTC_CHECKER must be tsgo or tsc-ts, not ${JSON.stringify(value)}`); diff --git a/packages/compiler/src/native/toolchain.test.ts b/packages/compiler/src/native/toolchain.test.ts index 0ee434300..ad4570c0c 100644 --- a/packages/compiler/src/native/toolchain.test.ts +++ b/packages/compiler/src/native/toolchain.test.ts @@ -99,11 +99,11 @@ test("native toolchain assets resolve from the physical manifest through package const direct = loadNativeToolchain(path); const viaLink = loadNativeToolchain(join(linked, "bin", "scriptc.exe.json")); expect(viaLink).toEqual(direct); - expect(viaLink.ts7Executable).toBe(join(assets, "typescript/lib/tsc.exe")); + expect(viaLink.ts7Executable).toBe(join(assets, "tsc-ts/lib/tsc-ts.exe")); expect( - loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), { SCRIPTC_CHECKER: "tsc-ts" }) + loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), { SCRIPTC_CHECKER: "tsgo" }) .ts7Executable, - ).toBe(join(assets, "tsc-ts/lib/tsc-ts.exe")); + ).toBe(join(assets, "typescript/lib/tsc.exe")); expect(viaLink.helperExecutable).toBe(join(assets, "llvm/bin/scriptc-llvm-codegen.exe")); expect( loadNativeToolchain(join(linked, "bin", "scriptc.exe.json"), { diff --git a/packages/compiler/src/native/toolchain.ts b/packages/compiler/src/native/toolchain.ts index fbcd94251..5abbb329e 100644 --- a/packages/compiler/src/native/toolchain.ts +++ b/packages/compiler/src/native/toolchain.ts @@ -177,11 +177,11 @@ export function loadNativeToolchain( : [...target.defaultLinkerArgs]; return { compilerVersion: manifest.compiler_version, - // tsc-ts, the default checker except on Windows, comes with the distribution; - // a development build takes it from the tsc-ts package installed with the - // compiler or for the working directory. SCRIPTC_CHECKER selects a checker. + // tsc-ts, the default checker, comes with the distribution; a development + // build takes it from the tsc-ts package installed with the compiler or for + // the working directory. SCRIPTC_CHECKER selects a checker. ts7Executable: - checkerBackend(env, host.platform) === "tsgo" + checkerBackend(env) === "tsgo" ? pathFrom(root, manifest.ts7) : manifest.tsc_ts !== undefined ? pathFrom(root, manifest.tsc_ts) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 589de2a51..525994ba1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,8 +110,8 @@ importers: specifier: workspace:* version: link:../runtime tsc-ts: - specifier: 0.0.7 - version: 0.0.7 + specifier: 0.0.8 + version: 0.0.8 typescript: specifier: 7.0.2 version: 7.0.2 @@ -1360,8 +1360,8 @@ packages: cpu: [x64] os: [win32] - '@scriptc/threads@0.4.1': - resolution: {integrity: sha512-wKgEBiVc07ZIGNFGwJpV5JbTXJN1N42qG6/g7ipnwt3klsU0nuWH2ToKV9vYH+RQstF7+twq6zMCf2qhUloTHQ==} + '@scriptc/threads@0.4.2': + resolution: {integrity: sha512-nR9FUxpOpgtTmJu6dejttElBL+JJTAZzJ2DZM+eNoujmLx9VpdlAVdgj3cu/MDsTUg/1sIUBq1ME1csC/Aa6yg==} engines: {node: '>=24.0.0'} '@sinclair/typebox@0.25.24': @@ -1377,42 +1377,42 @@ packages: '@ts-morph/common@0.11.1': resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} - '@tsc-ts/darwin-arm64@0.0.7': - resolution: {integrity: sha512-nIaTJKBpG6n60xxRYfBlh02G6aSO2punIvj4qPy8/CXZu0Kw4ra/J3PYJtrtYMIh/eIGo4+f1Q01v5dFPPwZbw==} + '@tsc-ts/darwin-arm64@0.0.8': + resolution: {integrity: sha512-+hB9yWk58lwZHGrVEzqEC4D7MQW7Vd2z7blV3TULIKZPTMwdf8aT7XFAmfP48PoGff0H9yuQKyRXJl9I+sQv9w==} cpu: [arm64] os: [darwin] - '@tsc-ts/darwin-x64@0.0.7': - resolution: {integrity: sha512-8ITDyF3FG4SYeS1cV4+oRpbbk0jVsfd23Ww138wbQymbOLYjWPafzj4bymxnIE5kEWjZPVqTFDbaYqgWwNStag==} + '@tsc-ts/darwin-x64@0.0.8': + resolution: {integrity: sha512-yz4UddicqMXe+T7nV7ftpf155lkiK6gIPNwSB8rH9dlwiV6a17CO/vpSk627QLMqnfGNicB6YwLMk/SJ9moPdw==} cpu: [x64] os: [darwin] - '@tsc-ts/linux-arm64-gnu@0.0.7': - resolution: {integrity: sha512-nXGjtbM3YrARWOb+nkDaFdhKGSzPf3ofl/gV4x8JnVQ8SgK4IWJ5J0u93sk3mTYgdLj/Gcobp/D3lZ6WIzjKvA==} + '@tsc-ts/linux-arm64-gnu@0.0.8': + resolution: {integrity: sha512-LHqfT9mQ4tusPdWmnanCvC8/YBOF5bHM5h5863a3t2HTgFVdps1Qwb0yzrW5DDbojIhQ0Z+GupxtXVUQcV0Q/Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@tsc-ts/linux-arm64-musl@0.0.7': - resolution: {integrity: sha512-hSjpQsOhrLaDsbvEO5EH9nC3Vy0WAt3aB+GejytEh9fl4F3kT0BasbBP4wJqu6S5SG9upLW7ZClXiNVnGYDGlQ==} + '@tsc-ts/linux-arm64-musl@0.0.8': + resolution: {integrity: sha512-I0CC3xZMwoQM6OiVsubRHb0VI/RrJpneb33kDQhadYHq3taPos71KwP+CbCXNu88rNsDsyn7D1oCVfJb73g4Mw==} cpu: [arm64] os: [linux] libc: [musl] - '@tsc-ts/linux-x64-gnu@0.0.7': - resolution: {integrity: sha512-MCU2MFefQGG/++nepg09BtYnsN2mVzeYfIS6GW30ChxUt2nJUMkEvzbDim9czDevW4LRqHgfnwEt+hs2ud1SDQ==} + '@tsc-ts/linux-x64-gnu@0.0.8': + resolution: {integrity: sha512-PLERuwUuxT92x0BZAMQubiQP5jhlqoLmswaZWqcvfKAZyBp0TbWiVOmuNnDbJozvBaNUrfdGPb9suiQ+Me7M6A==} cpu: [x64] os: [linux] libc: [glibc] - '@tsc-ts/linux-x64-musl@0.0.7': - resolution: {integrity: sha512-abTZZkn74772MPABStuXWJ1GHI07nbEf4a3Nwl7NYI5mLFYxeLSbq6+cGh0BUJeBeMhFNsjtQ5/j5sGmsrJ2tg==} + '@tsc-ts/linux-x64-musl@0.0.8': + resolution: {integrity: sha512-zDl36l1uyWc0mQniOAMZmkVD9TrlFExHlDT0fL8JGD4XLgx+bRe+To38yP0KSBehXBzjDNn8xrEoJ+3RP6mYwQ==} cpu: [x64] os: [linux] libc: [musl] - '@tsc-ts/win32-x64-msvc@0.0.7': - resolution: {integrity: sha512-EOhLTkuWYytJYklR5BI1jRtTpEIPh1FQJKD8R37qqlcnPrELlRO6Nc2ncDpdCoYbpN+KrewIVPB3xD4o3VGUaw==} + '@tsc-ts/win32-x64-msvc@0.0.8': + resolution: {integrity: sha512-9gO+pFoLiIxITlAE2P6JL8ihZwns7z/2Raw5+38RqLzHmnSsRN47+H4p/4EqQsLW5/TTsAo9uSeSkf3EuRg+XA==} cpu: [x64] os: [win32] @@ -2577,8 +2577,8 @@ packages: ts-toolbelt@6.15.5: resolution: {integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==} - tsc-ts@0.0.7: - resolution: {integrity: sha512-KRWTXRUvwMXi4At6xOWPDa/0BreY3MR2tPHooEMNWLZUA2eLphNe7PJiHclPLLn9RCnHY4ffDTTeF5hqgyBvtA==} + tsc-ts@0.0.8: + resolution: {integrity: sha512-EiyDziYOryBg/YEvev/mKCn+hD4M6VFjH7VP9nv2WadDKYaPrdb+a3hP+wYr/UsDWNE7AdL7YGnjZppeXlXy6g==} engines: {node: '>=20'} hasBin: true @@ -3439,7 +3439,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@scriptc/threads@0.4.1': {} + '@scriptc/threads@0.4.2': {} '@sinclair/typebox@0.25.24': {} @@ -3454,25 +3454,25 @@ snapshots: mkdirp: 1.0.4 path-browserify: 1.0.1 - '@tsc-ts/darwin-arm64@0.0.7': + '@tsc-ts/darwin-arm64@0.0.8': optional: true - '@tsc-ts/darwin-x64@0.0.7': + '@tsc-ts/darwin-x64@0.0.8': optional: true - '@tsc-ts/linux-arm64-gnu@0.0.7': + '@tsc-ts/linux-arm64-gnu@0.0.8': optional: true - '@tsc-ts/linux-arm64-musl@0.0.7': + '@tsc-ts/linux-arm64-musl@0.0.8': optional: true - '@tsc-ts/linux-x64-gnu@0.0.7': + '@tsc-ts/linux-x64-gnu@0.0.8': optional: true - '@tsc-ts/linux-x64-musl@0.0.7': + '@tsc-ts/linux-x64-musl@0.0.8': optional: true - '@tsc-ts/win32-x64-msvc@0.0.7': + '@tsc-ts/win32-x64-msvc@0.0.8': optional: true '@tybys/wasm-util@0.10.3': @@ -4813,17 +4813,17 @@ snapshots: ts-toolbelt@6.15.5: {} - tsc-ts@0.0.7: + tsc-ts@0.0.8: dependencies: - '@scriptc/threads': 0.4.1 + '@scriptc/threads': 0.4.2 optionalDependencies: - '@tsc-ts/darwin-arm64': 0.0.7 - '@tsc-ts/darwin-x64': 0.0.7 - '@tsc-ts/linux-arm64-gnu': 0.0.7 - '@tsc-ts/linux-arm64-musl': 0.0.7 - '@tsc-ts/linux-x64-gnu': 0.0.7 - '@tsc-ts/linux-x64-musl': 0.0.7 - '@tsc-ts/win32-x64-msvc': 0.0.7 + '@tsc-ts/darwin-arm64': 0.0.8 + '@tsc-ts/darwin-x64': 0.0.8 + '@tsc-ts/linux-arm64-gnu': 0.0.8 + '@tsc-ts/linux-arm64-musl': 0.0.8 + '@tsc-ts/linux-x64-gnu': 0.0.8 + '@tsc-ts/linux-x64-musl': 0.0.8 + '@tsc-ts/win32-x64-msvc': 0.0.8 tslib@2.8.1: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ceb1506e9..e74d40442 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,15 +7,15 @@ minimumReleaseAgeExclude: - "effect@4.0.0" - "@effect/platform-node@4.0.0" - "@effect/platform-node-shared@4.0.0" - - "@tsc-ts/darwin-arm64@0.0.7" - - "@tsc-ts/darwin-x64@0.0.7" - - "@tsc-ts/linux-arm64-gnu@0.0.7" - - "@tsc-ts/linux-arm64-musl@0.0.7" - - "@tsc-ts/linux-x64-gnu@0.0.7" - - "@tsc-ts/linux-x64-musl@0.0.7" - - "@tsc-ts/win32-x64-msvc@0.0.7" - - "tsc-ts@0.0.7" - - "@scriptc/threads@0.4.1" + - "@tsc-ts/darwin-arm64@0.0.8" + - "@tsc-ts/darwin-x64@0.0.8" + - "@tsc-ts/linux-arm64-gnu@0.0.8" + - "@tsc-ts/linux-arm64-musl@0.0.8" + - "@tsc-ts/linux-x64-gnu@0.0.8" + - "@tsc-ts/linux-x64-musl@0.0.8" + - "@tsc-ts/win32-x64-msvc@0.0.8" + - "tsc-ts@0.0.8" + - "@scriptc/threads@0.4.2" allowBuilds: esbuild: true overrides: diff --git a/scripts/verify-native-cli.mjs b/scripts/verify-native-cli.mjs index 115573b5c..92de57938 100644 --- a/scripts/verify-native-cli.mjs +++ b/scripts/verify-native-cli.mjs @@ -94,5 +94,15 @@ if (process.argv.includes("--run")) { }).trim(); if (version !== identity.version) throw new Error(`native command reported ${version}, expected ${identity.version}`); + // tsc-ts, the default checker, runs on the distribution's own platform. + const tscTs = resolve(dirname(binary), hostPath(toolchain.tsc_ts)); + if (windows && !tscTs.endsWith("tsc-ts.exe")) + throw new Error(`tsc_ts is not tsc-ts.exe: ${tscTs}`); + const tscTsVersion = execFileSync(tscTs, ["--version"], { + encoding: "utf8", + env: { ...process.env, PATH: "" }, + }).trim(); + if (!tscTsVersion.startsWith("Version ")) + throw new Error(`the distribution's tsc-ts reported ${JSON.stringify(tscTsVersion)}`); } console.log(`${identity.name}@${identity.version}: native distribution verified`); From 1e35b1c56c46d743ed7c4fb010b844d0222572b8 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:36:34 -0500 Subject: [PATCH 37/39] Build the native compiler again The self-hosted compiler spreads only known record shapes and tests a value against null with == or != only as a plain read (SC1090). rewriteCandidates in int32-arrays.ts spread whole IR statement and expression unions: each spread now names one kind. The loop and block cases spread per kind, the hoisted read and sequence rewrites narrow with an if, switch cases spread through a typed helper, and the inlined ternary, a fresh copy, takes the call's location directly. The checked int32 array test in int32-slots.ts binds the range it reads first. --- packages/compiler/src/ir/int32-arrays.ts | 21 +++++++++++++++++---- packages/compiler/src/ir/int32-slots.ts | 5 ++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts index bdb3f21e2..02de7268f 100644 --- a/packages/compiler/src/ir/int32-arrays.ts +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -1,4 +1,6 @@ import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js"; + +type SwitchCase = Extract["cases"][number]; import { reparseExpr } from "./serialize.js"; import { everyExprChild, @@ -382,20 +384,26 @@ function rewriteCandidates( if (!call || simple(call.args[1]!)) return [s]; const { decl, ref } = bind(call.args[1]!); const read: IrExpr = { ...call, args: [call.args[0]!, ref] }; - return [decl, s.kind === "varDecl" ? { ...s, init: read } : { ...s, value: read }]; + if (s.kind === "varDecl") return [decl, { ...s, init: read }]; + return [decl, { ...s, value: read }]; }); + const hoistCase = (c: SwitchCase): SwitchCase => ({ ...c, body: hoist(c.body) }); const lists = (s: IrStmt): IrStmt => { switch (s.kind) { case "if": return { ...s, then: hoist(s.then), else_: s.else_ === null ? null : hoist(s.else_) }; case "block": + return { ...s, body: hoist(s.body) }; case "while": + return { ...s, body: hoist(s.body) }; case "doWhile": + return { ...s, body: hoist(s.body) }; case "for": + return { ...s, body: hoist(s.body) }; case "forOf": return { ...s, body: hoist(s.body) }; case "switch": - return { ...s, cases: s.cases.map((c) => ({ ...c, body: hoist(c.body) })) }; + return { ...s, cases: s.cases.map((c) => hoistCase(c)) }; case "tryCatch": return { ...s, @@ -425,7 +433,10 @@ function rewriteCandidates( }; } const call = readerCall(e); - if (!call) return e.kind === "seqExpr" ? { ...e, stmts: hoist(e.stmts) } : e; + if (!call) { + if (e.kind === "seqExpr") return { ...e, stmts: hoist(e.stmts) }; + return e; + } const helper = readers.get(call.callee)!; const array = call.args[0]!; const [arrayParam, indexParam] = helper.params; @@ -448,7 +459,9 @@ function rewriteCandidates( }, stmt: (x) => x, }); - const typed: IrExpr = { ...body, loc: e.loc }; + // The ternary is a fresh copy (reparseExpr): it takes the call's location. + const typed = body; + typed.loc = e.loc; return stmts.length === 0 ? typed : { kind: "seqExpr", stmts, result: typed, type: typed.type, loc: e.loc }; diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index e6491ba80..4a2abc5b0 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -1131,7 +1131,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): const key = `l:${fn.name}\0${id}`; if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue; slots.dropInt32Elements(key); - const checkable = (value: IrExpr): boolean => int32(value) || ranges.get(value) != null; + const checkable = (value: IrExpr): boolean => { + const range = ranges.get(value); + return int32(value) || range != null; + }; if (values.every(checkable) && checkableInt32Allocations(fn, id)) slots.checkedInt32Arrays.add(`${fn.name}\0${id}`); else reason(`array:${fn.name}\0${id}`, "an element store is not int32"); From 1e4889bebeee87710ff37aa565272bc0369d41c6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:44:30 -0500 Subject: [PATCH 38/39] Declare the stream write-with-encoding helper's IrExpr result --- packages/compiler/src/frontend/lowering/lower-stream.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index e35f5564f..c37ae9074 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -5683,7 +5683,10 @@ export function lowerStreamMethodCall( // argument reaches an object-mode side's _write as passed, null when // the callback took its place (a byte-mode side encodes a string // chunk with it). - const writeEnc = (encArg: ts.Expression | undefined, cbArg: ts.Expression | undefined) => { + const writeEnc = ( + encArg: ts.Expression | undefined, + cbArg: ts.Expression | undefined, + ): IrExpr => { const receiver = streamReceiverExpr(lowerer, access.expression); const chunk = lowerer.lowerExprExpecting(args[0]!, DYN); const tail: IrExpr[] = []; @@ -5696,7 +5699,7 @@ export function lowerStreamMethodCall( args: [receiver, numLit(flags, loc), chunk, ...tail], type: BOOL, loc, - } satisfies IrExpr; + }; }; if (kind === "dyn") { if (args.length === 3) return writeEnc(args[1], args[2]); From e3e955eda084a4599f95f69eba34c2d264d1bd1b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:55:26 -0500 Subject: [PATCH 39/39] 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 }