From 054cb50ea4381566b4438a0e5b44448b426e4c04 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 02:31:20 -0500 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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 ac940ed936ad5c906812fac1b10555c155a52fa9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 15:30:16 -0700 Subject: [PATCH 05/23] 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 06/23] 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 7b79d73a550e530a4a20541ef5428e854a331fa5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 20:52:38 -0500 Subject: [PATCH 07/23] 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 7f7e11a1a749beb0f14d340fdb193e99e013c18b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 22 Oct 2026 18:21:31 -0700 Subject: [PATCH 08/23] 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 107d773382a6f83189b2ff959e5de89de6b3537e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:14:00 -0500 Subject: [PATCH 09/23] 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 10/23] 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 6115135d96affbf01e4960f270e9e7eac2e22ed5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 21:18:48 -0500 Subject: [PATCH 11/23] 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 12/23] 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 13/23] 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 14/23] 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 15/23] 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 16/23] 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 17/23] 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 18/23] 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 19/23] 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 77c063682675520bb5804eb27db6e48bcdc71b80 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 22:00:01 -0500 Subject: [PATCH 20/23] 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 21/23] 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 22/23] 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 23/23] 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");