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>
This commit is contained in:
Chris Tate
2026-10-23 15:13:03 -05:00
co-authored by patlux jlucaso1
parent 850710fbea
commit e75174aeea
19 changed files with 254 additions and 1 deletions
@@ -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")) {
@@ -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;
@@ -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(
@@ -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",
@@ -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) {
@@ -2937,6 +2937,10 @@ export class Lowerer {
* checks resolve through this map (stdlibGlobalNameOf), declarations
* emit nothing. */
readonly stdlibGlobalAliases = new Map<ts.Symbol, string>();
/** 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<ts.Symbol>();
/** 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). */
@@ -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;
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7309-globalthis-buffer-aliases.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7310-any-destructure-string-keys.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/any-destructure-unspelled-keys.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/globalthis-buffer-alias-values.ts"
],
"diags": []
}
@@ -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"));
@@ -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;
});
@@ -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);
@@ -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);
@@ -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);
@@ -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
@@ -0,0 +1 @@
{ "plain": 2600, "san": 3400 }
@@ -0,0 +1 @@
{ "plain": 1500, "san": 3900 }