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/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/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/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 34587b078..5f1eba44a 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4892,9 +4892,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, @@ -4964,14 +4965,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/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/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/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/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/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/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/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) 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 } 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 } 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 }