Read builtins into untyped TypeScript slots as Node's values (#2556)

This commit is contained in:
Chris Tate
2026-10-29 15:05:19 -05:00
committed by GitHub
15 changed files with 289 additions and 4 deletions
@@ -1,5 +1,5 @@
/* Identifier references in expression position: lowerExprInner's identifier branch and its helpers. */
import { globalMemberValue } from "../global-values.js";
import { globalMemberValue, tsUntypedGlobalValue } from "../global-values.js";
import { predeclareForwardClass } from "../lower-stmts.js";
import { cjsMemberAliasValueOf } from "../cjs-exports-object.js";
import { builtinClassValue, builtinMemberClassKind } from "../builtins/builtin-class-dyn.js";
@@ -825,6 +825,8 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) {
return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc };
}
const untyped = tsUntypedGlobalValue(lowerer, expr, canonical, loc);
if (untyped !== null) return untyped;
// The families with a WHY: each hint states what makes the
// surface genuinely non-static (or what to use instead).
const globalHints: Record<string, string | undefined> = {
@@ -47,6 +47,7 @@ import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.
import { builtinGlobalValue } from "./property-access.js";
import { holdsEvolvingArray } from "../lower-stmts.js";
import { builtinNamespaceValue } from "../builtin-namespaces.js";
import { tsUntypedGlobalValue } from "../global-values.js";
/** `{ a: 1, b: "x" }` → recordLit. The record type comes from the
* contextual type when tsc has one (annotated declarations, arguments,
@@ -446,8 +447,11 @@ export function lowerDynObjectLiteral(
// A PURE member read a JS file cannot lower (a namespace object
// in an export aggregate): the slot takes a boxed fence closure —
// the diagnostics defer to the runtime-fence ledger, the object
// builds, and only USING the member stops the run.
const pureMember = ts.isIdentifier(valueExpr);
// builds, and only USING the member stops the run. A TypeScript
// source keeps the compile-time refusal: the closure would answer
// typeof "function" and undefined members where Node reads the
// value.
const pureMember = ts.isIdentifier(valueExpr) && isJsSourceFile(ts.getSourceFileOfNode(prop));
if (!(err instanceof PoisonError) || !pureMember) throw err;
const fence = lowerer.deferToRuntimeFence(propDiagsBefore, prop, {
kind: "closure",
@@ -3540,7 +3544,8 @@ function shorthandBuiltinValue(
const name = ts.symbolName(symbol);
return (
(js ? builtinNamespaceValue(lowerer, name, locOf(prop.name)) : null) ??
builtinGlobalValue(name, locOf(prop.name), js)
builtinGlobalValue(name, locOf(prop.name), js) ??
tsUntypedGlobalValue(lowerer, prop.name as ts.Identifier, name, locOf(prop.name))
);
}
@@ -138,6 +138,55 @@ export function jsGlobalValueByName(
);
}
/** Globals whose values a TypeScript read keeps refused: the Array
* constructor value has none of Array's static members (`o.Array.isArray`
* would read undefined), and the timer values lack the
* util.promisify.custom getter Node's util.inspect lists. */
const UNTYPED_UNMODELED: ReadonlySet<string> = new Set(["Array", "setTimeout", "setImmediate"]);
/** The value a TypeScript source reads for the declared global `name` at
* `node` when the checker types the slot it flows into as any or unknown
* (`const o: any = { j: JSON }`, a call to a parameter typed unknown): the
* value a JavaScript source reads. Null in JavaScript sources, in typed
* slots, and for a global without a value, whose read keeps its SC2020
* refusal. Null for the primitive constructors too: a TypeScript read of
* String, Number, Boolean or BigInt is the closure with the typed
* signature (lowerIdentifierExpr), and `{ String }` must equal it. */
export function tsUntypedGlobalValue(
lowerer: Lowerer,
node: ts.Expression,
name: string,
loc: SrcLoc,
): IrExpr | null {
if (isJsSourceFile(ts.getSourceFileOfNode(node)) || UNTYPED_UNMODELED.has(name)) return null;
if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt")
return null;
if (!flowsIntoUntypedSlot(lowerer, node)) return null;
return jsGlobalValueByName(lowerer, name, loc);
}
/** Whether the checker types the slot `node` flows into as any or unknown.
* A member of an object or array literal whose own slot is any has no
* contextual type, so the literal's slot decides. */
function flowsIntoUntypedSlot(lowerer: Lowerer, node: ts.Expression): boolean {
let value: ts.Expression = node;
for (;;) {
const slot = lowerer.checker.getContextualType(value);
if (slot !== undefined) return (slot.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
const parent = value.parent;
if (
(ts.isPropertyAssignment(parent) && parent.initializer === value) ||
ts.isShorthandPropertyAssignment(parent)
) {
value = parent.parent as ts.ObjectLiteralExpression;
} else if (ts.isArrayLiteralExpression(parent) || ts.isParenthesizedExpression(parent)) {
value = parent;
} else {
return false;
}
}
}
/** jsGlobalValueByName for the globals other than the interned builtin
* functions and constructors (builtinGlobalValue). */
export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/builtin-values-untyped-slots.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/object-to-string-tag-inspect.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/builtin-values-without-lowering.ts"
],
"diags": []
}
+3
View File
@@ -1220,6 +1220,9 @@ void scr_dyn_install_species_symbol(ScrDyn *key);
/* Records each well-known symbol the symbol unit creates. */
void scr_dyn_install_well_known_symbol(ScrDyn *key);
void scr_dyn_install_to_string_tag_symbol(ScrDyn *key);
/* Symbol.toStringTag (borrowed), or NULL before the program first
* evaluates it. */
const ScrDyn *scr_dyn_to_string_tag_symbol(void);
/* Whether key is Symbol.asyncIterator. */
bool scr_dyn_is_async_iterator_symbol(const ScrDyn *key);
/* Symbol.asyncIterator (+1), or NULL before the symbol unit installed it. */
+51
View File
@@ -1708,7 +1708,48 @@ static bool insp_obj_shows(const ScrDynProp *ent) {
return ent->enumerable || (g_show_hidden && !(ent->key_len && ent->key[0] == '%'));
}
/* The @@toStringTag Node brackets after a plain object's prefix
* (`Object [Math] {}`), +1: a nonempty string read through the object and
* its prototypes, unless the object's own tag is listed with its keys
* (an enumerable one, or any under showHidden). NULL otherwise, and when
* the tag's getter throws, as in Node. */
static ScrStr *insp_plain_object_tag(ScrDyn *d) {
const ScrDyn *key = scr_dyn_to_string_tag_symbol();
if (!key) return NULL;
ScrDynProp own;
if (scr_dyn_own_symbol_prop(d, key, &own) && (own.enumerable || g_show_hidden)) return NULL;
ScrDyn *tag = scr_dyn_symbol_key_get(d, key, true);
if (!tag) {
if (scr_exc_pending()) scr_exc_clear();
return NULL;
}
ScrStr *out = tag->kind == SCR_DYN_STR && tag->v.str->len ? scr_str_retain(tag->v.str) : NULL;
scr_dyn_release(tag);
return out;
}
/* Node's getPrefix for a plain object, without the trailing space: "",
* "Object [tag]" ("Object" for the tag "Object"), or the null-prototype
* marker followed by " [tag]". +1. */
static ScrStr *insp_plain_object_prefix(const ScrDyn *d, const ScrStr *tag) {
InspBuf out = {0};
if (d->null_proto) ib_cstr(&out, "[Object: null prototype]");
else if (tag) ib_cstr(&out, "Object");
if (tag && !(tag->len == 6 && memcmp(tag->data, "Object", 6) == 0)) {
ib_cstr(&out, " [");
ib_bytes(&out, tag->data, tag->len);
ib_char(&out, ']');
}
return ib_take(&out);
}
static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char *cls) {
/* A plain object with a tag takes Node's tagged prefix in all three
* forms: `Object [Math] {}`, `[Object [Math]]` past the depth limit,
* and `Object [Math] { key: value }`. */
ScrStr *tag = cls ? NULL : insp_plain_object_tag(d);
ScrStr *tagged = tag ? insp_plain_object_prefix(d, tag) : NULL;
if (tag) scr_str_release(tag);
size_t shown = 0;
for (size_t i = 0; i < scr_dyn_obj_count(d); i++) {
ScrDynProp ent = scr_dyn_obj_prop_at(d, i);
@@ -1719,6 +1760,15 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char
ScrDynProp ent;
shown += scr_insp_symbol_entry(d, i, &ent);
}
if (tagged && (shown == 0 || recurse > depth)) {
InspBuf out = {0};
bool bracket = shown != 0 && !d->null_proto;
if (bracket) ib_char(&out, '[');
ib_bytes(&out, tagged->data, tagged->len);
ib_cstr(&out, bracket ? "]" : shown == 0 ? " {}" : "");
scr_str_release(tagged);
return shown == 0 ? ib_take(&out) : insp_styled(ib_take(&out), INSP_SPECIAL);
}
if (shown == 0) {
if (cls) return scr_insp_class_tag(cls, " {}");
return d->null_proto ? scr_str_new("[Object: null prototype] {}", 27)
@@ -1771,6 +1821,7 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char
scr_str_release(entry);
}
ScrStr *base = cls ? scr_str_new(cls, strlen(cls))
: tagged ? tagged
: d->null_proto ? scr_str_new("[Object: null prototype]", 24)
: scr_str_new("", 0);
ScrStr *b0 = scr_str_new("{", 1);
+1
View File
@@ -261,6 +261,7 @@ void scr_dyn_install_to_string_tag_symbol(ScrDyn *key) {
scr_to_string_tag_symbol = scr_dyn_retain(key);
scr_atexit(scr_to_string_tag_symbol_cleanup);
}
const ScrDyn *scr_dyn_to_string_tag_symbol(void) { return scr_to_string_tag_symbol; }
void scr_dyn_install_iterator_symbol(ScrDyn *key) {
if (scr_iterator_symbol) return;
@@ -0,0 +1,53 @@
// Builtins read into any- and unknown-typed slots hold the values Node reads.
function take(x: unknown): any {
return x;
}
function give(): any {
return Math;
}
const record: Record<string, unknown> = {
json: JSON,
math: Math,
buffer: Buffer,
};
const loose: any = {
JSON,
Reflect,
k: Map,
bytes: Uint8Array,
clone: structuredClone,
};
const list: any[] = [JSON, Math, atob, btoa];
const json: any = record.json;
const math: any = record.math;
console.log(typeof json, typeof math, typeof loose.Reflect, typeof loose.clone);
console.log(
json === loose.JSON,
json === list[0],
math === list[1],
math === give(),
);
console.log(
take(JSON) === json,
take(Map) === loose.k,
take(Buffer) === record.buffer,
);
console.log(json.stringify({ a: [1, "x"] }), json.parse("[2,3]").length);
console.log(math.max(3, 9, 4), math.PI.toFixed(5), list[1].sqrt(16));
console.log(loose.Reflect.ownKeys({ b: 1, c: 2 }));
console.log(new loose.k([[1, "one"]]).get(1), new loose.bytes(3).length);
console.log((record.buffer as any).from("hi").toString("hex"));
console.log(loose.clone({ n: [1, 2] }).n[1], list[2]("aGk="), list[3]("hi"));
console.log(math.max.name, math.max.length, json.parse.name, loose.clone.name);
console.log(Math, JSON, Reflect);
console.log({ nested: { math: record.math } });
console.log(Object.prototype.toString.call(math), String(json));
console.log(JSON.stringify({ math, json }));
console.log(
Object.keys(math).length,
Object.getOwnPropertyNames(json).join(","),
);
@@ -0,0 +1,37 @@
// util.inspect brackets a plain object's Symbol.toStringTag after its prefix.
const hidden = {};
Object.defineProperty(hidden, Symbol.toStringTag, { value: "X" });
const keyed = { a: 1 };
Object.defineProperty(keyed, Symbol.toStringTag, { value: "Y" });
const bare = Object.create(null);
Object.defineProperty(bare, Symbol.toStringTag, { value: "N" });
const bareKeyed = Object.create(null);
bareKeyed.k = 1;
Object.defineProperty(bareKeyed, Symbol.toStringTag, { value: "N" });
const empty = {};
Object.defineProperty(empty, Symbol.toStringTag, { value: "" });
const named = { z: 1 };
Object.defineProperty(named, Symbol.toStringTag, { value: "Object" });
const getter = { c: 1 };
Object.defineProperty(getter, Symbol.toStringTag, { get: () => "G" });
const throwing = { c: 1 };
Object.defineProperty(throwing, Symbol.toStringTag, {
get() {
throw new Error("boom");
},
});
const numeric = { c: 1 };
Object.defineProperty(numeric, Symbol.toStringTag, { value: 7 });
const inherited = Object.create(hidden);
const listed = { [Symbol.toStringTag]: "Z", b: 2 };
console.log(hidden, keyed, bare, bareKeyed);
console.log(empty, named, getter, throwing, numeric);
console.log(inherited, listed, [keyed], { keyed });
console.log({ a: { b: { keyed, bareKeyed, named, hidden } } });
console.log(Math, JSON, Reflect, { a: { b: { c: Math } } });
console.log(
Object.prototype.toString.call(hidden),
String(keyed),
`${inherited}`,
);
@@ -0,0 +1,11 @@
// Builtins without a lowered value refuse at compile time in TypeScript,
// including as object literal members.
function take(x: unknown): unknown {
return x;
}
const inferred = { json: JSON };
const loose: any = { intl: Intl, proxy: Proxy };
const timers: Record<string, unknown> = { setTimeout };
const list: any[] = [Array, parseInt];
console.log(inferred, loose, timers, list, take(process));
@@ -0,0 +1,53 @@
builtin-values-without-lowering.ts:7:7 - error SC2009: values of type '{ json: JSON; }' cannot be compiled: the record shape is supported, but its member 'json.parse' has type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }', which does not compile
6 |
7 | const inferred = { json: JSON };
| ^~~~~~~~
8 | const loose: any = { intl: Intl, proxy: Proxy };
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
builtin-values-without-lowering.ts:7:18 - error SC2009: values of type '{ json: JSON; }' cannot be compiled: the record shape is supported, but its member 'json.parse' has type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }', which does not compile
6 |
7 | const inferred = { json: JSON };
| ^~~~~~~~~~~~~~
8 | const loose: any = { intl: Intl, proxy: Proxy };
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
builtin-values-without-lowering.ts:8:28 - error SC2020: 'Intl' is part of the standard library types but has no scriptc lowering yet
7 | const inferred = { json: JSON };
8 | const loose: any = { intl: Intl, proxy: Proxy };
| ^~~~
9 | const timers: Record<string, unknown> = { setTimeout };
hint: native Intl supports default Unicode grapheme segmentation and the composed new Intl.NumberFormat("en-US").format(x); locale negotiation and other ICU-backed operations remain unsupported
builtin-values-without-lowering.ts:9:43 - error SC2020: 'setTimeout' is part of the standard library types but has no scriptc lowering yet
8 | const loose: any = { intl: Intl, proxy: Proxy };
9 | const timers: Record<string, unknown> = { setTimeout };
| ^~~~~~~~~~
10 | const list: any[] = [Array, parseInt];
hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations)
builtin-values-without-lowering.ts:10:22 - error SC2020: 'Array' is part of the standard library types but has no scriptc lowering yet
9 | const timers: Record<string, unknown> = { setTimeout };
10 | const list: any[] = [Array, parseInt];
| ^~~~~
11 | console.log(inferred, loose, timers, list, take(process));
hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations)
builtin-values-without-lowering.ts:11:13 - error SC2004: uses of 'inferred' inherit the blocker on its declaration
10 | const list: any[] = [Array, parseInt];
11 | console.log(inferred, loose, timers, list, take(process));
| ^~~~~~~~
12 |
hint: the declaration of 'inferred' did not compile — fix the diagnostic reported there and these sites clear with it
@@ -0,0 +1 @@
{ "plain": 4051, "san": 5400 }
@@ -0,0 +1 @@
{ "plain": 2793, "san": 3480 }