From 1a3caa88eb781d64a163d012712dfb16e5c35c7b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 14:20:36 -0700 Subject: [PATCH] web/text singletons: URLSearchParams ToString, match reads, Array.from URLSearchParams crossing an implicit ToString (`${sp}`, "" + sp, String(sp)) had no lowering: ensureString now answers sp.toString, the serialized pair list. Record inits also accept shorthand members (`new URLSearchParams({ model })`) like the explicit `{ model: model }` spelling. `re.exec(s)?.index` keeps the optional chain: the read is the match row's index/input member (the chain lowering binds the guarded receiver first), so the questionDotToken guard no longer diverts it. Array.from(s) over a string whose binding the checker narrowed through a guard read as the narrowed union and fenced; the site type now decides, and the read is coerced to that string (code points, fresh array). Corpus 8423-8425 pin all three against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/builtins/url.ts | 21 ++++++++++++++++++- .../lowering/containers/array-construction.ts | 13 ++++++++++++ .../frontend/lowering/expressions/binary.ts | 4 ++++ .../lowering/expressions/property-access.ts | 4 +++- ...23-urlsearchparams-implicit-string.ts.json | 6 ++++++ .../8424-regex-match-index-chain.ts.json | 6 ++++++ .../8425-array-from-string-nullable.ts.json | 6 ++++++ .../8423-urlsearchparams-implicit-string.ts | 15 +++++++++++++ tests/corpus/8424-regex-match-index-chain.ts | 12 +++++++++++ .../corpus/8425-array-from-string-nullable.ts | 19 +++++++++++++++++ ...23-urlsearchparams-implicit-string.ts.json | 1 + .../8424-regex-match-index-chain.ts.json | 1 + .../8425-array-from-string-nullable.ts.json | 1 + 13 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json create mode 100644 tests/corpus/8423-urlsearchparams-implicit-string.ts create mode 100644 tests/corpus/8424-regex-match-index-chain.ts create mode 100644 tests/corpus/8425-array-from-string-nullable.ts create mode 100644 tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json create mode 100644 tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json create mode 100644 tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json diff --git a/packages/compiler/src/frontend/lowering/builtins/url.ts b/packages/compiler/src/frontend/lowering/builtins/url.ts index 8619b4997..1c4736cd7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/url.ts +++ b/packages/compiler/src/frontend/lowering/builtins/url.ts @@ -228,11 +228,30 @@ function lowerSearchParamsInit(lowerer: Lowerer, arg: ts.Expression, loc: SrcLoc if (ts.isObjectLiteralExpression(arg)) { let acc: IrExpr = { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc }; for (const prop of arg.properties.nodes) { + // `{ model }` appends the binding's value under its own name, like + // the `{ model: model }` spelling. + if (ts.isShorthandPropertyAssignment(prop)) { + const keyExpr: IrExpr = { + kind: "strLit", + value: (prop.name as ts.Identifier | ts.StringLiteral).text, + type: STRING, + loc: locOf(prop.name), + }; + const value = lowerer.coerceInto(prop.name, lowerer.lowerShorthandValue(prop), STRING); + acc = { + kind: "libCall", + fn: "sp.with", + args: [acc, keyExpr, value], + type: SEARCH_PARAMS_T, + loc, + }; + continue; + } if (!ts.isPropertyAssignment(prop) || prop.name === undefined) { lowerer.unsupported( "SC1090", prop, - "URLSearchParams record inits with spreads, accessors, or shorthand entries", + "URLSearchParams record inits with spreads or accessors", ); } let key: string; diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 2a3ad64c0..4d507b3a6 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1085,6 +1085,19 @@ export function lowerArrayFromCall( // and materializes holes while keeping element reference identity. if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) { const source = lowerer.lowerExpr(args[0]!); + // Array.from(s): the string iterator's code points, into a fresh + // array (scr_str_code_points already builds one, like Array.from). + // The checker can narrow the argument to a string while the binding + // keeps its nullable storage (`value.includes(x) && Array.from(value)`), + // so the site type decides and the read is coerced to that string. + if ( + args.length === 1 && + (source.type.kind === "string" || + lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string") + ) { + const str = source.type.kind === "string" ? source : lowerer.coerceToExpected(source, STRING); + return strCharsCall(str, loc); + } let src = source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source; if (src.type.kind === "array") { diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 58ae3e7bd..17c88c3c0 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -648,6 +648,10 @@ export function ensureString(lowerer: Lowerer, e: IrExpr, node: ts.Node): IrExpr if (e.type.kind === "string") return e; if (e.type.kind === "url") return { kind: "libCall", fn: "url.href", args: [e], type: STRING, loc: e.loc }; + // String(sp) / `${new URLSearchParams(...)}`: URLSearchParams.toString, + // the serialized list (`?` stripped, pairs encoded). + if (e.type.kind === "searchParams") + return { kind: "libCall", fn: "sp.toString", args: [e], type: STRING, loc: e.loc }; // String(date) / `${date}` / "" + date: Date's ToPrimitive prefers // toString(). if (e.type.kind === "date") diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index e566ed292..c6fd215ea 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -698,7 +698,9 @@ export function lowerPropertyAccessExpr( }; } } - if ((expr.name.text === "index" || expr.name.text === "input") && !expr.questionDotToken) { + // `re.exec(s)?.index` takes the same read: the chain lowering binds the + // guarded receiver (non-nullish inside the guard) before re-dispatching. + if (expr.name.text === "index" || expr.name.text === "input") { const represented = tryLowerExpression(lowerer, expr.expression); if ( represented && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..511710dcf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8423-urlsearchparams-implicit-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..46f66548d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8424-regex-match-index-chain.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8424-regex-match-index-chain.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..24a32bab3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8425-array-from-string-nullable.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8425-array-from-string-nullable.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8423-urlsearchparams-implicit-string.ts b/tests/corpus/8423-urlsearchparams-implicit-string.ts new file mode 100644 index 000000000..da32d4906 --- /dev/null +++ b/tests/corpus/8423-urlsearchparams-implicit-string.ts @@ -0,0 +1,15 @@ +// URLSearchParams crossing an implicit ToString: a template span, string +// concatenation, String() and an explicit toString() all serialize the +// pair list in insertion order, percent-encoded exactly as Node does. The +// record init accepts shorthand members (`{ model }`) like `{ model: model }`. +const model = "m 1"; +const sp = new URLSearchParams({ model, autoload: "false", q: "a&b" }); +console.log(`${sp}`); +console.log("prefix:" + sp); +console.log(String(sp)); +console.log(sp.toString()); +console.log(sp.get("model"), sp.get("q"), sp.get("missing")); +const empty = new URLSearchParams({}); +console.log(`[${empty}]`, JSON.stringify(empty.toString())); +const pairs = [...sp]; +console.log(pairs.length, pairs[0]![0], pairs[0]![1]); diff --git a/tests/corpus/8424-regex-match-index-chain.ts b/tests/corpus/8424-regex-match-index-chain.ts new file mode 100644 index 000000000..4245b161d --- /dev/null +++ b/tests/corpus/8424-regex-match-index-chain.ts @@ -0,0 +1,12 @@ +// A match row read through an optional chain: `re.exec(s)?.index` and the +// `?.input` beside it take the same member reads as the guarded form. +const re = /b(c)/g; +const text = "abcz"; +const m = re.exec(text); +console.log(m !== null ? m.index : -1); +console.log(re.exec(text)?.index); +console.log(re.exec(text)?.input); +console.log(/(z)/.exec(text)?.index); +console.log(/q/.exec(text)?.index); +const groups = /(?b)/.exec(text)?.groups; +console.log(groups?.lead); diff --git a/tests/corpus/8425-array-from-string-nullable.ts b/tests/corpus/8425-array-from-string-nullable.ts new file mode 100644 index 000000000..210eb1b2a --- /dev/null +++ b/tests/corpus/8425-array-from-string-nullable.ts @@ -0,0 +1,19 @@ +// Array.from over a string the checker narrowed through a guard, and over +// code points (surrogate pairs stay whole), each a fresh array. +const values: (string | undefined)[] = ["abc", undefined, "a\u{1F600}b"]; +for (const value of values) { + if (value === undefined) { + console.log("skip"); + continue; + } + const chars = Array.from(value); + console.log(chars.length, chars.join("|")); +} +const maybe: string | undefined = "xy"; +if (maybe !== undefined) console.log(Array.from(maybe).length); +const plain: string = "q"; +console.log(Array.from(plain), Array.from(plain).length); +function guarded(v: string | undefined): boolean { + return v !== undefined && Array.from(v).length > 1; +} +console.log(guarded("ab"), guarded("a"), guarded(undefined)); diff --git a/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json new file mode 100644 index 000000000..846fa300d --- /dev/null +++ b/tests/harness/corpus-costs/8423-urlsearchparams-implicit-string.ts.json @@ -0,0 +1 @@ +{ "plain": 4163, "san": 7431 } diff --git a/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json new file mode 100644 index 000000000..2520fba26 --- /dev/null +++ b/tests/harness/corpus-costs/8424-regex-match-index-chain.ts.json @@ -0,0 +1 @@ +{ "plain": 2579, "san": 9345 } diff --git a/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json new file mode 100644 index 000000000..b7845d83e --- /dev/null +++ b/tests/harness/corpus-costs/8425-array-from-string-nullable.ts.json @@ -0,0 +1 @@ +{ "plain": 2724, "san": 5297 }