diff --git a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts index 92a724b6b..ad8f019b9 100644 --- a/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts +++ b/packages/compiler/src/frontend/lowering/containers/indexed-objects.ts @@ -1,3 +1,4 @@ +import { lowerWrapperToObject, programCreatesWrappers } from "../primitive-wrappers.js"; import { keyedHelperName } from "../stable-names.js"; import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js"; import { countedFor, numLit, varRef } from "../../../ir/build.js"; @@ -50,6 +51,13 @@ export function lowerObjectConstructor( } if (args.length !== 1 || ts.isSpreadElement(args[0]!)) return null; const value = lowerer.lowerExpr(args[0]!); + // ToObject of a value that may be a primitive, once the program creates + // wrapper objects (programCreatesWrappers counts this very call). + if ( + ["dyn", "string", "f64", "bool"].includes(value.type.kind) && + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)) + ) + return lowerWrapperToObject(lowerer, lowerer.coerceToExpected(value, DYN), loc); if (value.kind === "dynObjLit" || value.kind === "dynArrLit") return value; if ( [ diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index a47e24908..5c41aad52 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1,4 +1,5 @@ /* instanceof and in, including private-name and runtime-key in. */ +import { programCreatesWrappers, WRAPPER_KIND_NUMBER } from "../primitive-wrappers.js"; import { lowerArrayClassInstanceOf } from "../builtins/array-classes.js"; import { dateRootOf } from "../builtins/date-classes.js"; import { keyedHelperName } from "../stable-names.js"; @@ -201,6 +202,23 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc // (typebox's IsBoolean/IsNumber/IsString guards). The operand still runs. if (PRIMITIVE_WRAPPERS.some((name) => lowerer.isStdlibGlobal(expr.right, name))) { const value = lowerer.lowerExpr(expr.left); + // In a program that creates wrappers, a checked-dynamic value may be + // one: the kind's prototype on its chain decides. + const wrapperKind = (["String", "Number", "Boolean"] as const).find((name) => + lowerer.isStdlibGlobal(expr.right, name), + ); + if ( + wrapperKind && + value.type.kind === "dyn" && + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)) + ) + return { + kind: "libCall", + fn: "wrapper.instanceOf", + args: [value, { kind: "numLit", value: WRAPPER_KIND_NUMBER[wrapperKind], type: F64, loc }], + type: BOOL, + loc, + }; return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: value, loc }], diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e9d79213c..827e69b58 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1,3 +1,4 @@ +import { programCreatesWrappers } from "./primitive-wrappers.js"; import { arrayClassMemberOwner, arrayClassReceiver, @@ -4045,7 +4046,9 @@ function lowerDynReceiverMethodCall( // else calls its member as Node does. if ( (checkerUntyped || - (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 || + // A receiver of the boxed String type may be a wrapper object. + (lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) && !call.questionDotToken && !access.questionDotToken && !call.arguments.nodes.some(ts.isSpreadElement) @@ -4289,11 +4292,50 @@ function lowerStringOrMemberCall( const loc = locOf(call); const receiverLocal = lowerer.declareHiddenLocal("%dynCallReceiver", DYN); const receiver = varRef(receiverLocal.id, DYN, loc); - const isString: IrExpr = { kind: "dynTest", test: "string", value: receiver, type: BOOL, loc }; + const nameLit: IrExpr = { kind: "strLit", value: access.name.text, type: STRING, loc }; + const receiverIsString: IrExpr = { + kind: "dynTest", + test: "string", + value: receiver, + type: BOOL, + loc, + }; + // In a program that creates wrapper objects, a receiver whose member is + // the builtin String.prototype method runs the string lowering on + // ToString of the receiver, after the arguments, as the builtin does. + const wrappers = (lowerer.createsWrappers ??= programCreatesWrappers(lowerer)); + const builtinLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallStringMethod", BOOL) : null; + const isString: IrExpr = builtinLocal + ? { kind: "varRef", localId: builtinLocal.id, type: BOOL, loc } + : receiverIsString; + const stringLocal = wrappers ? lowerer.declareHiddenLocal("%dynCallString", DYN) : null; const calleeLocal = lowerer.declareHiddenLocal("%dynCallCallee", DYN); const callee = varRef(calleeLocal.id, DYN, loc); const stmts: IrStmt[] = [ { kind: "varDecl", localId: receiverLocal.id, init: recv, loc }, + ...(builtinLocal + ? [ + { + kind: "varDecl" as const, + localId: builtinLocal.id, + init: { + kind: "logical" as const, + op: "||" as const, + left: receiverIsString, + right: { + kind: "libCall" as const, + fn: "wrapper.isStringMethod" as const, + args: [receiver, nameLit], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + loc, + }, + ] + : []), { kind: "varDecl", localId: calleeLocal.id, @@ -4315,14 +4357,28 @@ function lowerStringOrMemberCall( }, ]; const args = call.arguments.nodes.map((node) => { - const value = lowerer.lowerExpr(node); + const lowered = lowerer.lowerExpr(node); + // A unit-typed argument (`f()` of a void function, a literal undefined + // or null) has no local storage of its own: hold it as the dynamic + // value both branches receive. + const value = + lowered.type.kind === "void" || + lowered.type.kind === "undefinedT" || + lowered.type.kind === "nullT" + ? lowerer.coerceToExpected(lowered, DYN) + : lowered; const local = lowerer.declareHiddenLocal("%dynCallArg", value.type); stmts.push({ kind: "varDecl", localId: local.id, init: value, loc: value.loc }); return { node, value: varRef(local.id, value.type, value.loc) }; }); // String.prototype methods ignore arguments past the ones they read. const arity = DYN_STRING_ONLY_ARITY[access.name.text] ?? call.arguments.nodes.length; - const stringReceiver = (): IrExpr => ({ kind: "dynCheck", value: receiver, type: STRING, loc }); + const stringReceiver = (): IrExpr => ({ + kind: "dynCheck", + value: stringLocal ? varRef(stringLocal.id, DYN, loc) : receiver, + type: STRING, + loc, + }); const lowerString = (): IrExpr | null => (call.arguments.nodes.length <= arity ? lowerRegexMethodCall(lowerer, call, access, stringReceiver) @@ -4351,7 +4407,35 @@ function lowerStringOrMemberCall( kind: "ternary", cond: isString, then: stringCall - ? lowerer.coerceToExpected(stringCall, DYN) + ? stringLocal + ? { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: stringLocal.id, + init: { + kind: "ternary", + cond: receiverIsString, + then: receiver, + else_: { + kind: "libCall", + fn: "wrapper.thisString", + args: [receiver, nameLit], + type: DYN, + loc, + }, + type: DYN, + loc, + }, + loc, + }, + ], + result: lowerer.coerceToExpected(stringCall, DYN), + type: DYN, + loc, + } + : lowerer.coerceToExpected(stringCall, DYN) : nodeThrowExpr( 0, "SC1090", diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 014bbc71a..243be7173 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -1,3 +1,4 @@ +import { lowerWrapperNew } from "./primitive-wrappers.js"; import { lowerAsyncLocalStorageOptions } from "./builtins/async-context.js"; import { shapeOverlayAnnotated } from "../shape-inference/index.js"; import { @@ -10189,6 +10190,8 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { return lowerWithInlineRequireInit(lowerer, req, () => lowerNew(lowerer, expr)); } } + const wrapper = lowerWrapperNew(lowerer, expr); + if (wrapper) return wrapper; const worker = lowerWorkerNew(lowerer, expr); if (worker) return worker; const readlineUi = lowerReadlineUiNew(lowerer, expr) ?? lowerReadlineInterfaceNew(lowerer, expr); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..3096fdcdc 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,3 +1,4 @@ +import { programCreatesWrappers, routeWrapperConsumers } from "./primitive-wrappers.js"; import type { DiagnosticCode } from "../../diagnostics/codes.js"; import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inference/index.js"; import { npmStaticPackageOfPath } from "../npm-static.js"; @@ -3485,6 +3486,9 @@ export class Lowerer { * interned here so references under one source-world ABI are the SAME * zero-capture closure (see primitiveCtorClosure). */ readonly primitiveCtorFns = new Map(); + /** Whether a compiled source creates primitive wrapper objects + * (programCreatesWrappers), computed on first use. */ + createsWrappers: boolean | undefined = undefined; /** Builtin-module FUNCTIONS admitted as values by their surface-table * entries. The resolved runtime function plus ABI is the identity key, * so named imports, namespace reads, CommonJS bindings, and alias chains @@ -5134,6 +5138,7 @@ export class Lowerer { mixinIntersectionInstance: (widened) => mixinIntersectionInstanceType(this, widened), classImplementedProtocol: (type) => this.classImplementedProtocol(type), isStdlibFile: (sf) => this.isStdlibFile(sf), + primitiveWrappers: () => (this.createsWrappers ??= programCreatesWrappers(this)), isNpmFile: (sf) => this.isNpmFile(sf), isExperimentalFile: (sf) => isExperimentalDeclarationFile(sf, this.experimentalFiles), // The declaration-typed boundary's generated types (decl-projection.ts) @@ -9655,6 +9660,7 @@ export class Lowerer { if (this.diags.length === 0) fuseArrayPipelines(this, functions); finalizeClassMethodValues(this, functions); finalizePrivateBrands(this, functions); + routeWrapperConsumers(this, functions); trackClassFieldCreation(this, functions); boxClassInstancesExactly(this, functions); addUntypedIntegrityWords(this); diff --git a/packages/compiler/src/frontend/lowering/primitive-wrappers.ts b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts new file mode 100644 index 000000000..d57e398a9 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/primitive-wrappers.ts @@ -0,0 +1,237 @@ +/* Primitive wrapper objects (`new String(s)`, `new Number(n)`, `new + * Boolean(b)`, `Object(primitive)`) in JavaScript sources. A wrapper is an + * ordinary dynamic object (scr_js_wrapper.c): its prototype is the + * wrapper unit's String/Number/Boolean prototype object, whose valueOf, + * toString and methods read the [[PrimitiveValue]] slot, and a String + * wrapper owns its index properties and `length`. Every generic dynamic + * operation (ToPrimitive, property reads, method calls, key listing, + * identity, typeof) already handles that shape; this module builds the + * values and routes the few consumers that must see the slot itself. */ +import * as ts from "../ts7/adapter.js"; +import { + BOOL, + DYN, + F64, + STRING, + type IrExpr, + type IrFunction, + type IrStmt, + type SrcLoc, +} from "../../ir/ir.js"; +import { transformStmtList } from "../../ir/traverse.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import { primitiveCtorClosure } from "./lower-exprs.js"; + +export type WrapperKind = "String" | "Number" | "Boolean"; + +/** The runtime's kind numbers (scr_js_wrapper.c SCR_JS_WRAPPER_*). */ +export const WRAPPER_KIND_NUMBER: Record = { + String: 0, + Number: 1, + Boolean: 2, +}; + +/** Whether any compiled source creates a primitive wrapper: a `new` of the + * stdlib String, Number, Boolean or Object constructor with an argument, or + * a call of the stdlib Object with an argument. Until a program has one, no + * value is a wrapper, so the boxed interface types keep their primitive + * mapping and every fold that relies on it stays. */ +export function programCreatesWrappers(lowerer: Lowerer): boolean { + for (const sf of lowerer.program.getSourceFiles()) { + if (sf.isDeclarationFile || !isJsSourceFile(sf)) continue; + const text = sf.text; + if ( + !text.includes("String(") && + !text.includes("Number(") && + !text.includes("Boolean(") && + !text.includes("Object(") + ) + continue; + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if ( + (ts.isNewExpression(node) || ts.isCallExpression(node)) && + ts.isIdentifier(node.expression) && + (node.arguments?.nodes.length ?? 0) > 0 + ) { + const name = node.expression.text; + const isNew = ts.isNewExpression(node); + if ( + ((isNew && (name === "String" || name === "Number" || name === "Boolean")) || + name === "Object") && + lowerer.isStdlibGlobal(node.expression, name) + ) { + found = true; + return; + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + if (found) return true; + } + return false; +} + +/** `new String(x)` / `new Number(x)` / `new Boolean(x)` in a JavaScript + * source: the wrapper over ToString/ToNumber/ToBoolean of the argument + * (the runtime converts, with the object hooks), or over "", 0, false + * without one. Null outside JavaScript sources and for other callees. */ +export function lowerWrapperNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + if (!isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + if (!ts.isIdentifier(expr.expression)) return null; + const kind = expr.expression.text; + if (kind !== "String" && kind !== "Number" && kind !== "Boolean") return null; + if (!lowerer.isStdlibGlobal(expr.expression, kind)) return null; + const args = expr.arguments?.nodes ?? []; + if (args.some(ts.isSpreadElement)) return null; + const loc = locOf(expr); + // Arguments past the first are evaluated and ignored. + const value: IrExpr = + args.length > 0 + ? lowerer.lowerExprExpecting(args[0]!, DYN) + : lowerer.coerceToExpected( + kind === "String" + ? { kind: "strLit", value: "", type: STRING, loc } + : kind === "Number" + ? { kind: "numLit", value: 0, type: F64, loc } + : { kind: "boolLit", value: false, type: BOOL, loc }, + DYN, + ); + const rest = args.slice(1).map((arg) => ({ + kind: "exprStmt" as const, + expr: lowerer.lowerExpr(arg), + loc: locOf(arg), + })); + const created: IrExpr = { + kind: "libCall", + fn: "wrapper.new", + args: [ + { kind: "numLit", value: WRAPPER_KIND_NUMBER[kind], type: F64, loc }, + value, + ...wrapperConstructors(lowerer, loc), + ], + type: DYN, + loc, + }; + if (rest.length === 0) return created; + const local = lowerer.declareHiddenLocal("%wrapperArg", DYN); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: value, loc }, ...rest], + result: { + ...created, + args: [ + created.args[0]!, + { kind: "varRef", localId: local.id, type: DYN, loc }, + ...created.args.slice(2), + ], + }, + type: DYN, + loc, + }; +} + +/** The String, Number and Boolean constructor values, for the prototypes' + * `constructor` property (the interned closures `x === String` compares). */ +export function wrapperConstructors(lowerer: Lowerer, loc: SrcLoc): IrExpr[] { + return (["String", "Number", "Boolean"] as const).map((name) => + lowerer.coerceToExpected(primitiveCtorClosure(lowerer, name, loc, true), DYN), + ); +} + +/** Consumers that walk a dynamic value's whole graph and must see the + * [[PrimitiveValue]] slot (JSON's unwrapping, util.inspect's `[String: + * 'x']`, structuredClone's wrapper clone, deep equality's primitive + * compare). Until they know the slot, a wrapper reaching one refuses: each + * dynamic argument passes through wrapper.guard, which throws the + * not-supported Error when the value's own data graph holds a wrapper. */ +const GUARDED_CONSUMERS: ReadonlySet = new Set([ + "json.stringifyValue", + "json.stringifyReplacer", + "util.isDeepStrictEqual", + "dyn.structuredClone", + "assert.looseDeepDyn", + "assert.partialDeepDyn", + "console.dirDyn", + "insp.format", + "insp.formatPrimitives", + "insp.formatList", + "insp.formatWithOptions", + "insp.inspect", + "insp.dynS", + "insp.dynFormatS", +]); + +/** util.types brands that read the [[PrimitiveValue]] slot. */ +const WRAPPER_TYPE_PROBES: ReadonlySet = new Set([ + "isStringObject", + "isNumberObject", + "isBooleanObject", + "isBoxedPrimitive", +]); + +/** The consumer as the refusal names it. */ +function consumerName(fn: string): string { + if (fn.startsWith("json.")) return "JSON.stringify"; + if (fn === "dyn.structuredClone") return "structuredClone"; + if (fn === "util.isDeepStrictEqual") return "util.isDeepStrictEqual"; + if (fn.startsWith("assert.")) return "assert's deep equality"; + return "console.log/util.inspect"; +} + +function guarded(arg: IrExpr, fn: string): IrExpr { + const consumer = consumerName(fn); + if (arg.type.kind !== "dyn" && !(arg.type.kind === "array" && arg.type.elem.kind === "dyn")) + return arg; + return { + kind: "libCall", + fn: arg.type.kind === "dyn" ? "wrapper.guard" : "wrapper.guardList", + args: [arg, { kind: "strLit", value: consumer, type: STRING, loc: arg.loc }], + type: arg.type, + loc: arg.loc, + }; +} + +/** In a program that creates wrappers, route the consumers that read + * internal slots: Object.prototype.toString's tag and the util.types + * brands answer from the slot (wrapper.objectTag, wrapper.typeIs), and the + * graph-walking consumers above guard their dynamic arguments. Programs + * without a wrapper site keep every node unchanged. */ +export function routeWrapperConsumers(lowerer: Lowerer, functions: IrFunction[]): void { + if (!(lowerer.createsWrappers ??= programCreatesWrappers(lowerer))) return; + const transform = { + expr: (expr: IrExpr): IrExpr => { + if (expr.kind === "libCall") { + if (expr.fn === "dyn.objectTag") return { ...expr, fn: "wrapper.objectTag" }; + if ( + expr.fn === "util.typeIs" && + expr.args[1]?.kind === "strLit" && + WRAPPER_TYPE_PROBES.has(expr.args[1].value) + ) + return { ...expr, fn: "wrapper.typeIs" }; + if (GUARDED_CONSUMERS.has(expr.fn)) + return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.fn)) }; + } + if ( + expr.kind === "intrinsic" && + (expr.name === "console.log" || expr.name === "console.error") + ) + return { ...expr, args: expr.args.map((arg) => guarded(arg, expr.name)) }; + return expr; + }, + stmt: (stmt: IrStmt): IrStmt => stmt, + }; + for (const fn of functions) fn.body = transformStmtList(fn.body, transform); +} + +/** `Object(x)` / `new Object(x)` over a checked-dynamic `x` in a program + * that creates wrappers: ToObject (wrapper.toObject), a wrapper for a + * primitive, the value itself for an object, a fresh object for null and + * undefined. */ +export function lowerWrapperToObject(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr { + const ctors = wrapperConstructors(lowerer, loc); + return { kind: "libCall", fn: "wrapper.toObject", args: [value, ...ctors], type: DYN, loc }; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 7836b8918..5ae7961fd 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1528,8 +1528,16 @@ function suppressedJsStrictness7(d: ts.Diagnostic): boolean { // runtime meaning in a JS file. if (d.code >= 8000 && d.code <= 8999) return true; if (JS_ANY_OPERATOR_CODES.has(d.code)) { - return ts.flattenDiagnosticMessageText(d, "\n").includes("'any'"); + const text = ts.flattenDiagnosticMessageText(d, "\n"); + // A primitive wrapper operand (`new Number(1) * 2`): the operator + // converts it with ToPrimitive, as for any object. + return text.includes("'any'") || /'(Number|String|Boolean)'/.test(text); } + // An index write on a String wrapper (`new String("ab")[5] = "q"`): Node + // adds the property past the end and ignores writes to the read-only + // index properties (sloppy) or throws (strict), as the object does. + if (d.code === 2542 && ts.flattenDiagnosticMessageText(d, "\n").includes("type 'String'")) + return true; // The deprecated Buffer(...) call without `new`, which Node's Buffer // function accepts (containers/bytes.ts lowers it), and fs.ReadStream(...) // and fs.WriteStream(...), which construct their stream diff --git a/packages/compiler/src/frontend/tsc-codes.ts b/packages/compiler/src/frontend/tsc-codes.ts index a30945be7..f154d1457 100644 --- a/packages/compiler/src/frontend/tsc-codes.ts +++ b/packages/compiler/src/frontend/tsc-codes.ts @@ -114,6 +114,9 @@ export const JS_RELAXED_TSC_CODES: ReadonlySet = new Set([ // differs (adopted options, hidden declaration twins), and an unused // suppression changes nothing at runtime 2578, + // a primitive on the left of instanceof (`"x" instanceof String`): Node + // answers false, which the lowering folds + 2358, // a lib-floor demand (es2024+ members under the es2023 lib): the member // types as the error-any and its use fences per site with the honest // no-lowering story instead of gating the whole program diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 02539b610..acbe3c76c 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1369,6 +1369,11 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null; * once per Lowerer and threaded through the recursion — adding a hook means * one new field here, not a new parameter at every call site. */ export interface TypeMapperCtx { + /** Whether the program creates primitive wrapper objects + * (primitive-wrappers.ts): then the boxed interface types (`String`, + * `Number`, `Boolean`) map to the checked-dynamic value instead of the + * primitive. Absent or false: every mapping is unchanged. */ + primitiveWrappers?: () => boolean; /** Node islands (scope-B NI-4): whether a type's values are island * handles, which map to the `foreign` type (a dyn value). Absent in * programs without islands. */ @@ -2408,6 +2413,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ts.symbolName(boxSym) === "Boolean") && boxSym.declarations.some((d) => ctx.isStdlibFile(ts.getSourceFileOfNode(d))) ) { + // Once the program creates wrapper objects, a slot of the boxed type + // may hold one: the checked-dynamic value holds both. + if (ctx.primitiveWrappers?.()) return DYN; return ts.symbolName(boxSym) === "Number" ? F64 : ts.symbolName(boxSym) === "String" diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json new file mode 100644 index 000000000..a26882f25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/js-primitive-wrappers.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/js-primitive-wrappers.js" + ], + "diags": [] +} diff --git a/tests/corpus/js-primitive-wrappers.js b/tests/corpus/js-primitive-wrappers.js new file mode 100644 index 000000000..35050eadb --- /dev/null +++ b/tests/corpus/js-primitive-wrappers.js @@ -0,0 +1,59 @@ +// Primitive wrapper objects: new String/Number/Boolean and Object(primitive). +// A wrapper is an object (typeof, identity, own properties, prototype +// chain, instanceof, its builtin tag) and converts to its primitive in every +// coercion (ToPrimitive through valueOf/toString, ToNumber, property keys, +// method receivers). ToBoolean of any wrapper is true. + +const s = new String("lego"); +const n = new Number(4.5); +const b = new Boolean(false); +console.log("typeof", typeof s, typeof n, typeof b); +console.log("identity", s === s, new String("a") === new String("a"), s == "lego", s === "lego"); +console.log("valueOf", s.valueOf() === "lego", n.valueOf() + 1, b.valueOf()); +console.log("coerce", s + "!", `${s}`, String(s), n * 2, +n, n + 1, Number(n)); +console.log("truthy", b ? "wrapper true" : "wrapper false", !!b, b == false); +console.log("string reads", s.length, s[1], s.charAt(0), s.toUpperCase(), s.slice(1, 3)); +console.log("number methods", n.toFixed(2), n.toString(), new Number(255).toString(16)); +console.log("instanceof", s instanceof String, n instanceof Number, b instanceof Boolean, s instanceof Object); +console.log("not instances", "x" instanceof String, s instanceof Number); +console.log("tags", Object.prototype.toString.call(s), Object.prototype.toString.call(n), Object.prototype.toString.call(b)); +console.log("constructor", s.constructor === String, n.constructor === Number, b.constructor === Boolean); +console.log("prototype", Object.getPrototypeOf(s) === Object.getPrototypeOf(new String("z"))); + +// Own properties: index properties and length, then expandos. +const w = new String("ab"); +w.extra = 1; +w[5] = "q"; +console.log("keys", Object.keys(w).join(), Object.getOwnPropertyNames(w).join()); +console.log("in", "0" in w, 2 in w, "length" in w, w.hasOwnProperty("length")); +const d = Object.getOwnPropertyDescriptor(w, "0"); +console.log("descriptor", d.value, d.writable, d.enumerable, d.configurable); +const keyed = {}; +keyed[new String("k")] = 1; +keyed[new Number(2)] = 2; +console.log("property keys", Object.keys(keyed).join()); + +// Overridden coercion hooks are honored. +const hooked = new Number(4); +hooked.valueOf = function () { + return 10; +}; +console.log("hooks", hooked + 1, `${hooked}`, String(hooked)); + +// ToObject. +const o = Object("x"); +const p = Object(5); +const q = Object(true); +const r = {}; +console.log("Object()", typeof o, o.length, o == "x", o instanceof String, typeof p, p + 1, q.valueOf()); +console.log("Object() of objects", Object(r) === r, typeof Object(null), typeof Object(undefined)); + +// Brand checks on the prototype methods, read through a wrapper. +const numberValueOf = new Number(0).valueOf; +const stringToString = new String("").toString; +console.log("method values", numberValueOf.call(n), stringToString.call(s), numberValueOf === n.valueOf); +try { + numberValueOf.call(s); +} catch (error) { + console.log("brand", error.constructor.name, error.message); +} diff --git a/tests/harness/corpus-costs/js-primitive-wrappers.js.json b/tests/harness/corpus-costs/js-primitive-wrappers.js.json new file mode 100644 index 000000000..b8ae4c686 --- /dev/null +++ b/tests/harness/corpus-costs/js-primitive-wrappers.js.json @@ -0,0 +1 @@ +{ "plain": 7725, "san": 117318 } diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index d31d96ac9..7d0d7c8b0 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -98,7 +98,16 @@ const KNOWN: Record = { "node24-style-text": { run: "SC2020" }, "node24-system-errors": { run: "SC1090" }, "node24-text-encoding": { run: "SC2020" }, - "node24-text-utilities": { run: "SC2020" }, + // The fixture builds a String and a Number wrapper, which used to refuse + // with SC2020 and now compile: the case runs, but Node's + // ERR_INVALID_ARG_TYPE names a wrapper's class (`an instance of String`) + // where the error formatter prints `Object`, and the program's own + // `String(functionValue)` prints `function () { [native code] }` for a + // named function where Node prints its source. + "node24-text-utilities": { + divergence: + "wrapper objects reach the text utilities now, but ERR_INVALID_ARG_TYPE names them `Object` instead of `String`/`Number`, and function values print without their source", + }, "node24-timers-unref": { run: "SC2020" }, "node24-url-factories": { run: "SC2020" }, "node24-url-mutation": { run: "SC1090" }, diff --git a/tests/harness/primitive-wrapper-refusals.test.ts b/tests/harness/primitive-wrapper-refusals.test.ts new file mode 100644 index 000000000..c6022661c --- /dev/null +++ b/tests/harness/primitive-wrapper-refusals.test.ts @@ -0,0 +1,52 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); + +// JSON, util.inspect, structuredClone and deep equality cannot read a +// wrapper's [[PrimitiveValue]] slot yet, so a wrapper anywhere in their +// argument refuses with a catchable error instead of a silently wrong +// answer. Values without a wrapper keep working in the same program. +test("graph-walking consumers refuse primitive wrapper objects", async () => { + const outDir = mkdtempSync(join(tmpdir(), "scriptc-wrapper-refusals-")); + const entry = join(outDir, "main.mjs"); + writeFileSync( + entry, + ` +function attempt(label, run) { + try { run(); process.stdout.write(label + " ok\\n"); } catch (error) { process.stdout.write(label + " " + error.message + "\\n"); } +} +attempt("json", () => JSON.stringify({ v: new Number(3) })); +attempt("log", () => console.log([new String("a")])); +attempt("clone", () => structuredClone([new Boolean(true)])); +attempt("plain json", () => JSON.stringify({ v: 3 })); +attempt("plain clone", () => structuredClone([true])); +`, + ); + const result = await compile(entry, { + backend: "llvm", + sanitize: process.env["SCRIPTC_SAN"] === "1", + outDir, + outPath: join(outDir, "program"), + }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" }); + const refused = (consumer: string): string => + `${consumer} of a primitive wrapper object (new String/Number/Boolean or Object(primitive)) is not supported yet`; + expect(stdout).toBe( + [ + `json ${refused("JSON.stringify")}`, + `log ${refused("console.log/util.inspect")}`, + `clone ${refused("structuredClone")}`, + "plain json ok", + "plain clone ok", + "", + ].join("\n"), + ); +});