From 279d42e5624436b28d70178e95e4098abbab6c6a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:11:41 -0700 Subject: [PATCH 01/15] Lower a discarded `undefined` catch handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Promise.catch(() => undefined)` tripped the IR validator's bare-unitLit rule: the handler body lowered as a bare expression, and `undefined` has no standalone IR value. The handler's value is discarded, so lower the body as a statement — the same rule a discarded `undefined;` statement already follows. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 5 ++- tests/corpus/promise-void-catch-undefined.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/promise-void-catch-undefined.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 6229917a2..45d470c60 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -6908,7 +6908,10 @@ export function lowerPromiseMethodCall( if (ts.isBlock(hb)) { catchBody = lowerer.lowerStmts(hb.statements.nodes); } else if (R.kind === "void") { - catchBody = [{ kind: "exprStmt", expr: lowerer.lowerExpr(hb), loc: locOf(hb) }]; + // The handler's value is discarded, so the body lowers as a + // statement: `() => undefined` is a pure no-op (a bare unit + // literal has no IR value), not an expression to evaluate. + catchBody = [lowerer.lowerExprStatement(hb)]; } else { // Bare-expression handler: `(e) => v` (promise results flatten // through the async-return path, like any `return v`). diff --git a/tests/corpus/promise-void-catch-undefined.ts b/tests/corpus/promise-void-catch-undefined.ts new file mode 100644 index 000000000..ee7285d8a --- /dev/null +++ b/tests/corpus/promise-void-catch-undefined.ts @@ -0,0 +1,31 @@ +// `Promise.catch(() => undefined)` — the handler's value is +// discarded and the recovered promise settles as void. The handler runs +// exactly once, and the chain continues on the next turn whether the +// receiver fulfilled or rejected. + +const ok: Promise = Promise.resolve(); +const bad: Promise = Promise.reject(new Error("boom")); + +ok.catch(() => undefined).then(() => console.log("fulfilled recovered")); + +bad + .catch(() => { + console.log("rejected recovered"); + }) + .then(() => console.log("rejected continued")); + +let runs = 0; + +function recover(): Promise { + runs++; + return runs % 2 === 1 ? Promise.reject(new Error("odd")) : Promise.resolve(); +} + +async function run(): Promise { + await recover().catch(() => undefined); + console.log("awaited recover", runs); + await recover().catch(() => undefined); + console.log("awaited recover", runs); +} + +void run().then(() => console.log("done")); From bf285950351105acf9463e99635fbc5a4d3ef4b9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:12:28 -0700 Subject: [PATCH 02/15] Lower promise methods called through an optional chain `p?.catch(h)`, `p?.then(h)` and a guarded chain tail (`f()?.then(h)`) were refused: the promise-method lowering rejected every `questionDotToken` instead of asking whether the optional chain was currently being lowered, so the guarded body never reached it. It uses `chainBlocked` now, like every other lowering that rides the chain. The call that ENDS a chain checker-types as the chain's undefined-armed union (`Promise | undefined`) while this lowering builds the promise arm, which `finishOptionalChain` wraps back into the union. The handler's result type takes that arm, so the handler is typed against the promise it actually handles. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 24 +++++-- .../src/frontend/lowering/maybe-promise.ts | 12 ++++ tests/corpus/promise-catch-optional-chain.ts | 64 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/corpus/promise-catch-optional-chain.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 45d470c60..34587b078 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -279,7 +279,11 @@ import { isThisParameter, type ParamShape } from "./call-signatures.js"; import { dynamicOverloadChoice } from "./overload-choice.js"; import { lowerStringIteratorValue } from "./iterator-adapters.js"; import { checkedArmExtraction } from "./coercions/unions.js"; -import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js"; +import { + maybePromiseReturnStmt, + promiseArmedUnionOf, + promiseOrUnitUnionOf, +} from "./maybe-promise.js"; import { requireObjLitGenericReceiver, objLitGenericFnNodeOf, @@ -5998,9 +6002,19 @@ export function lowerPromiseMethodCall( call: ts.CallExpression, access: ts.PropertyAccessExpression, ): IrExpr | null { - if (call.questionDotToken || access.questionDotToken) return null; + if (lowerer.chainBlocked(access, call)) return null; const member = access.name.text; if (member !== "then" && member !== "catch" && member !== "finally") return null; + // A call that ENDS an optional chain (`refreshing?.catch(h)`, + // `client?.close().catch(h)`) checker-types as the chain's + // undefined-armed union while the body this lowering builds is the + // promise arm — `finishOptionalChain` wraps it back into the union. + // Take the arm so the handler typing sees the promise it handles. + const callResultType = (): IrType | undefined => { + const t = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!t || t.kind === "promise") return t ?? undefined; + return promiseOrUnitUnionOf(lowerer, t)?.promise ?? t; + }; let recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)) ?? dynFallbackType(lowerer, access.expression, lowerer.typeOf(access.expression)); @@ -6117,7 +6131,7 @@ export function lowerPromiseMethodCall( const rejectedNode = call.arguments.nodes[1]!; const fulfilledAbsent = isAbsentHandler(fulfilledNode); const rejectedAbsent = isAbsentHandler(rejectedNode); - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const resultT = callResultType(); // An untyped call in JavaScript whose receiver arrived as a typed // promise (a function instantiated with one): Promise.prototype.then // over the promise as a checked-dynamic value, which shares its @@ -6516,7 +6530,7 @@ export function lowerPromiseMethodCall( `then handlers whose parameter is not the settled value's type (expected '${lowerer.fmt(inner)}', got '${lowerer.fmt(param)}')`, ); } - let resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + let resultT = callResultType(); // JavaScript whose declared result type has no mapping (zod's // `Promise.all(items).then((r) => ParseStatus.mergeArray(status, r))`, // typed by a .d.ts union): the handler's value settles the promise as @@ -6864,7 +6878,7 @@ export function lowerPromiseMethodCall( "value — take `(e)` or `(e: unknown)` and narrow with instanceof)", ); } - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + const resultT = callResultType(); if (resultT?.kind !== "promise") { lowerer.noLowering( "catch with this handler's result type", diff --git a/packages/compiler/src/frontend/lowering/maybe-promise.ts b/packages/compiler/src/frontend/lowering/maybe-promise.ts index 8323c2e91..8cc7d7e35 100644 --- a/packages/compiler/src/frontend/lowering/maybe-promise.ts +++ b/packages/compiler/src/frontend/lowering/maybe-promise.ts @@ -23,6 +23,18 @@ export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUn return armed; } +/** The promise arm of a promise-or-unit union (`Promise | void`, + * `Promise | undefined`): exactly one promise arm and only nullish + * arms beside it, so no other arm carries a value the promise path would + * have to reconcile. A guard that proves the value present (`?.`, an + * explicit narrow) leaves this arm alone. */ +export function promiseOrUnitUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null { + const armed = promiseArmedUnionOf(lowerer, t); + if (!armed) return null; + if (!armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) return null; + return armed; +} + /** Any union with exactly one promise arm: a maybe-promise union or a * promise-or-unit union (`Promise | void`). An async `return` * resolves both the same way: adopt the promise arm, fulfill otherwise. */ diff --git a/tests/corpus/promise-catch-optional-chain.ts b/tests/corpus/promise-catch-optional-chain.ts new file mode 100644 index 000000000..638fb8417 --- /dev/null +++ b/tests/corpus/promise-catch-optional-chain.ts @@ -0,0 +1,64 @@ +// `?.` reaches a promise method through a receiver that may be nullish: +// `p?.catch(handler)` guards the method read (JS calls nothing when `p` is +// nullish), and the chain tail `f()?.then(..).catch(..)` guards the whole +// tail. The guarded call itself is an ordinary promise method call, so the +// handler typing sees the promise arm, and the chain result is +// `Promise<..> | undefined`. + +interface Client { + name: string; +} + +class Box { + opening: Promise | undefined; + pending: Promise | undefined; + + constructor(opening: Promise | undefined, pending: Promise | undefined) { + this.opening = opening; + this.pending = pending; + } + + async run(): Promise { + this.opening?.catch(() => undefined); + console.log("1 opening guarded"); + await this.pending?.catch(() => undefined); + console.log("2 pending guarded"); + } +} + +function open(name: string): Promise { + return Promise.resolve({ name }); +} + +function pick(flag: boolean): Promise | undefined { + return flag ? Promise.resolve("value") : undefined; +} + +function tail(flag: boolean): Promise { + return pick(flag)! + .then((value) => { + console.log("tail then", value); + return value.length; + }) + .catch(() => undefined); +} + +function guardedTail(flag: boolean): void { + pick(flag)?.then((value) => { + console.log("guarded then", value); + return value.length; + }); + console.log("3 tail attached", flag); +} + +const present = new Box(open("digest"), Promise.resolve()); +const absent = new Box(undefined, undefined); +present + .run() + .then(() => absent.run()) + .then(() => tail(true)) + .then(() => { + guardedTail(true); + guardedTail(false); + }) + .then(() => console.log("done")); From a4a84f59da234c7401eed409363ccf19d3d02301 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:12:28 -0700 Subject: [PATCH 03/15] Resolve a promise-or-unit operand in `Promise.resolve` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Promise.resolve(p)` over `Promise | void` — an async callback's declared result — was refused: the maybe-promise detection required every non-promise arm to carry a value, while a nullish arm is exactly what `Promise.resolve` fulfills as undefined. `promiseOrUnitUnionOf` names that shape, and the resolution reuses the existing adopt-or- fulfill path, so the promise arm passes through unchanged (`Promise.resolve(p) === p`). Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/builtins/promises.ts | 16 +++++++-- .../corpus/promise-resolve-promise-or-unit.ts | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/promise-resolve-promise-or-unit.ts diff --git a/packages/compiler/src/frontend/lowering/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index c3b2cd479..fdd16da90 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -5,7 +5,11 @@ import * as ts from "../../ts7/adapter.js"; import type { Lowerer } from "../lowerer.js"; import { countedFor, varRef } from "../../../ir/build.js"; import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js"; -import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js"; +import { + maybePromiseResolve, + maybePromiseUnionOf, + promiseOrUnitUnionOf, +} from "../maybe-promise.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { BOOL, @@ -670,11 +674,17 @@ export function lowerPromiseStaticCall( if (v.type.kind !== "promise") lowerer.badType(argNode, argT); return v; } - const maybePromise = argIr ? maybePromiseUnionOf(lowerer, argIr) : null; + // A promise-or-unit union (`Promise | void`, + // `Promise | undefined`) resolves exactly like a maybe-promise + // one: Node adopts a promise operand and fulfills every other value + // as itself (a nullish arm fulfills undefined). + const maybePromise = argIr + ? (maybePromiseUnionOf(lowerer, argIr) ?? promiseOrUnitUnionOf(lowerer, argIr)) + : null; if (maybePromise) { const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); const v = lowerer.lowerExpr(argNode); - const mp = maybePromiseUnionOf(lowerer, v.type); + const mp = maybePromiseUnionOf(lowerer, v.type) ?? promiseOrUnitUnionOf(lowerer, v.type); const resolved = resultT?.kind === "promise" && mp ? maybePromiseResolve(lowerer, v, mp, resultT, loc) diff --git a/tests/corpus/promise-resolve-promise-or-unit.ts b/tests/corpus/promise-resolve-promise-or-unit.ts new file mode 100644 index 000000000..209021ab0 --- /dev/null +++ b/tests/corpus/promise-resolve-promise-or-unit.ts @@ -0,0 +1,34 @@ +// `Promise.resolve(u)` over a promise-or-unit union (`Promise | +// void`): Node adopts a promise operand — the SAME promise comes +// back — and fulfills a nullish one as undefined. The settled turn of the +// adopted promise is unchanged, so a handler chain observes the same +// microtask order either way. + +let runs = 0; + +async function step(label: string): Promise { + runs++; + await Promise.resolve(); + console.log("step", label, runs); +} + +// A callback whose declared result is `Promise | void`: the +// async path answers a promise, the sync one answers undefined. +function handler(flag: boolean): Promise | void { + if (flag) return step("async"); + runs++; + console.log("sync handler", runs); +} + +function call(flag: boolean): Promise { + return Promise.resolve(handler(flag)).then(() => console.log("call done", flag)); +} + +const pending: Promise | void = Promise.resolve(); +console.log("identity", Promise.resolve(pending) === pending); + +console.log("start"); +call(true) + .then(() => call(false)) + .then(() => call(true)) + .then(() => console.log("runs", runs)); From 0496f0eb2553b064837d87c9ed01345028af3170 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:12:28 -0700 Subject: [PATCH 04/15] Lower `Array.from(typedArray, mapper)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.from(bytes, f)` had no lowering — only the mapper-less `toArray` — so every mapper over a typed array fenced. It lifts a loop that reads each element through the typed array itself, because Node iterates live: a mapper that writes the source is visible to the indexes it has not visited yet. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-construction.ts | 121 ++++++++++++++++++ tests/corpus/array-from-typed-array-mapper.ts | 25 ++++ 2 files changed, 146 insertions(+) create mode 100644 tests/corpus/array-from-typed-array-mapper.ts diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index cadee368a..2a3ad64c0 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1158,6 +1158,37 @@ export function lowerArrayFromCall( loc, }; } + // `Array.from(typedArray, mapper)`: the element read is live (a + // mapper that writes the source changes later reads, as in Node), + // so this is a loop over the typed array, not a toArray snapshot. + if (src.type.kind === "bytes" && args.length === 2) { + const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64)); + if (callback.arity > 2) + lowerer.noLowering("Array.from mapper with an array parameter", args[1]!); + const fnRet = callback.fnArg.type.ret; + if (fnRet.kind === "void" || fnRet.kind === "func") + lowerer.badType(call, lowerer.typeOf(call)); + requireProducedArrayElement(lowerer, call, "'Array.from(typedArray, mapper)'", fnRet); + const outElem = callbackArrayElement(lowerer, call, fnRet); + // The element kind rides in the key because the lifted helper's + // source parameter carries it (the reads themselves are always f64). + const key = `fromBytes:${src.type.elem}:${typeKey(outElem)}:${typeKey(fnRet)}:${callback.arity}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = keyedHelperName(lowerer, "%arr.fromBytes", key); + lowerer.arrHofHelpers.set(key, helper); + lowerer.liftedFns.push( + buildArrayFromBytesFn(lowerer, helper, src.type, outElem, fnRet, callback.arity, loc), + ); + } + return { + kind: "call", + callee: helper, + args: [src, callback.fnArg], + type: arrayOf(outElem), + loc, + }; + } // A union of typed arrays and number arrays (indexed-unions.ts). const indexedArms = indexedUnionArms(lowerer, src.type); if (indexedArms && args.length === 1) @@ -1320,6 +1351,96 @@ function buildArrayFromArrayFn( }; } +/** The `Array.from(typedArray, mapper)` generation loop: every index is + * read through the typed array itself (live, like Node's iteration), so + * a mapper that writes the source is visible to the indexes it has not + * visited yet. */ +function buildArrayFromBytesFn( + lowerer: Lowerer, + name: string, + bytesT: IrType & { kind: "bytes" }, + outElem: IrType, + fnRet: IrType, + arity: number, + loc: SrcLoc, +): IrFunction { + const outT = arrayOf(outElem); + const fnT = funcOf([F64, F64].slice(0, arity), fnRet); + const a = varRef("a.0", bytesT, loc); + const i = varRef("i.0", F64, loc); + const out = varRef("out.0", outT, loc); + // The index parameter is the second one, never read for an index the + // element covers, so an element-only mapper drops it. + const reads: IrExpr[] = [ + { kind: "bytesIntrinsic", method: "get", receiver: a, args: [i], type: F64, loc }, + i, + ]; + const mapped: IrExpr = { + kind: "callValue", + callee: varRef("f.0", fnT, loc), + args: reads.slice(0, arity), + type: fnRet, + loc, + }; + const held = !typeEquals(fnRet, outElem); + const value: IrExpr = held ? varRef("v.0", fnRet, loc) : mapped; + return { + name, + params: [ + { localId: "a.0", name: "a", type: bytesT }, + { localId: "f.0", name: "f", type: fnT }, + ], + returnType: outT, + locals: [ + { id: "a.0", name: "a", type: bytesT, mutable: true }, + { id: "f.0", name: "f", type: fnT, mutable: true }, + { id: "out.0", name: "out", type: outT, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + ...(held ? [{ id: "v.0", name: "v", type: fnRet, mutable: false }] : []), + ], + body: [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: outT, loc }, + loc, + }, + { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, + { + kind: "while", + cond: { + kind: "bin", + op: "<", + left: i, + right: { + kind: "bytesIntrinsic", + method: "length", + receiver: a, + args: [], + type: F64, + loc, + }, + type: BOOL, + loc, + }, + body: [ + ...(held ? [{ kind: "varDecl", localId: "v.0", init: mapped, loc } as const] : []), + arrayValueStore(lowerer, out, i, value, outElem, loc), + { + kind: "assign", + localId: "i.0", + value: { kind: "bin", op: "+", left: i, right: numLit(1, loc), type: F64, loc }, + loc, + }, + ], + loc, + }, + { kind: "return", value: out, loc }, + ], + loc, + }; +} + /** `Array.from(s)` / `[...s]` on a STRING: the code-point split into a * fresh string[] (scr_str_code_points). */ export function strCharsCall(src: IrExpr, loc: SrcLoc): IrExpr { diff --git a/tests/corpus/array-from-typed-array-mapper.ts b/tests/corpus/array-from-typed-array-mapper.ts new file mode 100644 index 000000000..a6ee1012e --- /dev/null +++ b/tests/corpus/array-from-typed-array-mapper.ts @@ -0,0 +1,25 @@ +// `Array.from(typedArray, mapper)`: the mapper receives each element as a +// number and its index, and the element reads are LIVE — iteration reads +// index i after visiting i-1, so a mapper that writes the source changes +// the values the loop has not visited yet. The result is a plain array of +// the mapper's results. + +const bytes = new Uint8Array([104, 105, 33]); +console.log(Array.from(bytes, (byte) => String.fromCharCode(byte)).join("")); +console.log(Array.from(bytes, (byte, index) => `${index}:${byte}`).join(",")); + +const live = new Uint8Array([1, 2, 3]); +const doubled = Array.from(live, (value, index) => { + if (index === 0) live[2] = 30; + return value * 2; +}); +console.log(doubled.join(",")); + +// A mapper that ignores its parameters, and an empty source. +console.log(Array.from(new Uint8Array(0), () => 7).length); +console.log(Array.from(bytes, () => "x").join("")); + +// Element identity survives the map: objects are not copied. +const words = new Uint8Array([1, 2]); +const boxed = Array.from(words, (word) => ({ word })); +console.log(boxed.length, boxed[1]!.word); From b8bb5bfdb04c8e51da46c4b40276fe258a88f62b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:24:37 -0700 Subject: [PATCH 05/15] Record the new corpus programs' CI costs Co-Authored-By: Claude Opus 5.5 --- tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json | 1 + tests/harness/corpus-costs/promise-catch-optional-chain.ts.json | 1 + .../harness/corpus-costs/promise-resolve-promise-or-unit.ts.json | 1 + tests/harness/corpus-costs/promise-void-catch-undefined.ts.json | 1 + 4 files changed, 4 insertions(+) create mode 100644 tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json create mode 100644 tests/harness/corpus-costs/promise-catch-optional-chain.ts.json create mode 100644 tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json create mode 100644 tests/harness/corpus-costs/promise-void-catch-undefined.ts.json diff --git a/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json b/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json new file mode 100644 index 000000000..094458e73 --- /dev/null +++ b/tests/harness/corpus-costs/array-from-typed-array-mapper.ts.json @@ -0,0 +1 @@ +{ "plain": 1400, "san": 1200 } diff --git a/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json b/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json new file mode 100644 index 000000000..ba681e2a0 --- /dev/null +++ b/tests/harness/corpus-costs/promise-catch-optional-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 1000, "san": 900 } diff --git a/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json b/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json new file mode 100644 index 000000000..035725f07 --- /dev/null +++ b/tests/harness/corpus-costs/promise-resolve-promise-or-unit.ts.json @@ -0,0 +1 @@ +{ "plain": 900, "san": 900 } diff --git a/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json b/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json new file mode 100644 index 000000000..a72de5c97 --- /dev/null +++ b/tests/harness/corpus-costs/promise-void-catch-undefined.ts.json @@ -0,0 +1 @@ +{ "plain": 1600, "san": 900 } From a0a6910e4e29963b03eeda26236a6379ab069db1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:16:59 -0700 Subject: [PATCH 06/15] Destructure union sources by narrowing first and joining arm reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object pattern over a union whose arms type a field differently, or do not declare it at all, was refused with SC1031, even though the pattern element's own type is the checker's answer: the join of the arms' declared types, with undefined for an arm that omits the key, or the narrowed arm when a discriminant test or an `in` guard refined the source at the pattern's site. Keep the unionDisc fast path wherever every arm declares the field with one type, so programs that compile today emit identical IR. Otherwise read through the existing keyed join (unionKeyGet): each arm answers its declared type and a record arm that does not declare the key answers undefined, which is exactly the desugar's own property read. Only destructuring passes the new missingFieldAsUndefined flag, so dotted reads keep their current refusals. Narrow first when the un-narrowed pattern does not fit: bind the checker-narrowed view of the source to a hidden local — a trusting arm extraction for one arm, a sub-union re-tag whose excluded arms trap, and the stored layout view for an intersection refinement — and lower the pattern over that view. A rest element over a union source packs each arm's unconsumed fields into a fresh record of the checker's arm-wise rest type, selected by the runtime tag; CopyDataProperties makes a fresh object too, so nothing aliases the source. Shapes that do not spell the rest type still fence. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 17 +- .../src/frontend/lowering/lower-stmts.ts | 338 ++++++++++++++++-- .../union-destructuring-join-narrow.ts.json | 6 + .../corpus/union-destructuring-join-narrow.ts | 73 ++++ tests/diagnostics/destructuring-fences.ts | 2 +- .../__snapshots__/destructuring-fences.ts.txt | 16 - .../union-destructuring-join-narrow.ts.json | 1 + 7 files changed, 403 insertions(+), 50 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json create mode 100644 tests/corpus/union-destructuring-join-narrow.ts create mode 100644 tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 8f41aa93e..4a35b3aee 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3592,11 +3592,12 @@ export function lowerUnionProperty( * message. The caller (the property/element dispatch) maybeNarrows. */ export function lowerUnionKeyedRead( lowerer: Lowerer, - expr: ts.Expression, + expr: ts.Node, unionId: string, value: IrExpr, key: IrExpr, literalField: string | null, + missingFieldAsUndefined = false, ): IrExpr | null { const def = lowerer.unions.get(unionId); if (!def) return null; @@ -3646,6 +3647,14 @@ export function lowerUnionKeyedRead( } if (!shape.indexValue) { if (literalField === null && shape.fields.length > 0) continue; + // A record arm that does not declare the key at all answers + // UNDEFINED in JS. Destructuring asks for that answer (its own + // desugar reads a missing property as undefined, Node-exact); a + // dotted read keeps refusing until narrowing proves the arm. + if (literalField !== null && missingFieldAsUndefined) { + push(UNDEFINED_T); + continue; + } return null; } if (!pushAnswer(shape.indexValue)) return null; @@ -3690,6 +3699,12 @@ export function lowerUnionKeyedRead( if (!surfaces(declared)) return null; continue; } + if (literalField !== null && !shape.indexValue && missingFieldAsUndefined) { + // The missing-field answer above must surface as the join's + // undefined arm (the backend's keyed-read miss path stores it). + if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; + continue; + } const ovfShape: IrRecordShape = literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 08f468850..ff51f8c5f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9,8 +9,12 @@ import { lowerIndexedUnionNumberRead, lowerIndexedUnionRead, } from "./indexed-unions.js"; -import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; -import { hostsAll } from "./expressions/hosted-narrowing.js"; +import { + declaredMemberOf, + isPromisePrototypeRead, + lowerUnionKeyedRead, +} from "./expressions/property-access.js"; +import { hostedStorageView, hostsAll } from "./expressions/hosted-narrowing.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; import { lowerForAwaitDynStream, nodeStreamInterfaceSide } from "./dyn-streams.js"; import { lowerForAwaitValueStream } from "./builtins/hash-stream.js"; @@ -3537,6 +3541,34 @@ export function lowerBindingPattern( .get(srcType.unionId) ?.arms.every((arm) => arm.kind === "record" || arm.kind === "object") ) { + const arms = lowerer.unions.get(srcType.unionId)!.arms; + if (!unionObjectPatternFits(lowerer, pattern, arms)) { + // NARROW FIRST: the checker may have refined the union at this + // very site (`if (u.kind === "a") { const { x } = u }`, an `in` + // guard) while the IR value keeps the stored, wider union. Bind + // the narrowed view once and destructure that. + const view = narrowedUnionPatternSource( + lowerer, + pattern, + present, + srcType.unionId, + locOf(pattern), + ); + if (view !== null) { + const tmp = lowerer.declareHiddenLocal("%destrN", view.type); + out.push({ kind: "varDecl", localId: tmp.id, init: view, loc: locOf(pattern) }); + lowerer.lowerBindingPattern( + pattern, + () => ({ kind: "varRef", localId: tmp.id, type: view.type, loc: locOf(pattern) }), + view.type, + isLet, + out, + dynSpell, + allowDynObject, + ); + return; + } + } lowerUnionObjectPattern(lowerer, pattern, srcRef, srcType, isLet, out); return; } @@ -3949,11 +3981,235 @@ function lowerUrlObjectPattern( } } +/** The fresh record for `{ a, ...rest }` over a UNION source: per arm, + * the unconsumed fields copied out at destructure time into a fresh + * record of the checker's arm-wise rest type, selected by the runtime + * tag. Null-safe answer: an arm whose remaining fields do not spell an + * arm of the checker's rest type keeps the named fence. */ +function unionObjectRestValue( + lowerer: Lowerer, + el: ts.BindingElement, + srcRef: () => IrExpr, + srcType: IrType & { kind: "union" }, + arms: readonly IrType[], +): IrExpr { + const loc = locOf(el); + const restT = patternBindingType(lowerer, el.name!); + const fence: (why: string) => never = (why) => lowerer.unsupported("SC1031", el, why); + if (restT === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + // The consumed set is the pattern's literal keys: a computed sibling + // would make it a runtime fact. + const consumed = new Set(); + const pattern = el.parent; + if (ts.isObjectBindingPattern(pattern)) { + for (const sib of pattern.elements.nodes) { + if (sib === el || sib.dotDotDotToken) continue; + const key = sib.propertyName ?? sib.name; + if (key && ts.isComputedPropertyName(key)) { + fence("rest bindings beside computed keys (the consumed set is a runtime fact)"); + } + if (key && (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key))) { + consumed.add(key.text); + } + } + } + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < arms.length; tag++) { + const arm = arms[tag]!; + if (arm.kind !== "record") { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.indexValue) { + fence( + "rest bindings over index-signature shapes (the undeclared entries would need overflow packing)", + ); + } + const rem = shape.fields.filter((f) => !consumed.has(f.name)); + if (rem.some((f) => f.name.startsWith("%"))) { + fence("rest bindings over accessor shapes (the entries are closures, not data)"); + } + const armRef: IrExpr = { + kind: "unionNarrow", + unionId: srcType.unionId, + tag, + value: srcRef(), + type: arm, + loc, + }; + const packed: IrExpr = lowerer.keyOrder.copy( + { + kind: "recordLit", + fields: rem.map((f) => ({ + name: f.name, + value: lowerer.presenceKeepingCopy( + armRef, + arm.shapeId, + f.name, + { + kind: "recordGet", + obj: armRef, + shapeId: arm.shapeId, + field: f.name, + type: f.type, + loc, + }, + loc, + ), + })), + type: { + kind: "record", + shapeId: lowerer.shapes.intern(rem.map((f) => ({ name: f.name, type: f.type }))), + }, + loc, + }, + arm.shapeId, + ); + const coerced = lowerer.coerceInto(el, packed, restT); + if (!typeEquals(coerced.type, restT)) { + fence( + `rest bindings over union sources (the remaining fields of an arm do not spell an arm of '${lowerer.fmt(restT)}')`, + ); + } + pieces.push({ tag, value: coerced }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: piece.tag, + negated: false, + value: srcRef(), + type: BOOL, + loc, + }, + then: piece.value, + else_: result, + type: restT, + loc, + }; + } + if (result === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + return result; +} + +/** The one type every arm declares for `propName`, or null when an arm + * does not declare it (or types it differently, or not at all). */ +function unionPatternCommonType( + lowerer: Lowerer, + arms: readonly IrType[], + propName: string, +): IrType | null { + let common: IrType | null = null; + for (const arm of arms) { + let ft: IrType | undefined; + if (arm.kind === "record") { + ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; + } else if (arm.kind === "object") { + ft = lowerer.classes.get(arm.className)?.fields.get(propName); + } + if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { + return null; + } + common = ft; + } + return common; +} + +/** Whether the unionDisc fast path lowers this pattern over these arms + * unchanged: no rest element, and every element's key folds to one name + * every arm declares with one type. */ +function unionObjectPatternFits( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + arms: readonly IrType[], +): boolean { + for (const el of pattern.elements.nodes) { + if (el.name === undefined) continue; + if (el.dotDotDotToken) return false; + const prop = el.propertyName ?? el.name; + const propName = + ts.isIdentifier(prop) || + ts.isComputedPropertyName(prop) || + ts.isStringLiteralLike(prop) || + ts.isNumericLiteral(prop) + ? patternKeyNameOf(lowerer, prop as ts.PropertyName) + : null; + if (propName === null) return false; + if (unionPatternCommonType(lowerer, arms, propName) === null) return false; + } + return true; +} + +/** The CHECKER-NARROWED view of a destructuring source: `if (u.kind === + * "a") { const { x } = u }` or an `in` guard refines the type at the + * pattern's own site, while the IR value keeps the stored (wider) union + * and the un-narrowed lowering. Bind the value to the narrowed sub-union + * — a trusted arm extraction for one arm, a re-tag with the excluded + * arms as traps for a sub-union — so the pattern reads the arms the + * checker proved. Null when the checker did not narrow, when the + * refinement is an intersection of stored records (hostedStorageView + * keeps that layout), or when nothing was excluded. */ +function narrowedUnionPatternSource( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + value: IrExpr, + fromId: string, + loc: SrcLoc, +): IrExpr | null { + const from = lowerer.unions.get(fromId); + if (!from) return null; + const narrowed = lowerer.irTypeOf(pattern); + if (narrowed.kind === "void" || isUnitType(narrowed)) return null; + const siteArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!siteArms || siteArms.length === 0) return null; + const allowed = new Set(); + for (const arm of siteArms) { + const tag = lowerer.armTag(fromId, arm); + // Not a stored arm: a checker intersection that refines the stored + // records, which keeps the value's layout. + if (tag < 0) return hostedStorageView(lowerer, value, narrowed, loc); + allowed.add(tag); + } + if (allowed.size === 0 || allowed.size === from.arms.length) return null; + if (allowed.size === 1) { + const tag = [...allowed][0]!; + // The checker proved the tag; the extraction traps on a mismatch + // instead of reading another arm. + return { kind: "unionNarrow", unionId: fromId, tag, value, type: from.arms[tag]!, loc }; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const helper = lowerer.narrowedRetagHelper(pattern, fromId, subId, loc); + if (!helper) return null; + return { + kind: "call", + callee: helper, + args: [value], + type: { kind: "union", unionId: subId }, + loc, + }; +} + /** An object pattern over a union of records and class instances * (`function f({ kind, path }: Add | Remove)`): each element reads a data - * field every arm declares with one type, through the runtime tag, as - * `value.field` does. Rest elements and fields the arms type differently - * stay fenced. */ + * field through the runtime tag, as `value.field` does — the shared + * `unionDisc` when every arm declares it with one type, else the + * `unionKeyGet` join (an arm that does not declare the key answers + * undefined, as JS reads it). A rest element packs each arm's unconsumed + * fields into a fresh record. Class arms still need same-typed fields. */ function lowerUnionObjectPattern( lowerer: Lowerer, pattern: ts.ObjectBindingPattern, @@ -3966,7 +4222,17 @@ function lowerUnionObjectPattern( for (const el of pattern.elements.nodes) { if (el.name === undefined) continue; if (el.dotDotDotToken) { - lowerer.unsupported("SC1031", el, `rest bindings over union sources (${NARROW_FIRST})`); + // `{ a, ...rest }` over a union: each arm packs its own unconsumed + // fields into a FRESH record (JS's CopyDataProperties makes a fresh + // object too), and the checker's rest type is the union of those + // per-arm packs. The tag decides which pack runs. + lowerer.bindPatternTarget( + el.name, + unionObjectRestValue(lowerer, el, srcRef, srcType, arms), + isLet, + out, + ); + continue; } const prop = el.propertyName ?? el.name; const propName = @@ -3983,35 +4249,43 @@ function lowerUnionObjectPattern( "destructuring with computed keys that do not fold to one property name", ); } - let common: IrType | null = null; - for (const arm of arms) { - let ft: IrType | undefined; - if (arm.kind === "record") { - ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; - } else if (arm.kind === "object") { - ft = lowerer.classes.get(arm.className)?.fields.get(propName); - } - if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { - common = null; - break; - } - common = ft; - } - if (common === null) { - lowerer.unsupported( - "SC1031", + const common = unionPatternCommonType(lowerer, arms, propName); + let value: IrExpr; + if (common !== null) { + value = { + kind: "unionDisc", + unionId: srcType.unionId, + field: propName, + value: srcRef(), + type: common, + loc: locOf(el), + }; + } else { + // The unionDisc generalization (`unionKeyGet`): each arm answers + // its own declared type and an arm that does not declare the + // key answers undefined — exactly JS's desugar, which reads a + // missing property as undefined. + const loc = locOf(el); + const joined = lowerUnionKeyedRead( + lowerer, el, - `destructuring '${propName}' from a union whose arms type it differently (every arm must be a record or class with a same-typed field '${propName}' — ${NARROW_FIRST})`, + srcType.unionId, + srcRef(), + { kind: "strLit", value: propName, type: STRING, loc }, + propName, + true, ); + if (joined === null) { + lowerer.unsupported( + "SC1031", + el, + `destructuring '${propName}' from a union whose arms answer it with types that have no combined representation (every arm's '${ + propName + }' must join into one union — ${NARROW_FIRST})`, + ); + } + value = joined; } - let value: IrExpr = { - kind: "unionDisc", - unionId: srcType.unionId, - field: propName, - value: srcRef(), - type: common, - loc: locOf(el), - }; if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..99385b8d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-destructuring-join-narrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-destructuring-join-narrow.ts b/tests/corpus/union-destructuring-join-narrow.ts new file mode 100644 index 000000000..85de1faff --- /dev/null +++ b/tests/corpus/union-destructuring-join-narrow.ts @@ -0,0 +1,73 @@ +// Object patterns over a union whose arms type a field differently (or do +// not declare it), narrowed sources, and rest elements over a union. +interface Wide { + kind: "wide"; + value: number; + extra?: string; +} +interface Narrow { + kind: "narrow"; + value: string; + extra?: undefined; +} +type Item = Wide | Narrow; + +// The joined read: `number | string`, `string | undefined`. +function read(item: Item): void { + const { value, extra } = item; + console.log(item.kind, String(value), String(extra)); +} +read({ kind: "wide", value: 7, extra: "e" }); +read({ kind: "narrow", value: "v" }); + +interface Delta { + type: "delta"; + partial: { text: string }; + other: number; +} +interface Final { + type: "final"; + partial: { text: number }; + tail: boolean; +} +interface Bare { + type: "bare"; + other: number; +} +type Wire = Delta | Final | Bare; + +// A rest element over a union packs each arm's own remaining fields. +function rest(event: Delta | Final): string { + const { partial: _partial, ...kept } = event; + return JSON.stringify(kept); +} +console.log(rest({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(rest({ type: "final", partial: { text: 3 }, tail: true })); + +// A discriminant test and an `in` guard narrow the source; the pattern +// reads the arms the checker proved. +function narrowed(event: Wire): string { + if (event.type === "delta") { + const { partial, other } = event; + return `tag:${partial.text}:${other}`; + } + if ("partial" in event) { + const { partial } = event; + return `in:${String(partial.text)}`; + } + return "none"; +} +console.log(narrowed({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(narrowed({ type: "final", partial: { text: 3 }, tail: true })); +console.log(narrowed({ type: "bare", other: 4 })); + +// An `in` guard alone narrows to a SUB-union: both remaining arms keep +// their own field types. +function inOnly(w: Wire): string { + if (!("partial" in w)) return "none"; + const { partial } = w; + return String(partial.text); +} +console.log(inOnly({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(inOnly({ type: "final", partial: { text: 3 }, tail: true })); +console.log(inOnly({ type: "bare", other: 4 })); diff --git a/tests/diagnostics/destructuring-fences.ts b/tests/diagnostics/destructuring-fences.ts index 0ee60579d..25ee180c2 100644 --- a/tests/diagnostics/destructuring-fences.ts +++ b/tests/diagnostics/destructuring-fences.ts @@ -42,7 +42,7 @@ interface E2 { label: string; } function fromUnion(u: E1 | E2): string { - const { b } = u; // union source whose arms type the field differently: narrow first + const { b } = u; // arms type the field differently: the joined read compiles return String(b); } console.log(fromUnion({ kind: "one", b: "s", count: 1 })); diff --git a/tests/harness/__snapshots__/destructuring-fences.ts.txt b/tests/harness/__snapshots__/destructuring-fences.ts.txt index 41026a7c8..02484d872 100644 --- a/tests/harness/__snapshots__/destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/destructuring-fences.ts.txt @@ -19,22 +19,6 @@ destructuring-fences.ts:31:9 - error SC1090: reading a property that has only a | ^~~~~~ 32 | console.log(onlyIn); -destructuring-fences.ts:45:11 - error SC1031: destructuring 'b' from a union whose arms type it differently (every arm must be a record or class with a same-typed field 'b' — narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - - 44 | function fromUnion(u: E1 | E2): string { - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - | ^ - 46 | return String(b); - -destructuring-fences.ts:46:17 - error SC2004: uses of 'b' inherit the blocker on its declaration - - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - 46 | return String(b); - | ^ - 47 | } - - hint: the declaration of 'b' did not compile — fix the diagnostic reported there and these sites clear with it - destructuring-fences.ts:51:7 - error SC1031: object destructuring of non-record values (the source is array-typed) are not supported yet 50 | const xs = [1, 2, 3]; diff --git a/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..2117fc7cd --- /dev/null +++ b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 2900 } From 99ebf7501d891d2e04463dd0d92bde3786180e75 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:17:03 -0700 Subject: [PATCH 07/15] Await a union whose promise arm settles a union `Promise | undefined` awaits as Awaited of the operand, which the maybe-promise lowering already computes: it awaits the narrowed promise arm and coerces its settled union, and the non-promise arms take JS's one microtask hop. The await lowering refused any union inner instead, so the promise-or-unit shape now goes through that same path; a union whose promise arm has no combined result keeps the SC1090 fence. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 26 ++++++++++++++++--- .../corpus/union-await-promise-arm.ts.json | 6 +++++ tests/corpus/union-await-promise-arm.ts | 24 +++++++++++++++++ .../union-await-promise-arm.ts.json | 1 + 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json create mode 100644 tests/corpus/union-await-promise-arm.ts create mode 100644 tests/harness/corpus-costs/union-await-promise-arm.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 89ea52bc5..cc380243c 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -26,7 +26,11 @@ import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands import { staticCondition } from "./static-typeof.js"; import { constituentTypes } from "../ts7/checker.js"; import type { Lowerer } from "./lowerer.js"; -import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; +import { + lowerMaybePromiseAwait, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; import { checkedClassUnionAssertion } from "./class-assertions.js"; import { BIGINT_T, @@ -668,9 +672,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { loc, }; } - if (inner.kind === "union" || inner.kind === "dyn") { - // A union inner would need an arm-wise re-tag into the result - + if (inner.kind === "union") { + // A UNION inner (`Promise | undefined`): the awaited + // result is the checker's own `Awaited` of the operand, which + // the maybe-promise lowering already produces — it awaits the + // narrowed promise arm and coerces its settled union, and the + // unit arms keep JS's one-microtask hop. Only the promise-or- + // unit shape reaches here, so the maybe-promise union is the + // same union with its only data arms being units. + const armed = promiseArmedUnionOf(lowerer, value.type); + if (armed) return lowerMaybePromiseAwait(lowerer, expr, value, armed, loc); + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the promise's inner type has no combined result union — await the promise arm after narrowing instead)`, + ); + } + if (inner.kind === "dyn") { lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json new file mode 100644 index 000000000..f62e66a8a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-await-promise-arm.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-await-promise-arm.ts b/tests/corpus/union-await-promise-arm.ts new file mode 100644 index 000000000..91d9f4485 --- /dev/null +++ b/tests/corpus/union-await-promise-arm.ts @@ -0,0 +1,24 @@ +// Awaiting a union whose promise arm settles a union +// (`Promise | undefined`): the promise arm is awaited, and a +// non-promise arm answers its own value after one microtask turn. +function maybe(flag: boolean): Promise | undefined { + if (!flag) return undefined; + return Promise.resolve(flag ? "value" : null); +} + +async function main(): Promise { + const absent = await maybe(false); + console.log(String(absent)); + const settled = await maybe(true); + console.log(String(settled)); + const nil = await maybe(true); + console.log(nil === null); + + // Awaiting a non-promise arm takes exactly one microtask turn. + const order: string[] = []; + Promise.resolve().then(() => order.push("microtask")); + const hopped = await maybe(false); + order.push(`after:${String(hopped)}`); + console.log(order.join(",")); +} +main(); diff --git a/tests/harness/corpus-costs/union-await-promise-arm.ts.json b/tests/harness/corpus-costs/union-await-promise-arm.ts.json new file mode 100644 index 000000000..6eebf7e03 --- /dev/null +++ b/tests/harness/corpus-costs/union-await-promise-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3150, "san": 4400 } From 071be693d997c9ef8ac6def69a046c1037fd9815 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:17:10 -0700 Subject: [PATCH 08/15] Support writeFileSync(fd, data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's writeFileSync accepts an open file descriptor where it accepts a path: it writes the data at the descriptor's current offset and leaves the descriptor open, since closeSync owns its lifetime. The static fs surface declared only the path forms, so `fs.writeFileSync(fd, text)` failed type mapping — the compiler was missing a documented overload, not a conversion. Declare the (fd, data) and (fd, data, "utf8" | "utf-8") forms in the node-fallback typings, route the call by the first argument's static f64 type to a new fs.writeFileSyncFd libfn, and implement it as scr_fs_write_file_fd: a write loop over the whole string through the descriptor-validating scr_fs_write_bytes, resuming short writes, with the descriptor operation's errno shape on failure. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/fs.d.ts | 5 +++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../lowering/builtins/filesystem-calls.ts | 36 +++++++++++++++++++ .../lowering/builtins/module-calls.ts | 12 +++++++ .../src/frontend/lowering/surfaces.ts | 4 ++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 +++ .../corpus/fs-write-file-sync-fd.ts.json | 6 ++++ packages/runtime/src/scr_lib.c | 19 ++++++++++ packages/runtime/src/scr_lib.h | 4 +++ tests/corpus/fs-write-file-sync-fd.ts | 14 ++++++++ .../fs-write-file-sync-fd.ts.json | 1 + 12 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json create mode 100644 tests/corpus/fs-write-file-sync-fd.ts create mode 100644 tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 6399558bd..534fad940 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -42,6 +42,11 @@ declare module "node:fs" { /* The bare-encoding spelling — the options record's encoding key alone. */ export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; export function writeFileSync(path: string | URL, data: string | Uint8Array): void; + /* The file-descriptor form: Node writes the whole data to an already + * open descriptor and leaves it open (closeSync owns its lifetime, and + * the write lands at the descriptor's current offset). */ + export function writeFileSync(fd: number, data: string): void; + export function writeFileSync(fd: number, data: string, encoding: "utf8" | "utf-8"): void; export function appendFileSync(path: string | URL, data: string | Uint8Array): void; export function existsSync(path: string | URL): boolean; export function mkdirSync(path: string | URL): void; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 185c85638..9b1172abd 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -556,6 +556,7 @@ export const LIB_FN_SYMS: Record = { "fs.readFileSyncBuf": "scr_fs_read_file_bytes", "fs.readFileSyncBytes": "scr_fs_read_file_bytes", "fs.writeFileSyncBytes": "scr_fs_write_file_bytes", + "fs.writeFileSyncFd": "scr_fs_write_file_fd", "fs.appendFileSyncBytes": "scr_fs_append_file_bytes", "fs.readFdSyncBytes": "scr_fs_read_fd_bytes", // Stats snapshots (scr_lib.c): statSync/lstatSync throw like the other diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 6b5e2d8ae..a308f2216 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -997,6 +997,42 @@ export function lowerFsWriteBytesCall( return null; } +// The writeFileSync(fd, data[, "utf8"]) form — Node accepts a file +// descriptor where it accepts a path (write to a descriptor openSync +// returned, the descriptor staying open: closeSync owns its lifetime). +// Routed by the ARGUMENT's static type, like readFileSync(fd). +export function lowerFsWriteDescriptorCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const supported = 'only string data is supported: writeFileSync(fd, data, "utf8")'; + const dataNode = expr.arguments.nodes[1]!; + const dataIr = lowerer.mapTypeOf(lowerer.typeOf(dataNode)); + if (dataIr?.kind !== "string") { + lowerer.noLowering( + `writeFileSync(fd) of '${dataIr ? lowerer.fmt(dataIr) : lowerer.checker.typeToString(lowerer.typeOf(dataNode))}' data`, + dataNode, + supported, + ); + } + if (expr.arguments.nodes.length > 3) { + lowerer.noLowering( + `writeFileSync(fd) with ${expr.arguments.nodes.length} arguments`, + expr, + supported, + ); + } + const fd = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64); + return { + kind: "libCall", + fn: "fs.writeFileSyncFd", + args: [fd, lowerer.lowerExprExpecting(dataNode, STRING)], + type: VOID, + loc, + }; +} + export function lowerFsWriteOptionsCall( lowerer: Lowerer, expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index b21c964f9..bf5223d2f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -14,6 +14,7 @@ import { lowerFsReadSyncCall, lowerFsWriteSyncCall, lowerFsReadDescriptorCall, + lowerFsWriteDescriptorCall, lowerFsEncodedRead, lowerFsPromisesEncodedRead, lowerFsMkdirSyncCall, @@ -331,6 +332,17 @@ export function lowerBuiltinModuleCall( const lowered = lowerFsWriteBytesCall(lowerer, expr, bi, loc); if (lowered) return lowered; } + // writeFileSync(fd, data): Node accepts an open descriptor where it + // accepts a path. Routed by the FIRST ARGUMENT's static type, like + // readFileSync(fd) (before the path forms complete the data type). + if ( + bi.module === "fs" && + bi.member === "writeFileSync" && + expr.arguments.nodes.length >= 2 && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "f64" + ) { + return lowerFsWriteDescriptorCall(lowerer, expr, loc); + } if ( bi.module === "fs" && (bi.member === "writeFileSync" || bi.member === "appendFileSync") && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 97fd3ccda..03b65bd54 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2197,7 +2197,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"], + // The bytes-data form, the { mode } options form, and the + // file-descriptor data form. + writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index c8dbc5a61..77fc699ad 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -906,6 +906,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "emitter.setDefaultMaxChk", "fs.readFileSyncBytes", "fs.writeFileSyncBytes", + "fs.writeFileSyncFd", "fs.appendFileSyncBytes", "zlib.inflateSync", "zlib.inflateRawSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 06ddb5007..ae3c6514d 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5262,6 +5262,11 @@ function buildLibFnSignatures() { "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, "fs.readFdSync": { argTypes: [F64, STRING], result: STRING }, + /** The writeFileSync(fd, data) form: a write(2) loop over the whole + * string at the descriptor's current offset, leaving the descriptor + * open (closeSync owns it). Errors carry the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ + "fs.writeFileSyncFd": { argTypes: [F64, STRING], result: VOID }, "fs.readFdSyncBytes": { argTypes: [F64], result: BYTES_U8 }, /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..25021cc12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-write-file-sync-fd.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e8009d25a..67f18c0d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4242,6 +4242,25 @@ double scr_fs_write_str_sync(double fd, ScrStr *data, double position, return wrote; } +/* writeFileSync(fd, data): Node writes the WHOLE data to the descriptor + * and leaves it open (closeSync owns its lifetime), at the descriptor's + * current offset — libuv's uv_fs_write loop, so a pipe's short writes + * resume until the data is gone. Errors keep the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ +void scr_fs_write_file_fd(double fd, ScrStr *data) { + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + double wrote = scr_fs_write_bytes(fd, bytes + at, data->len - at, -1); + /* A throw leaves 0 (or the short count a partial write reported); + * a zero-length write on a nonempty slice would spin, so stop. */ + if (wrote <= 0) break; + at += (size_t)wrote; + } + scr_str_utf8_end(owned); +} + /* fs.readSync(fd, buffer, offset, length[, position]) — the buffer form. * Node validates offset/length against the buffer before reading and * throws ERR_OUT_OF_RANGE; here the checks clamp to the same contract and diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 1d8d91d97..fa88873af 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -289,6 +289,10 @@ ScrStr *scr_fs_read_file(ScrStr *path); ScrStr *scr_fs_realpath(ScrStr *path); ScrStr *scr_fs_realpath_promise(ScrStr *path); void scr_fs_write_file(ScrStr *path, ScrStr *data); +/* writeFileSync(fd, data): a write(2) loop over the whole string at the + * descriptor's current offset; the descriptor stays open (closeSync owns + * it). Errors carry the fd operation's errno shape. */ +void scr_fs_write_file_fd(double fd, ScrStr *data); void scr_fs_append_file(ScrStr *path, ScrStr *data); bool scr_fs_exists(ScrStr *path); void scr_fs_mkdir(ScrStr *path); diff --git a/tests/corpus/fs-write-file-sync-fd.ts b/tests/corpus/fs-write-file-sync-fd.ts new file mode 100644 index 000000000..cfe992598 --- /dev/null +++ b/tests/corpus/fs-write-file-sync-fd.ts @@ -0,0 +1,14 @@ +// writeFileSync(fd, data): Node accepts an open file descriptor where it +// accepts a path, writes the whole data from the descriptor's current +// offset, and leaves the descriptor open. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const scratch = path.join(os.tmpdir(), `scriptc-write-fd-${process.pid}.txt`); +const fd = fs.openSync(scratch, "w"); +fs.writeFileSync(fd, "hello "); +fs.writeFileSync(fd, "world"); +fs.closeSync(fd); +console.log(fs.readFileSync(scratch, "utf8")); +fs.rmSync(scratch); diff --git a/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..5bfc37eb6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json @@ -0,0 +1 @@ +{ "plain": 3750, "san": 5900 } From f09e02255dcfb8ef3ffdd381ce0779c8672ad4e0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:01:43 -0700 Subject: [PATCH 09/15] Guard the X509 dynamic-value fence against recursive types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refuseX509DynValues walks a converted value's type looking for an X509Certificate record, and it memoized only records (by shape id) and unions (by union id). A type that reaches itself through an array, set, map or promise has no id to memoize by, so `type Rec = string | Rec[]` in a program that also holds a certificate recursed until the compiler ran out of stack — a crash, not a diagnostic. The walk now keeps an identity-based visited set over those kinds: a type met again while it is still being visited answers false, the least fixed point of reachability, since nothing outside the cycle makes a certificate reachable. Unions keep their memo, which now also covers a union cycle that names one. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/x509.ts | 37 +++++++++++++--- .../8420-x509-recursive-dyn-type.ts.json | 6 +++ tests/corpus/8420-x509-recursive-dyn-type.ts | 42 +++++++++++++++++++ .../8420-x509-recursive-dyn-type.ts.json | 1 + 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json create mode 100644 tests/corpus/8420-x509-recursive-dyn-type.ts create mode 100644 tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/x509.ts b/packages/compiler/src/frontend/lowering/builtins/x509.ts index 5d81070eb..1bd895eee 100644 --- a/packages/compiler/src/frontend/lowering/builtins/x509.ts +++ b/packages/compiler/src/frontend/lowering/builtins/x509.ts @@ -62,6 +62,22 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc ) return; const seen = new Map(); + // A type is recursive when it reaches itself without naming a shape or a + // union: `type Rec = string | Rec[]` passes through the array, set, map + // or promise case below. Those kinds have no id to memoize by, so a type + // met again while it is still being visited answers false: what a cycle + // adds to `holds` (a certificate exists somewhere in the type) is + // reachability, and nothing outside the cycle makes it reachable. + const visiting = new Set(); + const descend = (t: IrType, visit: () => boolean): boolean => { + if (visiting.has(t)) return false; + visiting.add(t); + try { + return visit(); + } finally { + visiting.delete(t); + } + }; const holds = (t: IrType): boolean => { switch (t.kind) { case "record": { @@ -79,13 +95,24 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc } case "array": case "set": - return holds(t.elem); + return descend(t, () => holds(t.elem)); case "map": - return holds(t.key) || holds(t.value); + return descend(t, () => holds(t.key) || holds(t.value)); case "promise": - return holds(t.inner); - case "union": - return lowerer.unions.get(t.unionId)?.arms.some(holds) === true; + return descend(t, () => holds(t.inner)); + case "union": { + // Unions have an id, so a cycle that names one is answered from the + // memo above; the identity guard covers a cycle that does not. + const key = `union:${t.unionId}`; + const known = seen.get(key); + if (known !== undefined) return known; + return descend(t, () => { + seen.set(key, false); + const result = lowerer.unions.get(t.unionId)?.arms.some(holds) === true; + seen.set(key, result); + return result; + }); + } default: return false; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json new file mode 100644 index 000000000..9bcba109c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8420-x509-recursive-dyn-type.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8420-x509-recursive-dyn-type.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8420-x509-recursive-dyn-type.ts b/tests/corpus/8420-x509-recursive-dyn-type.ts new file mode 100644 index 000000000..99d4991d2 --- /dev/null +++ b/tests/corpus/8420-x509-recursive-dyn-type.ts @@ -0,0 +1,42 @@ +// A recursive type that cycles through an array and a set, next to a +// certificate: the fence that refuses an X509Certificate in an `unknown` +// slot (x509.ts) walks the converted value's type, and a cycle through +// anything but a record used to recurse until the stack ran out — a crash, +// not a diagnostic. The types are named here the way a JSON-ish union is, +// and the certificate is the one 7090 parses. +import { X509Certificate } from "node:crypto"; + +// A self-signed certificate without alternative names (as in 7090). +const PLAIN = `-----BEGIN CERTIFICATE----- +MIIC8jCCAdoCAQcwDQYJKoZIhvcNAQELBQAwPjEaMBgGA1UEAwwRcGxhaW4uZXhh +bXBsZS5vcmcxIDAeBgkqhkiG9w0BCQEWEXBsYWluQGV4YW1wbGUub3JnMCAXDTI2 +MTAyMjA5NDY0MVoYDzIxMjYwOTI4MDk0NjQxWjA+MRowGAYDVQQDDBFwbGFpbi5l +eGFtcGxlLm9yZzEgMB4GCSqGSIb3DQEJARYRcGxhaW5AZXhhbXBsZS5vcmcwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq01NcNAy9Ng04mVuAFvmZUvsm +rY7aOaMgtODmptulgZ2qw25E09RgGYCn1S+4IYDu9hH9X4s+28Ag7Khmj4nmeoXN +of5095wche8DR2AAUElqyr/Nmgm2kTLdIJX4wl0eWXHIlmSPdOUNovA6JjGKH99b +8W7a2TqY/TsSadpPSvvN2esr7Jr4KgW7MXPUOnwNbcBOVcQP9rtFrduAWz01q0HM +02LboclnUnk/e58BXAj8vgdVojljDcc0bhbdz/Hh1/5gaaNILjJNOd4AgwZaCT02 +KojGz4Nb6MSgFmON2iMgAZjY1fXXkJ7wsfVPytjW73e7uV/RFuqSHQZenxupAgMB +AAEwDQYJKoZIhvcNAQELBQADggEBAJX1DGuiSLoCKBDI3oCgAu7X86G01ovEgZJr +8JrmGHW5q7ujjo53t46h8XPm3LJ8UV9/MZ+Yry43xB2txXYv3b3g/i+7JdTWLgUD +INA5VWvVkEZfm0lBEpU1Agpy4lV5uUCKZTgP/3MY5hnJ0YhQIs4BOjbwueMRFshL +yyHVb73MMbpWaZ7+T368seafa+ekeuY6gQly/HacuszjoYd6KKV3n8eDewenClCF +HoOF1Y4I+CvmR+a6B3iBczJ+a4tceFIIWsZUIeqzvGHpM7G9YwlmbvGprfLgR8Nv +igUib/D9tU3hdL6VWN5nMjdXV/8Le4nDnBjVBjYDkVbsnmT91AQ= +-----END CERTIFICATE----- +`; + +type Rec = string | Rec[]; +type RecSet = number | Set; + +const cert = new X509Certificate(PLAIN); +const rec: Rec = ["a", ["b"]]; +const set: RecSet = new Set([1, 2]); + +function describe(value: unknown): string { + return typeof value; +} + +console.log(describe(rec), rec.length, describe(set), set.size); +console.log(cert.subject); diff --git a/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json b/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json new file mode 100644 index 000000000..03614a023 --- /dev/null +++ b/tests/harness/corpus-costs/8420-x509-recursive-dyn-type.ts.json @@ -0,0 +1 @@ +{ "plain": 2100, "san": 1600 } From c7c214532eca878b151f8010280472a600fefa8b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:01:49 -0700 Subject: [PATCH 10/15] Lower species forms through a narrowing wrapper and a union flatMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The species lowering for a callback method rebuilt its `%arr.` call by shape — the result had to be a call taking the receiver as its first argument — so two forms the method lowering does produce fell to SC1090 whenever the program declared a class that extends Array: - a filter whose callback narrows: the filtered array comes back through the checked-narrowing wrapper, `call(%arr.narrow, [call(%arr.filter, …)])`, and the species path saw a call whose first argument is not the receiver; - a flatMap whose callback returns a union with an array arm, which lowers to `%arr.flatMap.` — a helper boundedArrayHofHelper rejected because arrHofInfo, which holds the pipeline-fusable helpers, has no entry for it. The call is now found by walking the expression for the `%arr.` helper call over the receiver, whatever wraps it, and boundedArrayHofHelper accepts any `%arr.` helper (forEach excepted, it makes no array), so the union flatMap earns a bounded variant. Hidden locals are still declared in the old order, so a program that compiled before gets byte-identical IR. Co-Authored-By: Claude Opus 5.5 --- .../lowering/builtins/array-classes.ts | 59 +++++++++++++------ .../lowering/containers/array-callbacks.ts | 9 ++- .../8421-array-subclass-species-forms.ts.json | 6 ++ .../8421-array-subclass-species-forms.ts | 32 ++++++++++ .../8421-array-subclass-species-forms.ts.json | 1 + 5 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json create mode 100644 tests/corpus/8421-array-subclass-species-forms.ts create mode 100644 tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/array-classes.ts b/packages/compiler/src/frontend/lowering/builtins/array-classes.ts index 1922be0af..58a948372 100644 --- a/packages/compiler/src/frontend/lowering/builtins/array-classes.ts +++ b/packages/compiler/src/frontend/lowering/builtins/array-classes.ts @@ -647,26 +647,27 @@ export function lowerWithArraySpecies( // instance is created (with that length for map, 0 otherwise), then // the method runs to that length and its elements move into the // instance. - const bounded = - result.kind === "call" && - result.args[0]?.kind === "varRef" && - result.args[0].localId === local.id - ? boundedArrayHofHelper(lowerer, result.callee) - : null; - if (bounded === null || result.kind !== "call") + const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }]; + let bound: IrLocal | undefined; + const rebound = rebuildSpeciesCall(result, local.id, (inner) => { + const args: IrExpr[] = [receiverRef]; + for (const arg of inner.args.slice(1)) { + const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type); + stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc }); + args.push({ kind: "varRef", localId: held.id, type: arg.type, loc }); + } + const length = lowerer.declareHiddenLocal("%speciesLength", F64); + bound = length; + args.push({ kind: "varRef", localId: length.id, type: F64, loc }); + const helper = boundedArrayHofHelper(lowerer, inner.callee); + return helper === null ? null : { ...inner, callee: helper, args }; + }); + if (rebound === null || bound === undefined) lowerer.unsupported( "SC1090", call, `'${access.name.text}' in this form when the program declares a class that extends Array`, ); - const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }]; - const args: IrExpr[] = [receiverRef]; - for (const arg of result.args.slice(1)) { - const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type); - stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc }); - args.push({ kind: "varRef", localId: held.id, type: arg.type, loc }); - } - const bound = lowerer.declareHiddenLocal("%speciesLength", F64); stmts.push({ kind: "varDecl", localId: bound.id, @@ -681,7 +682,6 @@ export function lowerWithArraySpecies( loc, }); const boundRef: IrExpr = { kind: "varRef", localId: bound.id, type: F64, loc }; - args.push(boundRef); const instance = lowerer.declareHiddenLocal("%speciesInstance", receiver.type); const length: IrExpr = mode.resultLength ? boundRef @@ -706,7 +706,7 @@ export function lowerWithArraySpecies( fn: "array.speciesFinish", args: [ { kind: "varRef", localId: instance.id, type: receiver.type, loc }, - { ...result, callee: bounded, args }, + rebound, { kind: "boolLit", value: mode.setLength, type: BOOL, loc }, ], type: result.type, @@ -736,6 +736,31 @@ export function lowerWithArraySpecies( }; } +/** `e` with the `%arr.` helper call over `receiverLocal` rebuilt by + * `rebuild`, or null when `e` holds no such call. The helper call is the + * one taking the receiver as its first argument; a filter whose callback + * narrows converts the filtered array after the helper ran, so the call + * can sit behind a wrapper (the checked-narrowing call), which this + * rebuild leaves in place. */ +function rebuildSpeciesCall( + e: IrExpr, + receiverLocal: string, + rebuild: (inner: IrExpr & { kind: "call" }) => IrExpr | null, +): IrExpr | null { + if (e.kind !== "call") return null; + const first = e.args[0]; + if (first !== undefined && first.kind === "varRef" && first.localId === receiverLocal) + return rebuild(e); + for (let i = 0; i < e.args.length; i++) { + const replaced = rebuildSpeciesCall(e.args[i]!, receiverLocal, rebuild); + if (replaced === null) continue; + const args = e.args.slice(); + args[i] = replaced; + return { ...e, args }; + } + return null; +} + /** `C.of(...items)` and `C.from(iterable)` on a class declared `extends * Array` that declares neither: Array.of / Array.from's elements, in an * instance the class constructs (`new C(items.length)` for of, `new C()` diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index 5eee71231..eca6e5ed2 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -3382,10 +3382,13 @@ export function lowerTuplePredicateCall( * of the receiver's length when it starts. ArraySpeciesCreate reads the * receiver's length before the species constructor runs, so a constructor * that appends to the receiver does not extend the loop - * (lowerWithArraySpecies). Null when `name` is no such helper. */ + * (lowerWithArraySpecies). Every helper that reads a length is one, the + * flatMap whose callback returns a union included, whether or not a + * pipeline can fuse it (arrHofInfo holds the fusable ones); forEach makes + * no array, so it stays out. Null when `name` is no such helper. */ export function boundedArrayHofHelper(lowerer: Lowerer, name: string): string | null { - const info = lowerer.arrHofInfo.get(name); - if (!info || info.method === "forEach") return null; + if (!name.startsWith("%arr.")) return null; + if (lowerer.arrHofInfo.get(name)?.method === "forEach") return null; const bounded = `${name}.bounded`; if (lowerer.liftedFnNamed(bounded)) return bounded; const fn = lowerer.liftedFnNamed(name); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json new file mode 100644 index 000000000..da74ec380 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8421-array-subclass-species-forms.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8421-array-subclass-species-forms.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8421-array-subclass-species-forms.ts b/tests/corpus/8421-array-subclass-species-forms.ts new file mode 100644 index 000000000..67c119c80 --- /dev/null +++ b/tests/corpus/8421-array-subclass-species-forms.ts @@ -0,0 +1,32 @@ +// One class that extends Array sends every array method call in the program +// through the species lowering, plain-array receivers included. Two callback +// forms the species path used to refuse: a filter whose callback narrows the +// element union (a type predicate, or a discriminant test tsc infers one +// from), which wraps the filtered array in a checked-narrowing conversion, +// and a flatMap whose callback returns a union holding an array arm. +class Stack extends Array {} + +type Item = { kind: "a"; n: number } | { kind: "b"; s: string }; +const items: Item[] = [ + { kind: "a", n: 1 }, + { kind: "b", s: "x" }, + { kind: "a", n: 2 }, +]; +const isA = (item: Item): item is { kind: "a"; n: number } => item.kind === "a"; + +const tagged = items.filter(isA); +const inferred = items.filter((item) => item.kind === "b"); +const blocked = items.filter((item) => { + return item.kind === "b"; +}); +const flattened = items.flatMap((item) => (item.kind === "a" ? [item.n] : [])); +const mixed = items.flatMap((item) => (item.kind === "a" ? [item.n] : undefined)); +console.log(tagged.length, tagged.map((item) => item.n).join(",")); +console.log(inferred.length, inferred.map((item) => item.s).join(","), blocked.length); +console.log(flattened.join(","), mixed.length, mixed.join(",")); + +const stack = Stack.from([1, 2, 3]); +const even = stack.filter((n) => n % 2 === 0); +const doubled = stack.flatMap((n) => (n > 1 ? [n, n * 10] : [])); +console.log(even instanceof Stack, even.join(","), stack.length); +console.log(doubled instanceof Stack, doubled.join(","), stack.length); diff --git a/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json b/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json new file mode 100644 index 000000000..c328ae618 --- /dev/null +++ b/tests/harness/corpus-costs/8421-array-subclass-species-forms.ts.json @@ -0,0 +1 @@ +{ "plain": 1850, "san": 1250 } From 86299300f68c74caf2391dc3641a1919a1d5d0f9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:01:55 -0700 Subject: [PATCH 11/15] Fold Array.isArray over a computed argument after evaluating it `Array.isArray(v)` is a compile-time constant when `v` has a static type: an `T[]` or a tuple is an array, every other kind is not. The fold only accepted a bare variable, a record field or a field read, because only those could be dropped without losing effects, so `Array.isArray(make())` and `Array.isArray([1, 2])` reached an SC1090 refusal. A computed argument now folds the same way after being evaluated for its effects: the value goes into a sequence whose discarded statement is the argument. Unions keep answering by their runtime tag, and `unknown` values keep their real runtime test. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-calls.ts | 32 ++++++++++++------- .../8422-array-isarray-computed.ts.json | 6 ++++ tests/corpus/8422-array-isarray-computed.ts | 28 ++++++++++++++++ .../8422-array-isarray-computed.ts.json | 1 + 4 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json create mode 100644 tests/corpus/8422-array-isarray-computed.ts create mode 100644 tests/harness/corpus-costs/8422-array-isarray-computed.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 6229917a2..3f9e66d4b 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4888,9 +4888,10 @@ const DYN_STRING_ONLY_METHODS = new Set([ /** `Array.isArray(v)` — a real runtime test on `unknown` values (the checked-dynamic tree's * array kind: dyn arrays answer true, bytes/objects/scalars false — exactly * JS, Uint8Array included), a compile-time constant on statically-typed - * ones (an `T[]` value IS an array, every other static kind is not; folded - * only over side-effect-free reads, the `in`-operator discipline; unions - * fence with the narrow-first hint). Null when the callee isn't THE + * ones (an `T[]` value IS an array, every other static kind is not; a + * computed argument is evaluated and its value discarded, the + * `in`-operator discipline; unions fence with the narrow-first hint). Null + * when the callee isn't THE * stdlib Array.isArray, so the chain keeps trying. */ function lowerArrayIsArrayCall( lowerer: Lowerer, @@ -4960,14 +4961,23 @@ function lowerArrayIsArrayCall( ); } if (arg.type.kind === "caught") return null; - if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") { - return { kind: "boolLit", value: lowerer.isArrayValueType(arg.type), type: BOOL, loc }; - } - lowerer.unsupported( - "SC1090", - call, - "statically-decided Array.isArray on computed arguments (bind the value to a variable first)", - ); + const folded: IrExpr = { + kind: "boolLit", + value: lowerer.isArrayValueType(arg.type), + type: BOOL, + loc, + }; + if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") return folded; + // A computed argument (`Array.isArray(make())`, `Array.isArray([1, 2])`) + // still answers from its static type, so evaluate the argument for its + // effects and discard the value. + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: arg, loc }], + result: folded, + type: BOOL, + loc, + }; } /** Predicate declarations currently being inlined — re-entrancy guard diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json new file mode 100644 index 000000000..c04737333 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8422-array-isarray-computed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8422-array-isarray-computed.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8422-array-isarray-computed.ts b/tests/corpus/8422-array-isarray-computed.ts new file mode 100644 index 000000000..c0a91491c --- /dev/null +++ b/tests/corpus/8422-array-isarray-computed.ts @@ -0,0 +1,28 @@ +// `Array.isArray` over a computed argument: a call, an array literal, an +// object literal. The answer comes from the argument's static type, so the +// argument is evaluated for its effects and its value discarded. A union +// argument keeps answering by its runtime tag, and side effects of the +// argument survive the fold. +function rows(): number[] { + return [1, 2]; +} +function label(): string { + return "rows"; +} +type Pair = [number, string]; +function pair(): Pair { + return [3, "three"]; +} +let evals = 0; +function counted(): number[] { + evals++; + return []; +} + +console.log(Array.isArray(rows()), Array.isArray([1, 2]), Array.isArray({ a: 1 })); +console.log(Array.isArray(label()), Array.isArray(pair())); +console.log(Array.isArray(counted()), evals); +const maybe: string | string[] = ["a"]; +const narrow: string | string[] = "a"; +console.log(Array.isArray(maybe), Array.isArray(narrow)); +console.log(Array.isArray(rows().map((n) => n * 2))); diff --git a/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json b/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json new file mode 100644 index 000000000..b042dfb6e --- /dev/null +++ b/tests/harness/corpus-costs/8422-array-isarray-computed.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 1375 } From 7918ec43f74f8537aa7279b0cb568396a93fe0c6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 08:20:06 -0500 Subject: [PATCH 12/15] Skip the arena clear for record literals that write every field An arena-aware record constructor zeroed the whole object after the bump, then stored the count and shape number, and the record literal that called it then stored every field. A record literal covers every field of its shape, so the zeroing only mattered for the dispose entries of an arena that is not closed, which read the fields when the arena drops. The arena-aware constructor of a record shape without dispose entries now clears only the collector header, when the shape has one. Its object is never read before the literal's stores: a release of an arena object only lowers its count, and scr_arena_obj and the heap fallback still return zeroed objects. Shapes with dispose entries and classes, whose constructors need not store every field, keep the whole clear. --- .../compiler/src/backend/llvm/arenas.test.ts | 32 +++++++++++++++++++ packages/compiler/src/backend/llvm/arenas.ts | 14 +++++++- packages/compiler/src/backend/llvm/shapes.ts | 14 +++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/arenas.test.ts b/packages/compiler/src/backend/llvm/arenas.test.ts index 1ab4b010a..a0885bba4 100644 --- a/packages/compiler/src/backend/llvm/arenas.test.ts +++ b/packages/compiler/src/backend/llvm/arenas.test.ts @@ -67,6 +67,38 @@ console.log(total); expect(body).not.toContain("ScrArena.closed"); expect(body).not.toContain("@scr_arena_disposal"); } + // A record literal stores every field, and no dispose entry reads them: + // the record's constructor does not clear the object. A class constructor + // may leave fields unset, so the class's does. + const record = constructors.find((body) => body.includes("@sc_rnew_"))!; + const cls = constructors.find((body) => !body.includes("@sc_rnew_"))!; + expect(record).not.toContain("@llvm.memset"); + expect(cls).toContain("@llvm.memset"); +}); + +test("record literals in an arena that is not closed keep the clear their dispose entries need", async () => { + // The items hold outside strings, so their groups are not closed. + const llvm = await llvmFor(` +interface Item { + label: string; + next: Item | null; +} +function chain(labels: string[], n: number): Item { + return n === 0 ? { label: labels[0]!, next: null } : { label: labels[n % labels.length]!, next: chain(labels, n - 1) }; +} +function length(item: Item): number { + return item.next === null ? item.label.length : item.label.length + length(item.next); +} +const labels = ["a", "bb", "ccc"]; +let total = 0; +for (let round = 0; round < 4; round++) total += length(chain(labels, 10 + round)); +console.log(total); +`); + expect(llvm).toMatch(/@scr_arena_open\(ptr %[\w.]+, i32 0\)/); + const items = arenaConstructors(llvm).filter((body) => body.includes("@sc_rnew_")); + expect(items.length).toBe(1); + expect(items[0]).toContain(".dispose"); + expect(items[0]).toContain("@llvm.memset"); }); test("an arena that is not closed keeps the dispose list in every arena-aware constructor", async () => { diff --git a/packages/compiler/src/backend/llvm/arenas.ts b/packages/compiler/src/backend/llvm/arenas.ts index 3e3954856..426d3a9e3 100644 --- a/packages/compiler/src/backend/llvm/arenas.ts +++ b/packages/compiler/src/backend/llvm/arenas.ts @@ -275,6 +275,12 @@ export function arenaCurrentAddress(host: ArenaHost, dest: string): string[] | n * the arena is full. The bump, the clear, the header and the dispose entry * are inline (scr_arena.c's ScrArena ABI); scr_arena_obj takes a new chunk * and scr_arena_disposal grows the list. + * + * `clear` is what the bump path zeroes: the whole object, or only the + * collector header, for callers that store every word of the object before + * anything reads it (a record literal of a shape with no dispose entries: + * `init` stores the count and the shape number, and the literal every + * field). scr_arena_obj and the heap constructor return zeroed objects. */ export function arenaNewLines( host: ArenaHost, @@ -284,6 +290,7 @@ export function arenaNewLines( dispose: string | null, init: string[], result: string, + clear: "object" | "header" = "object", ): string[] { const S = host.sizeType; // ScrArena's fields and dispose entries are pointer-sized (scr_arena.c). @@ -328,8 +335,13 @@ export function arenaNewLines( `carve:`, ` %end = getelementptr inbounds i8, ptr %bp, ${S} %n`, ` store ptr %end, ptr %a`, - ` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`, ); + if (clear === "object") + lines.push(` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`); + else if (cyc !== null) + lines.push( + ` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} ${CYC_HDR}, i1 false)`, + ); if (cyc !== null) lines.push( ` %id = trunc i32 %tid to i16`, diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 489b5d028..993a1ffa5 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -2387,8 +2387,20 @@ export function emitRecordShapes( ` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`, ` store i32 ${shapeNumber}, ptr %shp ; shape number`, ); + // A record literal covers every field of its shape (ir.ts recordLit), + // so without dispose entries, which read the fields at the drop, no + // field is read before the literal stores it. defs.push( - ...arenaNewLines(host, newSym, sizeOf, traced ? cycType : null, dispose, init, "%o"), + ...arenaNewLines( + host, + newSym, + sizeOf, + traced ? cycType : null, + dispose, + init, + "%o", + dispose === null ? "header" : "object", + ), ); if (dispose !== null) defs.push( From 4e18ec933b721e03c6f5d8706b78c92d40b2258a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 08:28:40 -0500 Subject: [PATCH 13/15] Record the preflight and order baselines of the new corpus programs array-from-typed-array-mapper.ts, promise-catch-optional-chain.ts, promise-resolve-promise-or-unit.ts and promise-void-catch-undefined.ts each load only themselves and report no preflight diagnostics. --- .../tests/corpus/array-from-typed-array-mapper.ts.json | 6 ++++++ .../tests/corpus/promise-catch-optional-chain.ts.json | 6 ++++++ .../tests/corpus/promise-resolve-promise-or-unit.ts.json | 6 ++++++ .../tests/corpus/promise-void-catch-undefined.ts.json | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json new file mode 100644 index 000000000..6862c988b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-from-typed-array-mapper.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/array-from-typed-array-mapper.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json new file mode 100644 index 000000000..5ff12d854 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-catch-optional-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-catch-optional-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json new file mode 100644 index 000000000..c85693cc2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-resolve-promise-or-unit.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-resolve-promise-or-unit.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json new file mode 100644 index 000000000..f9ad9a7b3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-void-catch-undefined.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-void-catch-undefined.ts" + ], + "diags": [] +} From 2c7b67c5aa1a6a0f6af2a76c65cb9d8bdebee488 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 08:35:25 -0500 Subject: [PATCH 14/15] Keep module-level lets unboxed in the dynamic tier and return receivers from dispatch helpers - The jsval pass also promotes dyn module globals to jsval globals when only dynamic-tier functions read and write them and the boxing their writes save outweighs the boxing their reads add, the measure locals use. `checksum += f(x)` in a top-level loop no longer allocates a number cell per update. - A class dispatch helper that calls a method returning `this` on every path calls a copy of the method that returns nothing and answers its own receiver, the capsule of the same instance at its exact class, so the call boxes nothing. --- .../compiler/src/frontend/lowering/lowerer.ts | 2 + .../src/frontend/lowering/receiver-returns.ts | 131 ++++++++++++++++++ packages/compiler/src/ir/jsval.test.ts | 63 +++++++++ packages/compiler/src/ir/jsval.ts | 91 +++++++++++- .../dynamic-method-returns-receiver.mjs.json | 6 + .../dynamic-module-let-imports/main.ts.json | 7 + .../dynamic-module-let-numbers.mjs.json | 6 + .../dynamic-method-returns-receiver.mjs | 72 ++++++++++ .../dynamic-module-let-imports/counter.mjs | 13 ++ .../corpus/dynamic-module-let-imports/main.ts | 6 + tests/corpus/dynamic-module-let-numbers.mjs | 53 +++++++ 11 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/receiver-returns.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json create mode 100644 tests/corpus/dynamic-method-returns-receiver.mjs create mode 100644 tests/corpus/dynamic-module-let-imports/counter.mjs create mode 100644 tests/corpus/dynamic-module-let-imports/main.ts create mode 100644 tests/corpus/dynamic-module-let-numbers.mjs diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index e6b6a4653..475fab7e7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -225,6 +225,7 @@ import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js"; import { promoteJsvals } from "../../ir/jsval.js"; +import { returnDispatchReceivers } from "./receiver-returns.js"; import { analyzeTiers, dynamicStatementCount, @@ -9464,6 +9465,7 @@ export class Lowerer { applyHandleProperties(this, functions); installBufferValue(functions); installFetchBodyWebStreams(this, functions); + returnDispatchReceivers(this, functions); // Globals typed by a class that never REGISTERED (a JS class whose // collection fenced — Symbol-keyed fields, an unsupported base): the diff --git a/packages/compiler/src/frontend/lowering/receiver-returns.ts b/packages/compiler/src/frontend/lowering/receiver-returns.ts new file mode 100644 index 000000000..5e83f236a --- /dev/null +++ b/packages/compiler/src/frontend/lowering/receiver-returns.ts @@ -0,0 +1,131 @@ +/* Methods that return their receiver, called through an untyped value. + * + * A JavaScript method that ends every path with `return this` (a fluent + * setter such as `step(dx, dy) { this.x += dx; return this; }`) boxes its + * receiver on every call: a lookup of the instance's live capsule, or a new + * capsule when it has none. A class dispatch helper + * (`%dyn.class.call.N`) that calls such a method already holds a capsule + * of the same instance at its exact class: its receiver after + * normalization. The helper now calls a copy of the method that returns + * nothing and answers its own receiver, so the call boxes nothing. Other + * callers (vtables, typed code, method values) keep the method itself. */ +import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; +import { VOID } from "../../ir/ir.js"; +import { varRef } from "../../ir/build.js"; +import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import type { Lowerer } from "./lowerer.js"; + +const DISPATCH_PREFIX = "%dyn.class.call."; +const BOX_EXACT_PREFIX = "%dyn.class.boxExact:"; + +/** The name of a method's copy that returns nothing. */ +function receiverCopyName(method: string): string { + return `%dyn.class.receiver:${method.slice(1)}`; +} + +export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[]): void { + const byName = new Map(functions.map((fn) => [fn.name, fn])); + const copies = new Map(); + const copyOf = (name: string): IrFunction | null => { + const known = copies.get(name); + if (known !== undefined) return known; + const method = byName.get(name); + const copy = method && returnsReceiver(lowerer, method) ? receiverCopy(method) : null; + copies.set(name, copy); + if (copy) functions.push(copy); + return copy; + }; + for (const helper of functions) { + if (!helper.name.startsWith(DISPATCH_PREFIX)) continue; + const receiver = helper.params[0]?.localId; + if (receiver === undefined || !normalizedOnly(helper, receiver)) continue; + for (const stmt of helper.body) { + if (stmt.kind !== "if" || stmt.then.length !== 1) continue; + const only = stmt.then[0]!; + if (only.kind !== "return" || only.value === null || only.value.kind !== "call") continue; + const call = only.value; + if (!receiverArgument(call.args[0], receiver)) continue; + const copy = copyOf(call.callee); + if (!copy) continue; + stmt.then = [ + { + kind: "exprStmt", + expr: { ...call, callee: copy.name, type: VOID }, + loc: only.loc, + }, + { kind: "return", value: varRef(receiver, only.value.type, only.loc), loc: only.loc }, + ]; + } + } +} + +/** The helper's receiver is assigned only by its normalization, so it is + * the capsule of the instance's own class wherever a branch reads it. */ +function normalizedOnly(helper: IrFunction, receiver: string): boolean { + return everyStmtList(helper.body, { + stmt: (s) => + !( + (s.kind === "assign" || s.kind === "forOf") && + s.localId === receiver && + !( + s.kind === "assign" && + s.value.kind === "call" && + s.value.callee === "%dyn.class.normalize" + ) + ), + expr: (e) => !((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === receiver), + }); +} + +/** `dynCheck(receiver)`, possibly upcast to the method's class. */ +function receiverArgument(arg: IrExpr | undefined, receiver: string): boolean { + if (arg?.kind === "upcast") arg = arg.value; + return arg?.kind === "dynCheck" && arg.value.kind === "varRef" && arg.value.localId === receiver; +} + +/** Whether every path of a synchronous method returns `this` boxed at its + * exact class. */ +function returnsReceiver(lowerer: Lowerer, fn: IrFunction): boolean { + if (fn.async !== undefined || fn.generator !== undefined) return false; + if (fn.captures?.length || fn.classCaptures?.length) return false; + if (fn.returnType.kind !== "dyn") return false; + const self = fn.params[0]; + if (self?.type.kind !== "object" || self.name !== "this") return false; + const last = fn.body[fn.body.length - 1]; + if (last?.kind !== "return") return false; + const isSelf = (value: IrExpr | null): boolean => { + if (value === null) return false; + if (value.kind === "call" && value.callee.startsWith(BOX_EXACT_PREFIX)) + return thisRef(value.args[0], self.localId); + // Without exact boxing the capsule names the static class, which is + // the instance's own class only when nothing extends it. + if (value.kind === "dynFrom" && value.liveRef === undefined && value.fnName === undefined) { + const className = self.type.kind === "object" ? self.type.className : ""; + return ( + thisRef(value.value, self.localId) && + (lowerer.classes.get(className)?.subclasses.length ?? 1) === 0 + ); + } + return false; + }; + return everyStmtList(fn.body, { + stmt: (s) => s.kind !== "return" || isSelf(s.value), + expr: () => true, + }); +} + +function thisRef(value: IrExpr | undefined, thisId: string): boolean { + return value?.kind === "varRef" && value.localId === thisId; +} + +/** A copy of `method` whose returns return nothing. */ +function receiverCopy(method: IrFunction): IrFunction { + const copy = structuredClone(method); + copy.name = receiverCopyName(method.name); + copy.returnType = VOID; + copy.body = transformStmtList(copy.body, { + stmt: (s): IrStmt => (s.kind === "return" ? { ...s, value: null } : s), + expr: (e) => e, + }); + return copy; +} diff --git a/packages/compiler/src/ir/jsval.test.ts b/packages/compiler/src/ir/jsval.test.ts index 610bfa804..d2eadf18d 100644 --- a/packages/compiler/src/ir/jsval.test.ts +++ b/packages/compiler/src/ir/jsval.test.ts @@ -344,3 +344,66 @@ test("a parameter read by dyn consumers or passed cells keeps the dyn ABI", () = expect(twice.returnType).toEqual(DYN); expect(validateModule(mod)).toEqual([]); }); + +/** `for (...) %g.sum = %g.sum + o.step` in a dynamic function. */ +function globalLoop(name: string, global: string): IrFunction { + return fn(name, [ + { + kind: "while", + cond: { kind: "boolLit", value: false, type: BOOL, loc }, + body: [ + { + kind: "assign", + localId: global, + value: op("dyn.add", ref(global), keyGet(ref("o"), "step")), + loc, + }, + ], + loc, + }, + { kind: "return", value: ref("o"), loc }, + ]); +} + +test("a dyn module global written by operators in a loop becomes a jsval global", () => { + const loop = globalLoop("loop", "%g.e.sum"); + // %g.sum.x, once: a dyn consumer. + const report = fn("report", [ + { kind: "exprStmt", expr: keyGet(ref("%g.e.sum"), "x"), loc }, + { kind: "return", value: ref("o"), loc }, + ]); + const mod = moduleOf(loop, report); + mod.globals = [{ id: "%g.e.sum", name: "sum", type: DYN, mutable: true }]; + promoteJsvals(mod); + expect(mod.globals[0]!.type).toEqual(JSVAL); + // The loop's own result (o, a jsval result) is the second js.fromDyn. + expect(jsCalls(loop.body)).toEqual(["js.add", "js.fromDyn", "js.fromDyn"]); + // One box for the read; the second call is the jsval result o. + expect(jsCalls(report.body)).toEqual(["js.toDyn", "js.fromDyn"]); + expect(validateModule(mod)).toEqual([]); +}); + +test("a module global a static function reads stays dyn", () => { + const loop = globalLoop("loop", "%g.e.sum"); + const reader: IrFunction = { + name: "reader", + params: [], + returnType: DYN, + locals: [], + body: [{ kind: "return", value: ref("%g.e.sum"), loc }], + loc, + }; + const mod = moduleOf(loop, reader); + expect(reader.tier).toBeUndefined(); + mod.globals = [{ id: "%g.e.sum", name: "sum", type: DYN, mutable: true }]; + promoteJsvals(mod); + expect(mod.globals[0]!.type).toEqual(DYN); + expect(jsCalls(loop.body)).toEqual([ + "js.toDyn", + "js.add", + "js.fromDyn", + "js.fromDyn", + "js.fromDyn", + ]); + expect(validateModule(mod)).toEqual([]); +}); diff --git a/packages/compiler/src/ir/jsval.ts b/packages/compiler/src/ir/jsval.ts index 4828e9a90..fad4a55c7 100644 --- a/packages/compiler/src/ir/jsval.ts +++ b/packages/compiler/src/ir/jsval.ts @@ -32,7 +32,9 @@ * of the parameter by dyn consumers box more often than its callers do; * - a dyn local becomes a jsval local when the boxing it saves on writes * of operator results outweighs the boxing its reads by dyn consumers add - * (loop depth weights both). + * (loop depth weights both); + * - a dyn module global becomes a jsval global by the same measure, summed + * over every function, when only dynamic-tier functions reference it. * * The rewrite changes representation only: every js builtin implements its * dyn twin's JS semantics, including ToPrimitive order and error messages @@ -116,10 +118,12 @@ export function promoteJsvals(mod: IrModule): number { const storesJs = (e: IrExpr): boolean => e.kind === "call" && sites.site(e.callee)?.kind === "set"; const params = jsvalParams(mod, direct, results, storesJs); + const globals = promotableGlobals(mod, results, params, storesJs); let count = 0; for (const fn of mod.functions) - if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs)) count++; + if (jsvalEligible(fn) && promoteFunction(fn, results, params, storesJs, globals)) count++; for (const fn of mod.functions) if (results.has(fn.name)) fn.returnType = JSVAL; + for (const global of mod.globals ?? []) if (globals.has(global.id)) global.type = JSVAL; return count; } @@ -273,6 +277,22 @@ function promotableLocals( credits: ReadonlyMap = new Map(), jsParams: ReadonlyMap = new Map(), ): Set { + const candidates = localCandidates(fn, credits); + if (candidates.size === 0) return new Set(); + const blocked = new Set(); + scanBindingCosts(fn, candidates, blocked, producer, jsResult, storesJs, jsParams); + const promoted = new Set(); + for (const [id, cost] of candidates) + if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + return promoted; +} + +/** The dyn locals of a function that may become jsval locals, parameters + * with their credit. */ +function localCandidates( + fn: IrFunction, + credits: ReadonlyMap, +): Map { const candidates = new Map(); for (const local of fn.locals) { if (local.type.kind === "dyn" && local.boxed === undefined && local.tdz === undefined) @@ -286,8 +306,69 @@ function promotableLocals( } for (const c of fn.captures ?? []) candidates.delete(c.localId); for (const c of fn.classCaptures ?? []) candidates.delete(c.localId); + return candidates; +} + +/** The dyn module globals that become jsval globals: those that only + * dynamic-tier functions reference, by reads and plain writes, and whose + * saved boxing outweighs the added boxing summed over every function, the + * same measure as for locals. Libraries keep every global (the host reads + * exported bindings), and so do bindings with a temporal dead zone. */ +function promotableGlobals( + mod: IrModule, + results: ReadonlySet, + jsParams: ReadonlyMap, + storesJs: (e: IrExpr) => boolean, +): Set { + const candidates = new Map(); + if (mod.lib !== undefined) return new Set(); + for (const global of mod.globals ?? []) + if (global.type.kind === "dyn" && global.tdz === undefined && global.initFlag === undefined) + candidates.set(global.id, { saved: 0, added: 0 }); if (candidates.size === 0) return new Set(); const blocked = new Set(); + const producer = (e: IrExpr): boolean => + isProducerCall(e) || (e.kind === "call" && results.has(e.callee)); + for (const fn of mod.functions) { + if (!jsvalEligible(fn)) { + // A static function reads and writes the binding as a cell (I1). + everyStmtList(fn.body, { + expr: (e) => { + if ( + (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") && + candidates.has(e.localId) + ) + blocked.add(e.localId); + return true; + }, + stmt: (st) => { + if ((st.kind === "assign" || st.kind === "forOf") && candidates.has(st.localId)) + blocked.add(st.localId); + return true; + }, + }); + continue; + } + scanBindingCosts(fn, candidates, blocked, producer, results.has(fn.name), storesJs, jsParams); + } + const promoted = new Set(); + for (const [id, cost] of candidates) + if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); + return promoted; +} + +/** Adds to `candidates` the boxing a jsval binding saves and adds in `fn`, + * and to `blocked` the candidates `fn` references other than by reads and + * plain writes. */ +function scanBindingCosts( + fn: IrFunction, + candidates: ReadonlyMap, + blocked: Set, + producer: (e: IrExpr) => boolean, + jsResult: boolean, + storesJs: (e: IrExpr) => boolean, + jsParams: ReadonlyMap, +): void { const write = (id: string, value: IrExpr | null, weight: number): void => { const cost = candidates.get(id); if (cost === undefined) return; @@ -431,10 +512,6 @@ function promotableLocals( } }; for (const s of fn.body) stmt(s, 1); - const promoted = new Set(); - for (const [id, cost] of candidates) - if (!blocked.has(id) && cost.saved > cost.added) promoted.add(id); - return promoted; } /** A statement-level sequence whose result nothing reads. */ @@ -449,6 +526,7 @@ function promoteFunction( results: ReadonlySet, params: ReadonlyMap, storesJs: (e: IrExpr) => boolean, + globals: ReadonlySet, ): boolean { const resultCall = (e: IrExpr): boolean => e.kind === "call" && results.has(e.callee); const operatorCall = (e: IrExpr): boolean => isProducerCall(e); @@ -460,6 +538,7 @@ function promoteFunction( new Map(), params, ); + for (const id of globals) promoted.add(id); const ownParams = params.get(fn.name); fn.params.forEach((p, i) => { if (ownParams?.[i] === true) promoted.add(p.localId); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json new file mode 100644 index 000000000..7cbf7bf9e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-returns-receiver.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-method-returns-receiver.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json new file mode 100644 index 000000000..fc0d4f6ef --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-imports/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/dynamic-module-let-imports/counter.mjs", + "/tests/corpus/dynamic-module-let-imports/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json new file mode 100644 index 000000000..107fa6e7a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-module-let-numbers.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-module-let-numbers.mjs" + ], + "diags": [] +} diff --git a/tests/corpus/dynamic-method-returns-receiver.mjs b/tests/corpus/dynamic-method-returns-receiver.mjs new file mode 100644 index 000000000..c83c2b45c --- /dev/null +++ b/tests/corpus/dynamic-method-returns-receiver.mjs @@ -0,0 +1,72 @@ +// Untyped methods that return their receiver, called through untyped +// values: chains, identity, subclasses, overrides and partial returns. +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + move(dx, dy) { + this.x += dx; + this.y += dy; + return this; + } + scale(k) { + if (k === 0) { + this.x = 0; + this.y = 0; + return this; + } + this.x *= k; + this.y *= k; + return this; + } + maybe(flag) { + if (flag) return this; + return { x: -1 }; + } + check(v) { + if (v < 0) throw new RangeError("negative " + v); + this.x += v; + return this; + } +} +class Tagged extends Point { + constructor(x, y, tag) { + super(x, y); + this.tag = tag; + } + move(dx, dy) { + super.move(dx, dy); + return this.tag; + } +} +class Plain extends Point {} +const points = [new Point(1, 2), new Tagged(3, 4, "t"), new Plain(5, 6), new Point(7, 8)]; +let sum = 0; +for (let r = 0; r < 200; r++) { + const p = points[r % points.length]; + const moved = p.move(1, -1); + if (typeof moved === "object") { + sum += moved.x + moved.y; + if (moved !== p) throw new Error("identity lost"); + } else sum += moved.length; + const scaled = p.scale(r % 3); + sum += scaled === p ? 1 : 0; + sum += p.maybe(r % 2 === 0).x; +} +console.log(sum); +const chain = points[0].move(1, 1).scale(2).move(-1, 0); +console.log(chain === points[0], chain.x, chain.y, chain instanceof Point); +const plain = points[2]; +const r1 = plain.move(2, 2); +r1.extra = "seen"; +console.log(plain.extra, r1 === plain, Object.keys(r1).join(","), r1.constructor.name); +try { + points[3].check(1).check(-2); +} catch (e) { + console.log(e instanceof RangeError, e.message, points[3].x); +} +const results = []; +for (const p of points) results.push(p.scale(1)); +console.log(results.map((p, i) => p === points[i]).join(",")); +console.log(JSON.stringify(points)); diff --git a/tests/corpus/dynamic-module-let-imports/counter.mjs b/tests/corpus/dynamic-module-let-imports/counter.mjs new file mode 100644 index 000000000..d67864f87 --- /dev/null +++ b/tests/corpus/dynamic-module-let-imports/counter.mjs @@ -0,0 +1,13 @@ +// Module-level bindings of an untyped module that a typed module imports. +export let total = 0; +let hidden = 0; +export function run(rounds) { + for (let i = 0; i < rounds; i++) { + total += i * 3; + hidden += i; + } + return hidden; +} +export function reset(value) { + total = value; +} diff --git a/tests/corpus/dynamic-module-let-imports/main.ts b/tests/corpus/dynamic-module-let-imports/main.ts new file mode 100644 index 000000000..b61a6c36b --- /dev/null +++ b/tests/corpus/dynamic-module-let-imports/main.ts @@ -0,0 +1,6 @@ +// A typed module reads a live binding that untyped code updates in a loop. +import { reset, run, total } from "./counter.mjs"; + +console.log(run(100), total); +reset(2.5); +console.log(run(10), total); diff --git a/tests/corpus/dynamic-module-let-numbers.mjs b/tests/corpus/dynamic-module-let-numbers.mjs new file mode 100644 index 000000000..1f08260b7 --- /dev/null +++ b/tests/corpus/dynamic-module-let-numbers.mjs @@ -0,0 +1,53 @@ +// Module-level `let` bindings that untyped code updates in loops: numbers, +// then values of other kinds, read by functions declared before and after. +function readTotal() { + return total; +} +let total = 0; +let label = "n"; +let mixed = 1; +let big = 10n; +let counter = 0; +var hoisted; +function bump(n) { + return n * 2 + 1; +} +for (let i = 0; i < 1000; i++) { + total += bump(i); + label += i % 10 === 0 ? i : ""; + mixed = mixed * 1.5 + i; + big += BigInt(i); + counter++; +} +console.log(total, readTotal(), label.length, mixed, big, counter); +total = total / 0; +console.log(total, readTotal()); +total = -0; +console.log(Object.is(total, -0), 1 / total); +total = NaN; +console.log(total === total, String(total)); +mixed = "text"; +mixed += 5; +console.log(mixed, typeof mixed); +mixed = { value: 3 }; +mixed.value += 4; +console.log(mixed.value, JSON.stringify(mixed)); +mixed = undefined; +console.log(mixed, typeof mixed, mixed ?? "fallback"); +mixed = null; +console.log(mixed === null, `${mixed}`); +hoisted = 0; +for (let i = 0; i < 10; i++) hoisted += i * 0.1; +console.log(hoisted); +const add = (n) => { + total = (Number.isNaN(total) ? 0 : total) + n; + return total; +}; +for (let i = 0; i < 5; i++) add(i); +console.log(total, readTotal()); +let product = 1; +for (let i = 1; i <= 25; i++) product *= i; +console.log(product, product > Number.MAX_SAFE_INTEGER); +let text = 0; +for (let i = 0; i < 3; i++) text = text + "" + i; +console.log(text, typeof text); From f041da4b10654dae5492d19bdd15e7123d02ecbf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 08:56:20 -0500 Subject: [PATCH 15/15] Drop the computed Array.isArray refusal from the stdlib fence snapshot `Array.isArray([1])` folds after evaluating its argument, so the stdlib fence battery no longer reports SC1090 for it. The snapshot drops that diagnostic and the statement notes that it lowers. --- tests/diagnostics/stdlib-fence.ts | 2 +- tests/harness/__snapshots__/stdlib-fence.ts.txt | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/diagnostics/stdlib-fence.ts b/tests/diagnostics/stdlib-fence.ts index 0952436e1..b3eff41f5 100644 --- a/tests/diagnostics/stdlib-fence.ts +++ b/tests/diagnostics/stdlib-fence.ts @@ -12,7 +12,7 @@ const g = globalThis; const nan = NaN; // lowers now — a non-finite numeric literal like Infinity const s5 = String(5); const n5 = Number.NaN; // lowers now, with the other Number constants -const isArr = Array.isArray([1]); +const isArr = Array.isArray([1]); // lowers now: a computed argument folds after it is evaluated const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal) const aliasedAssign = Object.assign(merged, { b: 2 }); // aliased targets stay fenced (real mutation of a live object) const refl = Reflect.has({ a: 1 }, "a"); diff --git a/tests/harness/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 313a9391b..6ba862c53 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -1,10 +1,3 @@ -stdlib-fence.ts:15:15 - error SC1090: statically-decided Array.isArray on computed arguments (bind the value to a variable first) are not supported yet - - 14 | const n5 = Number.NaN; // lowers now, with the other Number constants - 15 | const isArr = Array.isArray([1]); - | ^~~~~~~~~~~~~~~~~~ - 16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal) - stdlib-fence.ts:17:23 - error SC2020: 'Object.assign' is part of the standard library types but has no scriptc lowering yet 16 | const merged = Object.assign({}, { a: 1 }); // the empty-target literal-source shape lowers now (it IS the source literal)