From c7c214532eca878b151f8010280472a600fefa8b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:01:49 -0700 Subject: [PATCH] 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 }