diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index ebe7e2556..45c100535 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -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 = { diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index a3d2a5b11..e5b464edc 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -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)) ); } diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index b5d5a7225..5b8d828e9 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -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 = 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 { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json new file mode 100644 index 000000000..1d0c4e6d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/builtin-values-untyped-slots.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/builtin-values-untyped-slots.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json new file mode 100644 index 000000000..3ecd83187 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/object-to-string-tag-inspect.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/object-to-string-tag-inspect.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json new file mode 100644 index 000000000..e86bb86ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/builtin-values-without-lowering.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/builtin-values-without-lowering.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index f4bc8db68..7cd0c2b19 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -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. */ diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index a015c25fe..84a370aa9 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -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); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index bee032446..638c2f1ba 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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; diff --git a/tests/corpus/builtin-values-untyped-slots.ts b/tests/corpus/builtin-values-untyped-slots.ts new file mode 100644 index 000000000..4185a524e --- /dev/null +++ b/tests/corpus/builtin-values-untyped-slots.ts @@ -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 = { + 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(","), +); diff --git a/tests/corpus/object-to-string-tag-inspect.mjs b/tests/corpus/object-to-string-tag-inspect.mjs new file mode 100644 index 000000000..c72f43e65 --- /dev/null +++ b/tests/corpus/object-to-string-tag-inspect.mjs @@ -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}`, +); diff --git a/tests/diagnostics/builtin-values-without-lowering.ts b/tests/diagnostics/builtin-values-without-lowering.ts new file mode 100644 index 000000000..b8b5c90a1 --- /dev/null +++ b/tests/diagnostics/builtin-values-without-lowering.ts @@ -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 = { setTimeout }; +const list: any[] = [Array, parseInt]; +console.log(inferred, loose, timers, list, take(process)); diff --git a/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt new file mode 100644 index 000000000..97ee5fc49 --- /dev/null +++ b/tests/harness/__snapshots__/builtin-values-without-lowering.ts.txt @@ -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 = { 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 = { 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 = { 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 \ No newline at end of file diff --git a/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json b/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json new file mode 100644 index 000000000..b42466fc7 --- /dev/null +++ b/tests/harness/corpus-costs/builtin-values-untyped-slots.ts.json @@ -0,0 +1 @@ +{ "plain": 4051, "san": 5400 } diff --git a/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json b/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json new file mode 100644 index 000000000..ed19ae7fb --- /dev/null +++ b/tests/harness/corpus-costs/object-to-string-tag-inspect.mjs.json @@ -0,0 +1 @@ +{ "plain": 2793, "san": 3480 }