From 0bd90765cf36094f20b12098c1c85ad865258b47 Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Tue, 4 Aug 2026 07:02:37 -0400 Subject: [PATCH 1/7] feat(compiler): accept truthy (non-bool) filter predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.filter(fn)` required the callback to return exactly bool. JS applies ToBoolean to whatever the predicate answers, so `xs.filter((s) => s)` — the idiomatic drop-the-falsy — was refused for no semantic reason. The filter loop now wraps the call in the same toBool an `if` statement would apply. A bool answer is unchanged and emits identical IR. A union answer routes through its interned per-arm truthy helper, requested at the call site where a real node exists for the diagnostic. A unit-only answer is constantly falsy, and the call still runs for its effects. The kinds with no native ToBoolean keep the fence: void has no value at all, and dyn/jsval/caught truthiness needs the embedded engine. Verified against Node: filtering strings, numbers, and a `(string | undefined)[]` all print identically in both. Diagnostics 100, filter corpus 9 across both backends. --- .../src/frontend/lowering/lower-containers.ts | 74 ++++++++++++++++++- .../test/ts7/baselines/order-parity.json | 20 ++++- tests/corpus/2683-filter-truthy-predicate.ts | 31 ++++++++ tests/diagnostics/filter-void-predicate.ts | 8 ++ .../filter-void-predicate.ts.txt | 8 ++ 5 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 tests/corpus/2683-filter-truthy-predicate.ts create mode 100644 tests/diagnostics/filter-void-predicate.ts create mode 100644 tests/harness/__snapshots__/filter-void-predicate.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 6fd860a94..70aed1bd5 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -155,7 +155,15 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr { const receiver = L.lowerExpr(access.expression); if (receiver.type.kind === "jsval") { - const args = call.arguments.map((a) => L.jsvalIn(L.lowerExpr(a), a)); + const loweredArgs = call.arguments.map((a) => L.lowerExpr(a)); + if (name === "filter" && loweredArgs[0]?.type.kind === "func" && loweredArgs[0].type.ret.kind === "void") { + L.unsupported( + "SC1090", + call.arguments[0]!, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + const args = loweredArgs.map((arg, i) => L.jsvalIn(arg, call.arguments[i]!)); const result: IrExpr = { kind: "jsOp", op: "callMethod", name, args: [receiver, ...args], type: JSVAL, loc }; return islandPrimitiveExit(L, call, result); } @@ -172,7 +180,15 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr if (call.arguments.some((a) => ts.isSpreadElement(a))) { L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); } - const args = call.arguments.map((a) => L.lowerExprExpecting(a, DYN)); + const loweredArgs = call.arguments.map((a) => L.lowerExpr(a)); + if (name === "filter" && loweredArgs[0]?.type.kind === "func" && loweredArgs[0].type.ret.kind === "void") { + L.unsupported( + "SC1090", + call.arguments[0]!, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + const args = loweredArgs.map((arg, i) => L.coerceInto(call.arguments[i]!, arg, DYN)); return { kind: "dynInvoke", recv: receiver, @@ -668,13 +684,61 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr "'.map()' with a callback returning 'unknown'-typed values (the result array has no static element type — annotate the callback's return)", ); } - if (method === "filter" && fnRet.kind !== "bool") L.badType(argNode, L.typeOf(argNode)); + // JS applies ToBoolean to whatever the predicate answers, so a non-bool + // result is not an error — the filter loop wraps the call in the same + // toBool an `if` statement would apply. The island/dyn shapes, whose + // truthiness needs the engine, and void, whose real answer the ABI has + // already discarded, keep the fence. No separate + // requireTruthyUnion call belongs here: its check (no dyn/caught arm) + // IS filterPredicateOk's union branch, so it could never speak. + if (method === "filter" && !filterPredicateOk(L, fnRet)) { + L.badType(argNode, L.typeOf(argNode)); + } const helper = arrayHofHelper(L, method, elem, fnRet, arity, loc); const resultType: IrType = method === "map" ? arrayOf(fnRet) : method === "filter" ? arrayOf(elem) : VOID; return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultType, loc }; } +/** The predicate result kinds `.filter()` accepts. JS applies ToBoolean to + * whatever the callback answers, so a bool is not required: the scalars and + * the reference kinds have constant or by-value answers, and a union is fine + * when every arm does. void/dyn/jsval/caught stay out — see below. */ +function filterPredicateOk(L: Lowerer, ret: IrType): boolean { + if (ret.kind === "bool") return true; + // VOID is a TYPE erasure, not a runtime value: TS lets a value-returning + // function sit in a void-returning slot (`const p: (n: number) => void = + // (n) => n`), so the predicate's real answer can be truthy while the + // compiled ABI has already discarded it. Treating void as constantly + // falsy would silently answer [] where Node answers [1]. Fenced until + // the returned value can be preserved through the void ABI. + // dyn/jsval/caught stay out too: no native ToBoolean to compile against. + if (ret.kind === "void") return false; + if (ret.kind === "dyn" || ret.kind === "jsval" || ret.kind === "caught") return false; + if (ret.kind === "union") { + const def = L.unions.get(ret.unionId); + return def !== undefined && def.arms.every((a) => a.kind !== "dyn" && a.kind !== "caught"); + } + return true; +} + +/** The filter loop's condition: the predicate's result put through JS + * ToBoolean. A bool answer is already the condition; everything else takes + * the same `toBool` wrapper an `if` statement would apply (a union answer + * routes through its interned per-arm truthy helper). */ +function filterCond(call: IrExpr, fnRet: IrType, loc: SrcLoc): IrExpr { + if (fnRet.kind === "bool") return call; + // No constant-false arm here: void is fenced at the call site, and a + // bare undefinedT/nullT return cannot arise (mapType sends `undefined`/ + // `void` returns to void and a standalone `null` return to the unit-ONLY + // UNION, which routes through toBool below; ir/validate.ts rejects a + // bare unit return type outright). filterPredicateOk already rejected + // the arms with no native ToBoolean (dyn/caught) — which is exactly what + // requireTruthyUnion checks — and it did so at the call site, where a + // real node exists for the diagnostic. + return { kind: "toBool", operand: call, type: BOOL, loc }; +} + /** Interned synthetic loop function for one (method, elem, fnRet, arity) * combo. Named `%arr..` ('%' keeps it out of the user * namespace); rides `liftedFns` into the module like a lifted lambda (it @@ -1006,7 +1070,9 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr { kind: "varDecl", localId: "v.0", init: getElem, loc }, { kind: "if", - cond: callF(ref("v.0", elem)), + // ToBoolean over the predicate's answer — inert when it already + // returned bool, the per-union helper when it returned a union. + cond: filterCond(callF(ref("v.0", elem)), fnRet, loc), then: [push(arrT, ref("v.0", elem))], else_: null, loc, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 2fab13504..2d8744edc 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5569,8 +5569,14 @@ "diags": [] }, "/tests/corpus/2682-fs-rename.ts": { + "order": [ + "/tests/corpus/2682-fs-rename.ts" + ], + "diags": [] + }, + "/tests/corpus/2683-filter-truthy-predicate.ts": { "order": [ - "/tests/corpus/2682-fs-rename.ts" + "/tests/corpus/2683-filter-truthy-predicate.ts" ], "diags": [] }, @@ -5635,9 +5641,9 @@ "diags": [] }, "/tests/corpus/2700-wasi-core.ts": { - "order": [ - "/tests/corpus/2700-wasi-core.ts" - ], + "order": [ + "/tests/corpus/2700-wasi-core.ts" + ], "diags": [] }, "/tests/corpus/300-if-else.ts": { @@ -6977,6 +6983,12 @@ ], "diags": [] }, + "/tests/diagnostics/filter-void-predicate.ts": { + "order": [ + "/tests/diagnostics/filter-void-predicate.ts" + ], + "diags": [] + }, "/tests/diagnostics/function-forms.ts": { "order": [ "/tests/diagnostics/function-forms.ts" diff --git a/tests/corpus/2683-filter-truthy-predicate.ts b/tests/corpus/2683-filter-truthy-predicate.ts new file mode 100644 index 000000000..71f61d96a --- /dev/null +++ b/tests/corpus/2683-filter-truthy-predicate.ts @@ -0,0 +1,31 @@ +// Array.prototype.filter applies ToBoolean to predicate results; predicates +// need not return boolean. + +const words = ["", "a", "bb", "ccc"]; +console.log(words.filter((s) => s).join(",")); +console.log(words.filter((s) => s.length).join(",")); + +const nums = [-2, -1, 0, 1, 2]; +console.log(nums.filter((n) => n).join(",")); +console.log(nums.filter((n) => (n === 0 ? "" : "yes")).join(",")); + +// A `null` result is the unit-ONLY UNION, not a bare unit — it still rides +// the per-arm truthy helper, and every arm is falsy. (A `void`-returning +// predicate is NOT accepted: TS lets a value-returning function fill a +// void slot, so its real answer is unknowable once the ABI discards it.) +let unitCalls = 0; +console.log(nums.filter(() => { + unitCalls++; + return null; +}).length, unitCalls); + +// -0 and NaN are falsy; a non-empty string and a non-zero number truthy. +const edges = [-0, 0, NaN, 1]; +console.log(edges.filter((n) => n).length); +console.log(["", "0"].filter((s) => s).join("|")); + +// A mixed-arm union result routes through the interned per-arm helper. +function pick(n: number): string | number { + return n % 2 === 0 ? "" : n; +} +console.log(nums.filter((n) => pick(n)).join(",")); diff --git a/tests/diagnostics/filter-void-predicate.ts b/tests/diagnostics/filter-void-predicate.ts new file mode 100644 index 000000000..6afab45a8 --- /dev/null +++ b/tests/diagnostics/filter-void-predicate.ts @@ -0,0 +1,8 @@ +// `.filter()` takes truthy (non-boolean) predicate results, but NOT a +// `void`-returning one. void is a TYPE erasure, not a runtime value: TS +// lets a value-returning function sit in a void-returning slot, so the +// predicate's real answer can be truthy while the compiled ABI has already +// discarded it — under Node `[0, 1].filter(pred)` below is `[1]`. Fenced +// until the returned value can be preserved through the void ABI. +const pred: (n: number) => void = (n) => n; +console.log([0, 1].filter(pred).join(",")); diff --git a/tests/harness/__snapshots__/filter-void-predicate.ts.txt b/tests/harness/__snapshots__/filter-void-predicate.ts.txt new file mode 100644 index 000000000..8023c1605 --- /dev/null +++ b/tests/harness/__snapshots__/filter-void-predicate.ts.txt @@ -0,0 +1,8 @@ +filter-void-predicate.ts:8:27 - error SC2011: values of type '(n: number) => void' have no static representation but run in the embedded dynamic engine, which this build does not include + + 7 | const pred: (n: number) => void = (n) => n; + 8 | console.log([0, 1].filter(pred).join(",")); + | ^~~~ + 9 | + + hint: build with --dynamic to run these values in the embedded engine (adds ~620KB to the binary), or restate the type inside the static surface (https://scriptc.dev/limitations describes the boundary) \ No newline at end of file From 4fe24e91d4c0903bdca9db5dbec924eb659c67cc Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Fri, 7 Aug 2026 16:07:18 -0400 Subject: [PATCH 2/7] fix(compiler): clarify void filter predicate fence --- .../compiler/src/frontend/lowering/lower-containers.ts | 7 +++++++ tests/diagnostics/filter-void-predicate-dynamic.ts | 5 +++++ tests/diagnostics/filter-void-predicate.ts | 1 + .../__snapshots__/filter-void-predicate-dynamic.ts.txt | 6 ++++++ tests/harness/__snapshots__/filter-void-predicate.ts.txt | 6 ++---- 5 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 tests/diagnostics/filter-void-predicate-dynamic.ts create mode 100644 tests/harness/__snapshots__/filter-void-predicate-dynamic.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 70aed1bd5..3966b61c3 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -692,6 +692,13 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr // requireTruthyUnion call belongs here: its check (no dyn/caught arm) // IS filterPredicateOk's union branch, so it could never speak. if (method === "filter" && !filterPredicateOk(L, fnRet)) { + if (fnRet.kind === "void") { + L.unsupported( + "SC1090", + argNode, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } L.badType(argNode, L.typeOf(argNode)); } const helper = arrayHofHelper(L, method, elem, fnRet, arity, loc); diff --git a/tests/diagnostics/filter-void-predicate-dynamic.ts b/tests/diagnostics/filter-void-predicate-dynamic.ts new file mode 100644 index 000000000..03c5f4eb3 --- /dev/null +++ b/tests/diagnostics/filter-void-predicate-dynamic.ts @@ -0,0 +1,5 @@ +// @dynamic +// The void ABI remains a static fence even with the dynamic engine enabled. +const pred: (n: number) => void = (n) => n; +console.log([0, 1].filter(pred).join(",")); +// The callback's erased return makes this unsupported. diff --git a/tests/diagnostics/filter-void-predicate.ts b/tests/diagnostics/filter-void-predicate.ts index 6afab45a8..24f5e55dc 100644 --- a/tests/diagnostics/filter-void-predicate.ts +++ b/tests/diagnostics/filter-void-predicate.ts @@ -6,3 +6,4 @@ // until the returned value can be preserved through the void ABI. const pred: (n: number) => void = (n) => n; console.log([0, 1].filter(pred).join(",")); +// The callback's erased return makes this unsupported. diff --git a/tests/harness/__snapshots__/filter-void-predicate-dynamic.ts.txt b/tests/harness/__snapshots__/filter-void-predicate-dynamic.ts.txt new file mode 100644 index 000000000..dcba56469 --- /dev/null +++ b/tests/harness/__snapshots__/filter-void-predicate-dynamic.ts.txt @@ -0,0 +1,6 @@ +filter-void-predicate-dynamic.ts:4:27 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet + + 3 | const pred: (n: number) => void = (n) => n; + 4 | console.log([0, 1].filter(pred).join(",")); + | ^~~~ + 5 | // The callback's erased return makes this unsupported. \ No newline at end of file diff --git a/tests/harness/__snapshots__/filter-void-predicate.ts.txt b/tests/harness/__snapshots__/filter-void-predicate.ts.txt index 8023c1605..e928a41e0 100644 --- a/tests/harness/__snapshots__/filter-void-predicate.ts.txt +++ b/tests/harness/__snapshots__/filter-void-predicate.ts.txt @@ -1,8 +1,6 @@ -filter-void-predicate.ts:8:27 - error SC2011: values of type '(n: number) => void' have no static representation but run in the embedded dynamic engine, which this build does not include +filter-void-predicate.ts:8:27 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet 7 | const pred: (n: number) => void = (n) => n; 8 | console.log([0, 1].filter(pred).join(",")); | ^~~~ - 9 | - - hint: build with --dynamic to run these values in the embedded engine (adds ~620KB to the binary), or restate the type inside the static surface (https://scriptc.dev/limitations describes the boundary) \ No newline at end of file + 9 | // The callback's erased return makes this unsupported. \ No newline at end of file From f1a577fd0a6e8e39075ad7bd26a482ed4c2ca1b5 Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Mon, 10 Aug 2026 10:16:24 -0400 Subject: [PATCH 3/7] test(compiler): record dynamic filter diagnostic baseline --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 2d8744edc..3a38f883f 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6983,6 +6983,12 @@ ], "diags": [] }, + "/tests/diagnostics/filter-void-predicate-dynamic.ts": { + "order": [ + "/tests/diagnostics/filter-void-predicate-dynamic.ts" + ], + "diags": [] + }, "/tests/diagnostics/filter-void-predicate.ts": { "order": [ "/tests/diagnostics/filter-void-predicate.ts" From 6f4592295fe7ca3cc38bdd7336a106753fb74767 Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Fri, 14 Aug 2026 10:14:28 -0400 Subject: [PATCH 4/7] test(compiler): cover island filter void predicate --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ tests/diagnostics/filter-void-predicate-island.ts | 10 ++++++++++ .../__snapshots__/filter-void-predicate-island.ts.txt | 6 ++++++ 3 files changed, 22 insertions(+) create mode 100644 tests/diagnostics/filter-void-predicate-island.ts create mode 100644 tests/harness/__snapshots__/filter-void-predicate-island.ts.txt diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 3a38f883f..4ca43b76c 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6989,6 +6989,12 @@ ], "diags": [] }, + "/tests/diagnostics/filter-void-predicate-island.ts": { + "order": [ + "/tests/diagnostics/filter-void-predicate-island.ts" + ], + "diags": [] + }, "/tests/diagnostics/filter-void-predicate.ts": { "order": [ "/tests/diagnostics/filter-void-predicate.ts" diff --git a/tests/diagnostics/filter-void-predicate-island.ts b/tests/diagnostics/filter-void-predicate-island.ts new file mode 100644 index 000000000..cb869acb2 --- /dev/null +++ b/tests/diagnostics/filter-void-predicate-island.ts @@ -0,0 +1,10 @@ +// @dynamic +// An overload can present an island array as static. The callback still uses +// the void ABI, so the engine must not silently receive an erased return. +function values(): number[]; +function values(): any { + return [0, 1]; +} + +const pred: (n: number) => void = (n) => n; +console.log(values().filter(pred).join(",")); diff --git a/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt b/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt new file mode 100644 index 000000000..ce74df914 --- /dev/null +++ b/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt @@ -0,0 +1,6 @@ +filter-void-predicate-island.ts:10:29 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet + + 9 | const pred: (n: number) => void = (n) => n; + 10 | console.log(values().filter(pred).join(",")); + | ^~~~ + 11 | \ No newline at end of file From 76dde4bf87a326d8251bde5f65fa3c96118cc0cd Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Fri, 14 Aug 2026 10:14:44 -0400 Subject: [PATCH 5/7] test(compiler): refresh filter void snapshot --- tests/diagnostics/filter-void-predicate-island.ts | 1 + tests/harness/__snapshots__/filter-void-predicate-island.ts.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/diagnostics/filter-void-predicate-island.ts b/tests/diagnostics/filter-void-predicate-island.ts index cb869acb2..68a10455e 100644 --- a/tests/diagnostics/filter-void-predicate-island.ts +++ b/tests/diagnostics/filter-void-predicate-island.ts @@ -8,3 +8,4 @@ function values(): any { const pred: (n: number) => void = (n) => n; console.log(values().filter(pred).join(",")); +// The callback's erased return makes this unsupported. diff --git a/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt b/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt index ce74df914..e6c78021a 100644 --- a/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt +++ b/tests/harness/__snapshots__/filter-void-predicate-island.ts.txt @@ -3,4 +3,4 @@ filter-void-predicate-island.ts:10:29 - error SC1090: '.filter()' with a void-re 9 | const pred: (n: number) => void = (n) => n; 10 | console.log(values().filter(pred).join(",")); | ^~~~ - 11 | \ No newline at end of file + 11 | // The callback's erased return makes this unsupported. \ No newline at end of file From 625a8ee6c056a09addc26a4942daa51a478d19cb Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Fri, 14 Aug 2026 10:20:51 -0400 Subject: [PATCH 6/7] fix(compiler): fence dynamic filter void predicates --- .../compiler/src/frontend/lowering/lower-containers.ts | 8 +++++--- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ tests/diagnostics/filter-void-predicate-dyn.ts | 8 ++++++++ .../__snapshots__/filter-void-predicate-dyn.ts.txt | 6 ++++++ 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/diagnostics/filter-void-predicate-dyn.ts create mode 100644 tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 3966b61c3..4bedc0943 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -180,15 +180,17 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr if (call.arguments.some((a) => ts.isSpreadElement(a))) { L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); } - const loweredArgs = call.arguments.map((a) => L.lowerExpr(a)); - if (name === "filter" && loweredArgs[0]?.type.kind === "func" && loweredArgs[0].type.ret.kind === "void") { + const predicate = name === "filter" ? L.lowerExpr(call.arguments[0]!) : null; + if (predicate?.type.kind === "func" && predicate.type.ret.kind === "void") { L.unsupported( "SC1090", call.arguments[0]!, "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", ); } - const args = loweredArgs.map((arg, i) => L.coerceInto(call.arguments[i]!, arg, DYN)); + const args = call.arguments.map((arg, i) => + i === 0 && predicate ? L.coerceInto(arg, predicate, DYN) : L.lowerExprExpecting(arg, DYN), + ); return { kind: "dynInvoke", recv: receiver, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 4ca43b76c..ef9340172 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6983,6 +6983,12 @@ ], "diags": [] }, + "/tests/diagnostics/filter-void-predicate-dyn.ts": { + "order": [ + "/tests/diagnostics/filter-void-predicate-dyn.ts" + ], + "diags": [] + }, "/tests/diagnostics/filter-void-predicate-dynamic.ts": { "order": [ "/tests/diagnostics/filter-void-predicate-dynamic.ts" diff --git a/tests/diagnostics/filter-void-predicate-dyn.ts b/tests/diagnostics/filter-void-predicate-dyn.ts new file mode 100644 index 000000000..8e2abc176 --- /dev/null +++ b/tests/diagnostics/filter-void-predicate-dyn.ts @@ -0,0 +1,8 @@ +// @dynamic +// Object.keys over an unknown object returns the checked-dynamic array +// representation, not a static array. Its predicate still cannot use void. +const source: unknown = { zero: 0, one: 1 }; +const values = Object.keys(source as object); +const pred: (n: string) => void = (n) => n; +console.log(values.filter(pred).join(",")); +// The callback's erased return makes this unsupported. diff --git a/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt b/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt new file mode 100644 index 000000000..626a8abd7 --- /dev/null +++ b/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt @@ -0,0 +1,6 @@ +filter-void-predicate-dyn.ts:7:27 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet + + 6 | const pred: (n: string) => void = (n) => n; + 7 | console.log(values.filter(pred).join(",")); + | ^~~~ + 8 | // The callback's erased return makes this unsupported. \ No newline at end of file From b98b7c4a49262b668620f3ce24489e3929356953 Mon Sep 17 00:00:00 2001 From: Jimmy Miller Date: Mon, 17 Aug 2026 10:33:31 -0400 Subject: [PATCH 7/7] fix(compiler): guard zero-arg dynamic filter calls --- .../src/frontend/lowering/lower-calls.ts | 56 +++++++++++++------ .../src/frontend/lowering/lower-containers.ts | 29 +--------- .../test/ts7/baselines/order-parity.json | 6 ++ tests/corpus/2684-filter-no-predicate-dyn.cjs | 14 +++++ .../diagnostics/filter-void-predicate-dyn.ts | 8 +-- .../filter-void-predicate-dyn.ts.txt | 10 ++-- 6 files changed, 71 insertions(+), 52 deletions(-) create mode 100644 tests/corpus/2684-filter-no-predicate-dyn.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 8667621c8..bfb2ea656 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4573,8 +4573,9 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { // take the ordinary typed paths, but there is no static home for an // any-elemented array). Typed receivers keep their own lowerings. const recvTs = L.typeOf(access.expression); + const arrayReceiver = L.checker.isArrayType(recvTs); const anyArray = - L.checker.isArrayType(recvTs) && + arrayReceiver && ((L.checker.getTypeArguments(recvTs as ts.TypeReference)[0]?.flags ?? 0) & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0; let recv: IrExpr; @@ -4648,21 +4649,8 @@ export function lowerCall(L: Lowerer, expr: ts.CallExpression): IrExpr { // unimplemented methods throw a LOUD not-supported Error; names the // kind's prototype lacks throw Node's "x.y is not a function"; OBJ // receivers call the own member. - if (DYN_DISPATCH_METHODS.has(access.name.text) && !call.questionDotToken && !access.questionDotToken) { - if (call.arguments.some((a) => ts.isSpreadElement(a))) { - L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); - } - const args = call.arguments.map((a) => L.lowerExprExpecting(a, DYN)); - return { - kind: "dynInvoke", - recv, - method: access.name.text, - calleeName: access.getText(), - args, - type: DYN, - loc: locOf(call), - }; - } + const dispatched = lowerDynDispatchMethodCall(L, call, access, recv, arrayReceiver); + if (dispatched) return dispatched; // Names NO dyn-representable prototype declares: the member can only // be an OWN property, so "read the member, call it" IS Node's // semantics for every possible dyn value — `handlers.onDone(x)` on a @@ -4774,6 +4762,42 @@ export const DYN_DISPATCH_METHODS = new Set([ "additionalHeaders", "altsvc", "origin", ]); +export function lowerDynDispatchMethodCall( + L: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + recv: IrExpr, + arrayReceiver: boolean, +): IrExpr | null { + const method = access.name.text; + if (!DYN_DISPATCH_METHODS.has(method) || call.questionDotToken || access.questionDotToken) return null; + if (call.arguments.some((arg) => ts.isSpreadElement(arg))) { + L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); + } + const predicate = method === "filter" && call.arguments[0] + ? L.lowerExpr(call.arguments[0]) + : null; + if (arrayReceiver && predicate?.type.kind === "func" && predicate.type.ret.kind === "void") { + L.unsupported( + "SC1090", + call.arguments[0]!, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + const args = call.arguments.map((arg, i) => + i === 0 && predicate ? L.coerceInto(arg, predicate, DYN) : L.lowerExprExpecting(arg, DYN), + ); + return { + kind: "dynInvoke", + recv, + method, + calleeName: access.getText(), + args, + type: DYN, + loc: locOf(call), + }; +} + /** STR_METHODS ∪ the regex-form names, MINUS everything Array (or any * other dyn kind's prototype) also declares. */ const DYN_STRING_ONLY_METHODS = new Set([ diff --git a/packages/compiler/src/frontend/lowering/lower-containers.ts b/packages/compiler/src/frontend/lowering/lower-containers.ts index 4bedc0943..70855f1a6 100644 --- a/packages/compiler/src/frontend/lowering/lower-containers.ts +++ b/packages/compiler/src/frontend/lowering/lower-containers.ts @@ -9,7 +9,7 @@ import { ARRAY_METHODS, MAP_METHODS, SET_COMBINE_METHODS, SET_METHODS, STR_METHO import { droppableStatic, isRequireMainFilename, lowerDynObjectLiteral, probeLower, pureReemittable } from "./lower-exprs.js"; import { forOfVarTarget, lowerDestructuringAssign } from "./lower-stmts.js"; import { isJsSourceFile, locOf } from "../program.js"; -import { DYN_DISPATCH_METHODS, islandPrimitiveExit } from "./lower-calls.js"; +import { islandPrimitiveExit, lowerDynDispatchMethodCall } from "./lower-calls.js"; import { typeKey } from "../types.js"; import { dynUndefinedExpr, own, WidthLift } from "./lowerer.js"; @@ -176,31 +176,8 @@ function lowerSplitLimitArg(L: Lowerer, node: ts.Expression | undefined, loc: Sr // ICE). Consumers validate the dyn result where a static type is // required (dynCheck — the member-read discipline). if (receiver.type.kind === "dyn") { - if (DYN_DISPATCH_METHODS.has(name) && !call.questionDotToken && !access.questionDotToken) { - if (call.arguments.some((a) => ts.isSpreadElement(a))) { - L.unsupported("SC1090", call, "spread arguments in calls through 'unknown' values"); - } - const predicate = name === "filter" ? L.lowerExpr(call.arguments[0]!) : null; - if (predicate?.type.kind === "func" && predicate.type.ret.kind === "void") { - L.unsupported( - "SC1090", - call.arguments[0]!, - "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", - ); - } - const args = call.arguments.map((arg, i) => - i === 0 && predicate ? L.coerceInto(arg, predicate, DYN) : L.lowerExprExpecting(arg, DYN), - ); - return { - kind: "dynInvoke", - recv: receiver, - method: name, - calleeName: access.getText(), - args, - type: DYN, - loc, - }; - } + const dispatched = lowerDynDispatchMethodCall(L, call, access, receiver, true); + if (dispatched) return dispatched; L.noLowering( `.${name} on an array value held in a checked-dynamic binding`, call, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index ef9340172..d95e96e39 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -5586,6 +5586,12 @@ ], "diags": [] }, + "/tests/corpus/2684-filter-no-predicate-dyn.cjs": { + "order": [ + "/tests/corpus/2684-filter-no-predicate-dyn.cjs" + ], + "diags": [] + }, "/tests/corpus/2684-fs-rename-abort.ts": { "order": [ "/tests/corpus/2684-fs-rename-abort.ts" diff --git a/tests/corpus/2684-filter-no-predicate-dyn.cjs b/tests/corpus/2684-filter-no-predicate-dyn.cjs new file mode 100644 index 000000000..2a91d3dc4 --- /dev/null +++ b/tests/corpus/2684-filter-no-predicate-dyn.cjs @@ -0,0 +1,14 @@ +// A checked-dynamic filter call with no predicate reaches the runtime method, +// which throws the same callback TypeError as Node instead of crashing scriptc. +const source = JSON.parse('{"zero":0,"one":1}'); +try { + Object.keys(source).filter(); +} catch (error) { + console.log(error.name, error.message); +} + +const custom = JSON.parse('{}'); +custom.filter = (callback) => callback(); +custom.filter(() => { + console.log('custom filter'); +}); diff --git a/tests/diagnostics/filter-void-predicate-dyn.ts b/tests/diagnostics/filter-void-predicate-dyn.ts index 8e2abc176..fc2f0f34f 100644 --- a/tests/diagnostics/filter-void-predicate-dyn.ts +++ b/tests/diagnostics/filter-void-predicate-dyn.ts @@ -1,8 +1,6 @@ // @dynamic -// Object.keys over an unknown object returns the checked-dynamic array -// representation, not a static array. Its predicate still cannot use void. -const source: unknown = { zero: 0, one: 1 }; -const values = Object.keys(source as object); +// A direct Object.keys call over a dynamic object has a checker-array type +// but a checked-dynamic value. Its predicate still cannot use void. const pred: (n: string) => void = (n) => n; -console.log(values.filter(pred).join(",")); +console.log(Object.keys(JSON.parse('{"zero":0,"one":1}')).filter(pred).join(",")); // The callback's erased return makes this unsupported. diff --git a/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt b/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt index 626a8abd7..a0c8d5699 100644 --- a/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt +++ b/tests/harness/__snapshots__/filter-void-predicate-dyn.ts.txt @@ -1,6 +1,6 @@ -filter-void-predicate-dyn.ts:7:27 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet +filter-void-predicate-dyn.ts:5:66 - error SC1090: '.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested) is not supported yet - 6 | const pred: (n: string) => void = (n) => n; - 7 | console.log(values.filter(pred).join(",")); - | ^~~~ - 8 | // The callback's erased return makes this unsupported. \ No newline at end of file + 4 | const pred: (n: string) => void = (n) => n; + 5 | console.log(Object.keys(JSON.parse('{"zero":0,"one":1}')).filter(pred).join(",")); + | ^~~~ + 6 | // The callback's erased return makes this unsupported. \ No newline at end of file