From e75174aeea3d01e31ce4d8f2b3f2f2adeba679bd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 15:13:03 -0500 Subject: [PATCH] Compile string-key destructuring of any values and cast-guarded Buffer aliases Co-authored-by: patlux <4481570+patlux@users.noreply.github.com> Co-authored-by: jlucaso1 <55464917+jlucaso1@users.noreply.github.com> --- .../lowering/expressions/identifiers.ts | 11 ++++ .../frontend/lowering/expressions/typeof.ts | 6 ++ .../frontend/lowering/expressions/updates.ts | 4 ++ .../src/frontend/lowering/lower-exprs.ts | 4 ++ .../src/frontend/lowering/lower-stmts.ts | 9 ++- .../compiler/src/frontend/lowering/lowerer.ts | 4 ++ .../src/frontend/lowering/surfaces.ts | 32 ++++++++++ .../7309-globalthis-buffer-aliases.ts.json | 6 ++ .../7310-any-destructure-string-keys.ts.json | 6 ++ .../any-destructure-unspelled-keys.ts.json | 6 ++ .../globalthis-buffer-alias-values.ts.json | 6 ++ .../corpus/7309-globalthis-buffer-aliases.ts | 31 ++++++++++ .../7310-any-destructure-string-keys.ts | 60 +++++++++++++++++++ .../any-destructure-unspelled-keys.ts | 12 ++++ .../globalthis-buffer-alias-values.ts | 12 ++++ .../any-destructure-unspelled-keys.ts.txt | 27 +++++++++ .../globalthis-buffer-alias-values.ts.txt | 17 ++++++ .../7309-globalthis-buffer-aliases.ts.json | 1 + .../7310-any-destructure-string-keys.ts.json | 1 + 19 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7309-globalthis-buffer-aliases.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7310-any-destructure-string-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/any-destructure-unspelled-keys.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/globalthis-buffer-alias-values.ts.json create mode 100644 tests/corpus/7309-globalthis-buffer-aliases.ts create mode 100644 tests/corpus/7310-any-destructure-string-keys.ts create mode 100644 tests/diagnostics/any-destructure-unspelled-keys.ts create mode 100644 tests/diagnostics/globalthis-buffer-alias-values.ts create mode 100644 tests/harness/__snapshots__/any-destructure-unspelled-keys.ts.txt create mode 100644 tests/harness/__snapshots__/globalthis-buffer-alias-values.ts.txt create mode 100644 tests/harness/corpus-costs/7309-globalthis-buffer-aliases.ts.json create mode 100644 tests/harness/corpus-costs/7310-any-destructure-string-keys.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index ef0d8a2d1..b1116647a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -744,6 +744,17 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: return { kind: "libCall", fn: "webcrypto.object", args: [], type: DYN, loc }; const nativeAlias = stdlibGlobalNameOf(lowerer, expr); if (nativeAlias === "Buffer" && expr.text !== "Buffer") { + // An alias through a cast of globalThis carries the cast's static type, + // which the identity token below does not have. + const aliasSymbol = identifierReadSymbol(lowerer, expr); + if (aliasSymbol && lowerer.castGlobalAliases.has(aliasSymbol)) { + lowerer.unsupported( + "SC1090", + expr, + "the Buffer constructor read through a cast of globalThis as a value", + "test it in a condition, read its typeof, or call its members", + ); + } return { kind: "strLit", value: "[builtin Buffer]", type: STRING, loc }; } if (lowerer.isStdlibGlobal(expr, "fetch")) { diff --git a/packages/compiler/src/frontend/lowering/expressions/typeof.ts b/packages/compiler/src/frontend/lowering/expressions/typeof.ts index cc3ac06fb..fbbc068b9 100644 --- a/packages/compiler/src/frontend/lowering/expressions/typeof.ts +++ b/packages/compiler/src/frontend/lowering/expressions/typeof.ts @@ -88,6 +88,12 @@ export function lowerTypeofExpr(lowerer: Lowerer, expr: ts.TypeOfExpression, loc if (builtinClassRefOf(lowerer, expr.expression) !== null) { return { kind: "strLit", value: "function", type: STRING, loc }; } + // The Buffer constructor and its aliases (`const B = globalThis.Buffer`, + // `(globalThis as { Buffer?: B }).Buffer`): a value read of an alias is an + // identity token, so its typeof is decided here. + if (stdlibGlobalNameOf(lowerer, expr.expression) === "Buffer") { + return { kind: "strLit", value: "function", type: STRING, loc }; + } if (ts.isPropertyAccessExpression(expr.expression)) { const member = expr.expression; const prototype = member.expression; diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 04ca23a96..7cc77a2db 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -191,6 +191,10 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio operatorUnsupported(lowerer, expr, "+", [raw.type]); } case ts.SyntaxKind.ExclamationToken: { + // `!runtimeBuffer` over an alias of the Buffer global: Node always + // defines Buffer (the condition twin is lowerCondition's). + if (lowerer.isStdlibGlobal(expr.operand, "Buffer")) + return { kind: "boolLit", value: false, type: BOOL, loc }; // `!x` is ToBoolean-then-negate: f64/string operands go through toBool. const operand = lowerer.ensureBool( deferredSlotOr( diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 2bde5f2aa..c1c5ef8c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1087,6 +1087,10 @@ export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr { lowerer.isStdlibGlobal(e.expression, "Object") ) return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) }; + // `if (runtimeBuffer)` over an alias of the Buffer global (a + // capability probe): Node always defines Buffer. + if (lowerer.isStdlibGlobal(e, "Buffer")) + return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) }; if (ts.isPropertyAccessExpression(e)) { const presence = lowerPromiseThenPresence(lowerer, e, { kind: "boolLit", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index e66d0e855..e11f79c25 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -8749,13 +8749,20 @@ function destrSpellingOf(rhs: ts.Expression): string | null { return base === null ? null : `${base}.${e.name.text}`; } // `table[key]` and `list[0]`; V8 prints a numeric key in its canonical - // number form (`o[1.50]` reads "o[1.5]"). + // number form (`o[1.50]` reads "o[1.5]"), and a string key as a property + // name with its text decoded (`o["a.b"]` and `o[\`a.b\`]` read "o.a.b"). + // The message is a C string, so a key holding NUL keeps the refusal. if (ts.isElementAccessExpression(e) && !e.questionDotToken) { const base = destrSpellingOf(e.expression); const key = e.argumentExpression; if (base === null) return null; if (ts.isIdentifier(key)) return `${base}[${key.text}]`; if (ts.isNumericLiteral(key)) return `${base}[${String(Number(key.text))}]`; + if ( + (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) && + !key.text.includes("\0") + ) + return `${base}.${key.text}`; return null; } if (ts.isCallExpression(e) && !e.questionDotToken) { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index fff4a043b..441b313f0 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2937,6 +2937,10 @@ export class Lowerer { * checks resolve through this map (stdlibGlobalNameOf), declarations * emit nothing. */ readonly stdlibGlobalAliases = new Map(); + /** The stdlibGlobalAliases bound through a cast of the global object + * (`(globalThis as { Buffer?: B }).Buffer`). Their static type is the + * cast's, so their value reads are refused (identifiers.ts). */ + readonly castGlobalAliases = new Set(); /** CJS export-table ACCESSORS (`module.exports = { get path() {...} }`), * lifted lazily as module-level functions and interned per accessor * declaration: member reads call the getter (lower-exprs). */ diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 44564f2f1..ed615b276 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -6,6 +6,20 @@ function unparenthesized(e: ts.Expression): ts.Expression { while (ts.isParenthesizedExpression(e)) e = e.expression; return e; } + +/** `e` without parentheses and type-only wrappers (as, satisfies, type + * assertions, non-null assertions), which change no runtime value. */ +function withoutTypeCasts(e: ts.Expression): ts.Expression { + while ( + ts.isParenthesizedExpression(e) || + ts.isAsExpression(e) || + ts.isSatisfiesExpression(e) || + ts.isTypeAssertion(e) || + ts.isNonNullExpression(e) + ) + e = e.expression; + return e; +} import { UNSUPPORTED } from "../../diagnostics/diagnostic.js"; import { BOOL, @@ -3440,6 +3454,17 @@ export function stdlibGlobalNameOf(lowerer: Lowerer, expr: ts.Expression): strin return ts.symbolName(resolved) === "global" ? "globalThis" : ts.symbolName(resolved); } if (ts.isPropertyAccessExpression(expr) && !expr.questionDotToken) { + // A cast of the global object (`(globalThis as { Buffer?: B }).Buffer`, + // `(globalThis as any).Buffer`) changes only its static type: the + // member is the global object's own. + const uncast = withoutTypeCasts(expr.expression); + if (uncast !== unparenthesized(expr.expression)) { + if (!ts.isIdentifier(uncast) || stdlibGlobalNameOf(lowerer, uncast) !== "globalThis") + return null; + const member = lowerer.checker.getPropertyOfType(lowerer.typeOf(uncast), expr.name.text); + if (!member || !lowerer.isStdlibSymbol(member)) return null; + return ts.symbolName(member) === "global" ? "globalThis" : ts.symbolName(member); + } if (stdlibGlobalNameOf(lowerer, expr.expression) !== "globalThis") return null; // Through a detection chain the checker types the receiver as the // union of every arm; the selected arm (the global object) owns the @@ -3572,6 +3597,13 @@ export function stdlibGlobalAliasDecl( const symbol = lowerer.checker.getSymbolAtLocation(nameNode); if (!symbol) return false; lowerer.stdlibGlobalAliases.set(symbol, name); + const access = init !== undefined ? unparenthesized(init) : undefined; + if ( + access !== undefined && + ts.isPropertyAccessExpression(access) && + withoutTypeCasts(access.expression) !== unparenthesized(access.expression) + ) + lowerer.castGlobalAliases.add(symbol); return true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7309-globalthis-buffer-aliases.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7309-globalthis-buffer-aliases.ts.json new file mode 100644 index 000000000..94d458e11 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7309-globalthis-buffer-aliases.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7309-globalthis-buffer-aliases.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7310-any-destructure-string-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7310-any-destructure-string-keys.ts.json new file mode 100644 index 000000000..45a9f62ff --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7310-any-destructure-string-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7310-any-destructure-string-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/any-destructure-unspelled-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/any-destructure-unspelled-keys.ts.json new file mode 100644 index 000000000..f38d71f47 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/any-destructure-unspelled-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/any-destructure-unspelled-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/globalthis-buffer-alias-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/globalthis-buffer-alias-values.ts.json new file mode 100644 index 000000000..4f6fe0c8c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/globalthis-buffer-alias-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/globalthis-buffer-alias-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7309-globalthis-buffer-aliases.ts b/tests/corpus/7309-globalthis-buffer-aliases.ts new file mode 100644 index 000000000..39d896463 --- /dev/null +++ b/tests/corpus/7309-globalthis-buffer-aliases.ts @@ -0,0 +1,31 @@ +// Aliases of the global Buffer constructor read through a cast of +// globalThis, the capability-probe spelling of code that also runs where +// Buffer is absent: typed and `as any` casts, `if`, ternary, typeof and `!` +// guards, member calls through the alias, and typeof of the alias. +type RuntimeBuffer = { + byteLength(value: string, encoding: "utf8"): number; + from(value: string, encoding: "utf8"): Uint8Array; + isBuffer(value: unknown): boolean; +}; + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +const anyBuffer = (globalThis as any).Buffer; +const text = "héllo wörld"; + +if (runtimeBuffer) { + console.log("typed", runtimeBuffer.byteLength(text, "utf8")); +} else { + console.log("typed: no Buffer"); +} +if (typeof anyBuffer === "function") { + console.log("any", anyBuffer.byteLength(text, "utf8")); +} +const bytes = runtimeBuffer ? runtimeBuffer.from(text, "utf8") : undefined; +console.log("from", bytes ? bytes.length : -1); +console.log("isBuffer", runtimeBuffer ? runtimeBuffer.isBuffer(bytes) : false); +console.log("typeof", typeof runtimeBuffer, typeof anyBuffer, !runtimeBuffer); + +function utf8Length(value: string): number { + return runtimeBuffer ? runtimeBuffer.byteLength(value, "utf8") : value.length; +} +console.log("function", utf8Length("✓✓"), utf8Length("abc")); diff --git a/tests/corpus/7310-any-destructure-string-keys.ts b/tests/corpus/7310-any-destructure-string-keys.ts new file mode 100644 index 000000000..4ab24ec77 --- /dev/null +++ b/tests/corpus/7310-any-destructure-string-keys.ts @@ -0,0 +1,60 @@ +// Destructuring an `any` source read through a string-literal key +// (`const { value } = events["a.b"]`): reads, renames, defaults, rest, a +// nested pattern, the assignment form, a template-literal key, chained +// string keys, and V8's TypeError for undefined and null sources, whose +// message spells the key as a property name with its text decoded. +const events: any = { + "a.b": { value: 1, label: "first", extra: true, nested: { deep: "d" } }, + "x-y": { value: 2 }, + outer: { "in.ner": { value: 3 } }, + "nul": null, +}; + +const { value } = events["a.b"]; +const { value: renamed, missing = "fallback", ...rest } = events["a.b"]; +const { + nested: { deep }, +} = events["a.b"]; +console.log(value, renamed, missing, JSON.stringify(rest), deep); + +let assigned: unknown; +({ value: assigned } = events[`x-y`]); +const { value: chained } = events["outer"]["in.ner"]; +console.log(assigned, chained); + +function attempt(label: string, f: () => unknown): void { + try { + console.log(label, f()); + } catch (e) { + console.log(label, e instanceof TypeError, (e as Error).message); + } +} +attempt("missing", () => { + const { value } = events["no.such"]; + return value; +}); +attempt("null", () => { + const { value } = events["nul"]; + return value; +}); +attempt("quotes", () => { + const { value } = events["it's \"quoted\""]; + return value; +}); +attempt("escapes", () => { + const { value } = events["tab\there\nnext"]; + return value; +}); +attempt("accents", () => { + const { value } = events["été ✓"]; + return value; +}); +attempt("template", () => { + const { value } = events[`plain`]; + return value; +}); +attempt("assignment", () => { + let target: unknown; + ({ value: target } = events["gone"]); + return target; +}); diff --git a/tests/diagnostics/any-destructure-unspelled-keys.ts b/tests/diagnostics/any-destructure-unspelled-keys.ts new file mode 100644 index 000000000..6680de854 --- /dev/null +++ b/tests/diagnostics/any-destructure-unspelled-keys.ts @@ -0,0 +1,12 @@ +// Destructuring an `any` source keeps SC1031 where the source has no +// compiled spelling for V8's TypeError: a key computed by an expression, an +// optional element read, a template key with a substitution, and a string key +// holding NUL (the message is a C string). String-literal keys compile +// (tests/corpus/7310-any-destructure-string-keys.ts). +const events: any = { ab: { value: 1 } }; +const k = "a"; +const { value: computed } = events["a" + "b"]; +const { value: optional } = events?.["ab"]; +const { value: template } = events[`${k}b`]; +const { value: nul } = events["a\0b"]; +console.log(computed, optional, template, nul); diff --git a/tests/diagnostics/globalthis-buffer-alias-values.ts b/tests/diagnostics/globalthis-buffer-alias-values.ts new file mode 100644 index 000000000..97b49f920 --- /dev/null +++ b/tests/diagnostics/globalthis-buffer-alias-values.ts @@ -0,0 +1,12 @@ +// An alias of the Buffer global read through a cast of globalThis compiles +// in conditions, typeof and member calls +// (tests/corpus/7309-globalthis-buffer-aliases.ts). Its other value uses +// keep a named SC1090 refusal: the alias has the cast's static type. +type RuntimeBuffer = { from(value: string, encoding: "utf8"): Uint8Array }; +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +function keep(value: unknown): unknown { + return value; +} +const bytes = runtimeBuffer && runtimeBuffer.from("abc", "utf8"); +keep(runtimeBuffer); +keep(bytes); diff --git a/tests/harness/__snapshots__/any-destructure-unspelled-keys.ts.txt b/tests/harness/__snapshots__/any-destructure-unspelled-keys.ts.txt new file mode 100644 index 000000000..872b717cf --- /dev/null +++ b/tests/harness/__snapshots__/any-destructure-unspelled-keys.ts.txt @@ -0,0 +1,27 @@ +any-destructure-unspelled-keys.ts:8:29 - error SC1031: destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call) is not supported yet + + 7 | const k = "a"; + 8 | const { value: computed } = events["a" + "b"]; + | ^~~~~~~~~~~~~~~~~ + 9 | const { value: optional } = events?.["ab"]; + +any-destructure-unspelled-keys.ts:9:29 - error SC1031: destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call) is not supported yet + + 8 | const { value: computed } = events["a" + "b"]; + 9 | const { value: optional } = events?.["ab"]; + | ^~~~~~~~~~~~~~ + 10 | const { value: template } = events[`${k}b`]; + +any-destructure-unspelled-keys.ts:10:29 - error SC1031: destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call) is not supported yet + + 9 | const { value: optional } = events?.["ab"]; + 10 | const { value: template } = events[`${k}b`]; + | ^~~~~~~~~~~~~~~ + 11 | const { value: nul } = events["a\0b"]; + +any-destructure-unspelled-keys.ts:11:24 - error SC1031: destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call) is not supported yet + + 10 | const { value: template } = events[`${k}b`]; + 11 | const { value: nul } = events["a\0b"]; + | ^~~~~~~~~~~~~~ + 12 | console.log(computed, optional, template, nul); \ No newline at end of file diff --git a/tests/harness/__snapshots__/globalthis-buffer-alias-values.ts.txt b/tests/harness/__snapshots__/globalthis-buffer-alias-values.ts.txt new file mode 100644 index 000000000..f55423db9 --- /dev/null +++ b/tests/harness/__snapshots__/globalthis-buffer-alias-values.ts.txt @@ -0,0 +1,17 @@ +globalthis-buffer-alias-values.ts:10:15 - error SC1090: the Buffer constructor read through a cast of globalThis as a value is not supported yet + + 9 | } + 10 | const bytes = runtimeBuffer && runtimeBuffer.from("abc", "utf8"); + | ^~~~~~~~~~~~~ + 11 | keep(runtimeBuffer); + + hint: test it in a condition, read its typeof, or call its members + +globalthis-buffer-alias-values.ts:11:6 - error SC1090: the Buffer constructor read through a cast of globalThis as a value is not supported yet + + 10 | const bytes = runtimeBuffer && runtimeBuffer.from("abc", "utf8"); + 11 | keep(runtimeBuffer); + | ^~~~~~~~~~~~~ + 12 | keep(bytes); + + hint: test it in a condition, read its typeof, or call its members \ No newline at end of file diff --git a/tests/harness/corpus-costs/7309-globalthis-buffer-aliases.ts.json b/tests/harness/corpus-costs/7309-globalthis-buffer-aliases.ts.json new file mode 100644 index 000000000..f1c2cc30b --- /dev/null +++ b/tests/harness/corpus-costs/7309-globalthis-buffer-aliases.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 3400 } diff --git a/tests/harness/corpus-costs/7310-any-destructure-string-keys.ts.json b/tests/harness/corpus-costs/7310-any-destructure-string-keys.ts.json new file mode 100644 index 000000000..df39abc08 --- /dev/null +++ b/tests/harness/corpus-costs/7310-any-destructure-string-keys.ts.json @@ -0,0 +1 @@ +{ "plain": 1500, "san": 3900 }