From 5256f30957f040469b5741996b4bfc4ad1a6c70f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:45:48 -0500 Subject: [PATCH] feat(codemode): honor program valueOf and toString in operators and conversions (#50837) --- packages/codemode/interpreter-support.md | 24 ++- packages/codemode/src/interpreter/callback.ts | 34 +++- packages/codemode/src/interpreter/errors.ts | 10 +- .../codemode/src/interpreter/interpreter.ts | 173 +++++++++++------- packages/codemode/src/stdlib/array.ts | 28 ++- packages/codemode/src/stdlib/math.ts | 36 ++-- packages/codemode/src/stdlib/number.ts | 14 +- packages/codemode/src/stdlib/value.ts | 19 +- packages/codemode/test/parity.test.ts | 117 ++++++++++++ packages/codemode/test/test262/manifest.json | 4 + packages/codemode/test/test262/skipped.txt | 11 +- packages/codemode/test/tool-paths.test.ts | 14 ++ 12 files changed, 349 insertions(+), 135 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 8b32a4955ac..064db10f551 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -224,8 +224,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give `"[object Function]"` rather than the source text, `isNaN(fn)` is `true`. - [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a - nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in - primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`). + nullish operand never coerces the other side, and a data object facing a primitive converts through its own + `valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`). `switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as `rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other @@ -233,11 +233,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1` throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use the source text or `NaN`. -- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument - `Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call - the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form - (`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already - follow ToPrimitive. +- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`, + compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix), + `Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own + `valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their + throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and + `[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive` + behavior (`date + 1` concatenates, `date - date` subtracts). +- [ ] ToPrimitive elsewhere: multi-argument `Date` construction, `Date.UTC`, `Error.prototype.toString` on an object + `message`, and numeric built-in arguments outside `Math` (`at`, `indexOf` start, `toFixed` digits) still use the + built-in form (`NaN`, `"[object Object]"`) and ignore own methods. - [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become string keys. @@ -375,8 +380,9 @@ reject }` object. `flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`, `String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString (`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a - promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own - `valueOf`/`toString` is not consulted yet (see ToPrimitive above). + promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, and + `parseInt` consult a program object's own `valueOf`/`toString`; the array and number methods do not yet (see + ToPrimitive above). ## Strings diff --git a/packages/codemode/src/interpreter/callback.ts b/packages/codemode/src/interpreter/callback.ts index dfd3c88fc51..8147c9c2deb 100644 --- a/packages/codemode/src/interpreter/callback.ts +++ b/packages/codemode/src/interpreter/callback.ts @@ -13,7 +13,7 @@ import { type Cursor, type Value, } from "./objects.js" -import { typeofValue } from "./references.js" +import { isOpaque, typeofValue } from "./references.js" /** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's * return() is a return completion, so a failing close wins over it, as after `break`. */ @@ -31,16 +31,15 @@ export const preserveConsumerError = ( }) }) +export type Hint = "number" | "string" | "default" + /** * ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the - * default hint as "string", like their `Symbol.toPrimitive`. + * default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool + * references) pass through unchanged so callers reject or describe them in their built-in form. */ -export const toPrimitive = ( - ctx: Interpreter, - value: Value, - hint: "number" | "string" | "default", -): Effect.Effect => { - if (!(value instanceof Obj)) return Effect.succeed(value) +export const toPrimitive = (ctx: Interpreter, value: Value, hint: Hint): Effect.Effect => { + if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value) const asString = hint === "string" || (hint === "default" && value instanceof DateObj) const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"] return Effect.gen(function* () { @@ -67,6 +66,25 @@ export const toPrimitiveString = (ctx: Interpreter, value: Value) => export const toPrimitiveNumber = (ctx: Interpreter, value: Value) => Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber) +/** + * Runs a synchronous native body on its arguments after ToPrimitive, in order, with one hint for all positions or + * one per position. Primitive arguments skip the Effect entirely. + */ +export const withPrimitives = ( + ctx: Interpreter, + hints: Hint | ReadonlyArray, + values: Array, + body: (primitives: Array) => Value, +): Value | Effect.Effect => { + if (!values.some((value) => value instanceof Obj)) return body(values) + return Effect.map( + Effect.forEach(values, (value, index) => + toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!), + ), + body, + ) +} + // The single acceptance list for callbacks: collections, sort, string replacers, // Array.from mappers, and promise reactions all admit exactly these callables. // Admission means dispatchable, not necessarily invocable: new-requiring diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 55a98a75b36..587347645f1 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -20,6 +20,7 @@ import { type Value, } from "./objects.js" import type { Interpreter } from "./interpreter.js" +import { toPrimitiveString } from "./callback.js" import { formatValue } from "../stdlib/console.js" export const normalizeError = (error: unknown): Diagnostic => { @@ -139,14 +140,13 @@ const constructAggregateErrorValue = ( proto: Obj, ): Effect.Effect => Effect.gen(function* () { + const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1]) const cursor = yield* ctx.iterate(args[0]) if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.") const errors: Array = [] while (true) { const step = yield* cursor.next - if (step.done) { - return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto) - } + if (step.done) return createAggregateErrorValue(ctx, errors, message, proto) errors.push(step.value) } }) @@ -160,7 +160,9 @@ export const errorGlobal = (type: ErrorType, ctx: Interpreter) => { const created = type === "AggregateError" ? constructAggregateErrorValue(ctx, args, proto) - : Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0]))) + : Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) => + createErrorValue(proto, message), + ) // ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all. const options = args[type === "AggregateError" ? 2 : 1] if (!(options instanceof Obj) || !has(options, "cause")) return created diff --git a/packages/codemode/src/interpreter/interpreter.ts b/packages/codemode/src/interpreter/interpreter.ts index 707745f9cfb..e48bc44d851 100644 --- a/packages/codemode/src/interpreter/interpreter.ts +++ b/packages/codemode/src/interpreter/interpreter.ts @@ -95,7 +95,7 @@ import { coerceToString, type Value, } from "./objects.js" -import { preserveConsumerError } from "./callback.js" +import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js" import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js" import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js" import { ScopeStack } from "./scope.js" @@ -103,6 +103,26 @@ import { constructRegExp } from "../stdlib/regexp.js" import { enumerableSource } from "../stdlib/object.js" import { compoundOperators } from "../stdlib/value.js" +/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */ +const primitiveOperators = new Set([ + "+", + "-", + "*", + "/", + "%", + "**", + "<", + "<=", + ">", + ">=", + "&", + "|", + "^", + "<<", + ">>", + ">>>", +]) + // What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating. // Unlabelled break ends this loop; a label the loop does not carry propagates outward. const loopExit = (result: StatementResult, labels: ReadonlySet | undefined): StatementResult | undefined => { @@ -1368,10 +1388,38 @@ class Frame { const lhs = yield* self.evaluateExpression(left) const rhs = yield* self.evaluateExpression(node.right) if (operator === "instanceof") return instanceofValue(lhs, rhs, node) + if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node) return self.applyBinaryOperator(operator, lhs, rhs, node) }) } + /** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */ + private toPrimitive(value: Value, hint: Hint, node: AstNode) { + return this.native(() => toPrimitive(this.ctx, value, hint), node) + } + + // Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf` + // runs (and throws) in spec order; `+` asks for the default hint and the rest for a number. + private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect { + if (!(lhs instanceof Obj || rhs instanceof Obj)) + return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node)) + // IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool + // references, which are not Obj) compare by identity. + const equality = operator === "==" || operator === "!=" + const other = lhs instanceof Obj ? rhs : lhs + const converts = + primitiveOperators.has(operator) || + (equality && other !== null && other !== undefined && typeof other !== "object") + if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node)) + const hint = operator === "+" || equality ? "default" : "number" + const self = this + return Effect.gen(function* () { + const l = yield* self.toPrimitive(lhs, hint, node) + const r = yield* self.toPrimitive(rhs, hint, node) + return self.applyBinaryOperator(operator, l, r, node) + }) + } + private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value { if (operator === "===") return lhs === rhs if (operator === "!==") return lhs !== rhs @@ -1383,70 +1431,60 @@ class Frame { if (isOpaque(lhs) || isOpaque(rhs)) { throw invalidData("Binary operators require data values.", node) } - // Addition uses the default hint; every other operator asks for a number. - const hint = operator === "+" ? "default" : "number" - const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand) - const l = coerceOperand(lhs) - const r = coerceOperand(rhs) + // Object operands were already converted by applyOperator; only primitives reach the arithmetic below. switch (operator) { case "+": { - const sum = (l as string) + (r as string) + const sum = (lhs as string) + (rhs as string) if (typeof sum === "string") checkStringLength(sum.length) return sum } case "-": - return (l as number) - (r as number) + return (lhs as number) - (rhs as number) case "*": - return (l as number) * (r as number) + return (lhs as number) * (rhs as number) case "/": - return (l as number) / (r as number) + return (lhs as number) / (rhs as number) case "%": - return (l as number) % (r as number) + return (lhs as number) % (rhs as number) case "**": - return (l as number) ** (r as number) + return (lhs as number) ** (rhs as number) case "<": - return (l as string) < (r as string) + return (lhs as string) < (rhs as string) case "<=": - return (l as string) <= (r as string) + return (lhs as string) <= (rhs as string) case ">": - return (l as string) > (r as string) + return (lhs as string) > (rhs as string) case ">=": - return (l as string) >= (r as string) + return (lhs as string) >= (rhs as string) case "&": - return (l as number) & (r as number) + return (lhs as number) & (rhs as number) case "|": - return (l as number) | (r as number) + return (lhs as number) | (rhs as number) case "^": - return (l as number) ^ (r as number) + return (lhs as number) ^ (rhs as number) case "<<": - return (l as number) << (r as number) + return (lhs as number) << (rhs as number) case ">>": - return (l as number) >> (r as number) + return (lhs as number) >> (rhs as number) case ">>>": - return (l as number) >>> (r as number) + return (lhs as number) >>> (rhs as number) case "in": - if (!(rhs instanceof Obj)) { - throw typeError("The 'in' operator requires a data object on the right-hand side.", node) - } - return has(rhs, coerceOperand(lhs) as PropertyKey) + throw typeError("The 'in' operator requires a data object on the right-hand side.", node) default: throw typeError(`Unsupported binary operator '${operator}'.`, node) } } - // IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a - // data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there. + // IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish + // primitive never equals an object. private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean { const lhsObject = lhs !== null && typeof lhs === "object" const rhsObject = rhs !== null && typeof rhs === "object" if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs - const object = lhsObject ? lhs : rhs const primitive = lhsObject ? rhs : lhs if (primitive === null || primitive === undefined) return false - if (!(object instanceof Obj) || isOpaque(object)) { - throw invalidData("Binary operators require data values.", node) - } - return object.toPrimitive("default") == primitive + // Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here. + throw invalidData("Binary operators require data values.", node) } private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect { @@ -1468,14 +1506,16 @@ class Frame { if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) { return Effect.succeed("undefined") } - return Effect.map(this.evaluateExpression(argument), (value) => { + const self = this + return Effect.gen(function* () { + const value = yield* self.evaluateExpression(argument) if (operator === "typeof") return typeofValue(value) if (operator === "!") return !value if (operator === "void") return undefined - if (isOpaque(value)) { + const operand = yield* self.toPrimitive(value, "number", node) + if (isOpaque(operand)) { throw invalidData("Unary operators require data values.", node) } - const operand = value instanceof Obj ? value.toPrimitive("number") : value let result: Value switch (operator) { case "+": @@ -1497,11 +1537,16 @@ class Frame { private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect { const left = node.left const operator = node.operator + // The binary operator a compound assignment applies: `+=` is `+`. + const binary = operator.slice(0, -1) const self = this return Effect.gen(function* () { if (operator === "??=" || operator === "||=" || operator === "&&=") { return yield* self.evaluateLogicalAssignment(node, left, operator) } + if (operator !== "=" && !compoundOperators.has(operator)) { + throw typeError(`Unsupported assignment operator '${operator}'.`, node) + } if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) { const rightValue = yield* self.evaluateExpression(node.right) yield* self.assignPattern(left, rightValue, node) @@ -1512,17 +1557,24 @@ class Frame { if (operator !== "=") { const current = self.scopes.get(name, left) const rightValue = yield* self.evaluateExpression(node.right) - return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left) + const next = + current instanceof Obj || rightValue instanceof Obj + ? yield* self.applyOperator(binary, current, rightValue, node) + : self.applyBinaryOperator(binary, current, rightValue, node) + return self.scopes.set(name, next, left) } const rightValue = yield* self.evaluateNamed(node.right, name) return self.scopes.set(name, rightValue, left) } if (left.type === "MemberExpression") { return yield* self.modifyMember(left, (current) => - Effect.map(self.evaluateExpression(node.right), (rightValue) => { - if (operator === "=") return { write: true, next: rightValue, result: rightValue } - const next = self.applyCompoundAssignment(operator, current, rightValue, node) - return { write: true, next, result: next } + Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => { + if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue }) + return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({ + write: true, + next, + result: next, + })) }), ) } @@ -1572,8 +1624,7 @@ class Frame { throw typeError(`Unsupported update operator '${operator}'.`, node) } - // CodeMode numeric coercion, not host Number(): null-prototype data objects would make - // the host throw during ToPrimitive, and opaque runtime references must reject clearly. + // CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly. const operand = (current: Value): number => { if (isOpaque(current)) { throw invalidData(`'${operator}' requires a data value.`, argument) @@ -1582,21 +1633,26 @@ class Frame { } if (argument.type === "Identifier") { - return Effect.sync(() => { - const name = argument.name - const current = operand(this.scopes.get(name, argument)) - const next = current + increment + const name = argument.name + const current = this.scopes.get(name, argument) + const update = (value: Value) => { + const before = operand(value) + const next = before + increment this.scopes.set(name, next, argument) - return prefix ? next : current - }) + return prefix ? next : before + } + if (!(current instanceof Obj)) return Effect.sync(() => update(current)) + return Effect.map(this.toPrimitive(current, "number", argument), update) } if (argument.type === "MemberExpression") { - return this.modifyMember(argument, (current) => { - const value = operand(current) - const next = value + increment - return Effect.succeed({ write: true, next, result: prefix ? next : value }) - }) + return this.modifyMember(argument, (current) => + Effect.map(this.toPrimitive(current, "number", argument), (primitive) => { + const value = operand(primitive) + const next = value + increment + return { write: true, next, result: prefix ? next : value } + }), + ) } throw typeError("Update target must be an Identifier or MemberExpression.", argument) @@ -2065,7 +2121,7 @@ class Frame { if (index < expressions.length) { const raw = yield* self.evaluateExpression(expressions[index]) - output += coerceToString(raw) + output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index])) checkStringLength(output.length) } } @@ -2115,13 +2171,6 @@ class Frame { ) } - private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value { - if (!compoundOperators.has(operator)) { - throw typeError(`Unsupported assignment operator '${operator}'.`, node) - } - return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) - } - private getMemberReference( node: MemberExpression, ): Effect.Effect { diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index 82ced44ec50..a613de2f779 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -18,7 +18,7 @@ import { type Value, } from "../interpreter/objects.js" import { describeValue, rejectCircularInsertion } from "../interpreter/references.js" -import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js" +import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" import { compareText } from "../tool-runtime.js" @@ -146,20 +146,30 @@ export const arrayGlobal = (ctx: Interpreter) => { "join", 1, (thisValue, args) => { - const joined = self(thisValue, "join") - .items.map((item) => coerceToString(item ?? "")) - .join(args[0] === undefined ? "," : coerceToString(args[0])) - checkStringLength(joined.length) - return joined + // .map would keep holes, which Effect.forEach would then hand to the body as undefined. + const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "") + return withPrimitives( + ctx, + "string", + [args[0] === undefined ? "," : args[0], ...parts], + ([separator, ...items]) => { + const joined = items.map(coerceToString).join(coerceToString(separator)) + checkStringLength(joined.length) + return joined + }, + ) }, ], [ "toString", 0, (thisValue) => - self(thisValue, "toString") - .items.map((item) => coerceToString(item ?? "")) - .join(","), + withPrimitives( + ctx, + "string", + Array.from(self(thisValue, "toString").items, (item) => item ?? ""), + (items) => items.map(coerceToString).join(","), + ), ], [ "includes", diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index 8c1d15de01e..8e779db7732 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import { constants, type Method, methods } from "../interpreter/native.js" import { typeError } from "../interpreter/model.js" import { Obj, coerceToNumber } from "../interpreter/objects.js" -import { preserveConsumerError } from "../interpreter/callback.js" +import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" // Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types. @@ -12,25 +12,27 @@ declare global { } } -// Validate only the arguments a method consumes; like JS, extras are ignored -// (so built-ins work as callbacks receiving (element, index, array)). -const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))] - -const binary = (name: string, op: (a: number, b: number) => number): Method => [ - name, - 2, - (_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])), -] - -const variadic = (name: string, op: (...values: Array) => number): Method => [ - name, - 2, - (_, args) => op(...args.map(coerceToNumber)), -] - export const mathGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins const math = new Obj(builtins.Object) + // Convert only the arguments a method consumes; like JS, extras are ignored + // (so built-ins work as callbacks receiving (element, index, array)). + const unary = (name: string, op: (a: number) => number): Method => [ + name, + 1, + (_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))), + ] + const binary = (name: string, op: (a: number, b: number) => number): Method => [ + name, + 2, + (_, args) => + withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))), + ] + const variadic = (name: string, op: (...values: Array) => number): Method => [ + name, + 2, + (_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))), + ] constants(math, { PI: Math.PI, E: Math.E, diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index 4cb90ce151d..35dda1ac4e4 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,8 +1,8 @@ import { constructor, constants, methods } from "../interpreter/native.js" -import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js" +import { coerceToNumber, type Value } from "../interpreter/objects.js" import { rangeError, typeError } from "../interpreter/model.js" import type { Interpreter } from "../interpreter/interpreter.js" -import { coercion } from "./value.js" +import { coerce, coercion } from "./value.js" export const numberGlobal = (ctx: Interpreter) => { const builtins = ctx.builtins @@ -26,14 +26,8 @@ export const numberGlobal = (ctx: Interpreter) => { ["isFinite", 1, (_, args) => Number.isFinite(args[0])], ["isNaN", 1, (_, args) => Number.isNaN(args[0])], ["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])], - [ - "parseInt", - 2, - (_, args) => { - return parseInt(coerceToString(args[0]), coerceToNumber(args[1])) - }, - ], - ["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))], + ["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)], + ["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)], ]) const self = (thisValue: Value, name: string): number => { diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 0669f0b3350..2f6b77d7486 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -1,12 +1,13 @@ import { fn } from "../interpreter/native.js" import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js" import type { Interpreter } from "../interpreter/interpreter.js" +import { withPrimitives } from "../interpreter/callback.js" export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN" -const coerce = (ctx: Interpreter, name: Coercion, args: Array): Value => { +export const coerce = (ctx: Interpreter, name: Coercion, args: Array) => { // Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the // other coercers match native through the undefined-argument path below. if (args.length === 0) { @@ -14,15 +15,19 @@ const coerce = (ctx: Interpreter, name: Coercion, args: Array): Val if (name === "String") return "" } const raw = args[0] - if (name === "Number") return coerceToNumber(raw) if (name === "Boolean") return Boolean(raw) - if (name === "isFinite") return Number.isFinite(coerceToNumber(raw)) - if (name === "isNaN") return Number.isNaN(coerceToNumber(raw)) if (name === "parseInt") { - return parseInt(coerceToString(raw), coerceToNumber(args[1])) + return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) => + parseInt(coerceToString(text), coerceToNumber(radix)), + ) } - if (name === "parseFloat") return parseFloat(coerceToString(raw)) - return coerceToString(raw) + return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => { + if (name === "Number") return coerceToNumber(value) + if (name === "isFinite") return Number.isFinite(coerceToNumber(value)) + if (name === "isNaN") return Number.isNaN(coerceToNumber(value)) + if (name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) + }) } /** A global coercion function such as `Number` or `parseInt`. */ diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index cd427d299f3..371e3dddbd6 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -1571,3 +1571,120 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => { ) }) }) + +describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => { + test("program-installed valueOf and toString on opaque values are ignored at every site", async () => { + expect( + await value(` + const f = () => 1 + f.toString = () => "custom" + f.valueOf = () => 5 + return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))] + `), + ).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true]) + }) + + test("== converts an object facing a non-nullish primitive through its own valueOf", async () => { + expect( + await value(` + const one = { valueOf() { return 1 } } + return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1] + `), + ).toEqual([true, true, true, true, false, true, false, true]) + expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values") + }) + + test("operators, unary, template literals, and conversion functions use the object's own methods", async () => { + expect( + await value(` + const money = { valueOf() { return 7 } } + return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8, + Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money), + parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }), + Number.parseFloat({ toString() { return "1.5" } })] + `), + ).toEqual([ + 14, + 8, + "7", + -7, + 7, + -8, + true, + 49, + 15, + 7, + 7, + "[object Object]", + "[object Object]", + false, + true, + 42, + 255, + 1.5, + ]) + }) + + test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => { + expect( + await value(` + const both = { valueOf() { return 1 }, toString() { return "s" } } + return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""] + `), + ).toEqual(["1", "s", "s", 2, "s", "s", "s,2"]) + }) + + test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => { + expect( + await value(` + const order = [] + const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } } + a + b; a < b; a - b + return order + `), + ).toEqual(["a", "b", "a", "b", "a", "b"]) + expect( + await value(` + const bad = { valueOf() { throw new RangeError("nope") } } + const names = [] + try { bad + 1 } catch (e) { names.push(e.name) } + try { Number(bad) } catch (e) { names.push(e.name) } + try { Math.abs(bad) } catch (e) { names.push(e.name) } + return names + `), + ).toEqual(["RangeError", "RangeError", "RangeError"]) + }) + + test("arrays keep their built-in join form unless the program replaces toString", async () => { + expect( + await value(` + const arr = [1, 2] + const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")] + arr.toString = () => "x" + return [...before, arr + "", \`\${arr}\`, String(arr)] + `), + ).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"]) + }) + + test("update and compound assignment convert the current value", async () => { + expect( + await value(` + let x = { valueOf() { return 5 } } + const o = { n: { valueOf() { return 4 } } } + const after = x++ + o.n += 1 + o.n++ + let s = { valueOf() { return 2 } } + s *= 3 + return [after, x, o.n, s] + `), + ).toEqual([5, 6, 6, 6]) + }) + + test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => { + expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values") + expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values") + const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`) + expect(failure.message).toContain("Cannot convert object to primitive value") + }) +}) diff --git a/packages/codemode/test/test262/manifest.json b/packages/codemode/test/test262/manifest.json index 7938c625b5b..e2e430410e0 100644 --- a/packages/codemode/test/test262/manifest.json +++ b/packages/codemode/test/test262/manifest.json @@ -14,10 +14,14 @@ "built-ins/Object/isSealed", "built-ins/Object/preventExtensions", "built-ins/String/raw", + "built-ins/parseFloat", + "built-ins/parseInt", "language/arguments-object", "language/expressions/does-not-equals", "language/expressions/equals", + "language/expressions/exponentiation", "language/expressions/tagged-template", + "language/expressions/template-literal", "language/expressions/this", "language/statements" ], diff --git a/packages/codemode/test/test262/skipped.txt b/packages/codemode/test/test262/skipped.txt index f696a394382..769fcad561d 100644 --- a/packages/codemode/test/test262/skipped.txt +++ b/packages/codemode/test/test262/skipped.txt @@ -365,8 +365,6 @@ built-ins/Array/prototype/indexOf/call-with-boolean.js # TypeError: Array.proto built-ins/Array/prototype/indexOf/length-near-integer-limit.js # TypeError: Array.prototype.indexOf called on incompatible receiver a data object. built-ins/Array/prototype/join/S15.4.4.5_A2_T1.js # Array.prototype.join called on incompatible receiver a data object. built-ins/Array/prototype/join/S15.4.4.5_A2_T4.js # Array.prototype.join called on incompatible receiver a data object. -built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero arguments or one string separator. -built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true built-ins/Array/prototype/join/S15.4.4.5_A4_T3.js # Array.prototype.join called on incompatible receiver a data object. built-ins/Array/prototype/join/S15.4.4.5_A5_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.join() === "0,1". Actual: 0, built-ins/Array/prototype/join/call-with-boolean.js # TypeError: Array.prototype.join called on incompatible receiver a boolean. @@ -833,7 +831,6 @@ built-ins/Array/prototype/some/15.4.4.17-8-7.js # … cannot be constructed: us built-ins/Array/prototype/some/15.4.4.17-8-8.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct built-ins/Array/prototype/some/call-with-boolean.js # TypeError: Array.prototype.some called on incompatible receiver a boolean. built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T3.js # #1: Check ToString operator -built-ins/Array/prototype/sort/S15.4.4.11_A2.2_T3.js # #1: Check ToString operator built-ins/Array/prototype/sort/S15.4.4.11_A3_T1.js # Array.prototype.sort called on incompatible receiver a data object. built-ins/Array/prototype/sort/S15.4.4.11_A3_T2.js # Array.prototype.sort called on incompatible receiver a data object. built-ins/Array/prototype/sort/S15.4.4.11_A4_T3.js # Array.prototype.sort called on incompatible receiver a data object. @@ -873,7 +870,6 @@ built-ins/Array/prototype/toSpliced/length-clamped-to-2pow53minus1.js # TypeErr built-ins/Array/prototype/toSpliced/length-exceeding-array-length-limit.js # TypeError: Only init object properties are supported. built-ins/Array/prototype/toSpliced/length-tolength.js # TypeError: Array.prototype.toSpliced called on incompatible receiver a data object. built-ins/Array/prototype/toSpliced/this-value-boolean.js # TypeError: Array.prototype.toSpliced called on incompatible receiver a boolean. -built-ins/Array/prototype/toString/S15.4.4.2_A1_T4.js # Expected a Test262Error to be thrown but no exception was thrown at all built-ins/Array/prototype/toString/S15.4.4.2_A3_T1.js # #1: Array.prototype[1] = 1; x = [0]; x.length = 2; x.toString() === "0,1". Actual: 0, built-ins/Array/prototype/toString/call-with-boolean.js # TypeError: Array.prototype.toString called on incompatible receiver a boolean. built-ins/Array/prototype/unshift/S15.4.4.13_A2_T1.js # Array.prototype.unshift called on incompatible receiver a data object. @@ -906,11 +902,6 @@ built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262 built-ins/Iterator/prototype/take/limit-tonumber.js # Iterator.prototype.take expects a non-negative count, received NaN. built-ins/Object/is/not-same-value-x-y-object.js # Object(number) wrapper objects are not supported; use the primitive value directly. built-ins/Object/is/same-value-x-y-object.js # Object(number) wrapper objects are not supported; use the primitive value directly. -language/expressions/does-not-equals/S11.9.2_A7.8.js # #1: (true != {valueOf: function() {return 1}}) === false -language/expressions/does-not-equals/S11.9.2_A7.9.js # #1: ({valueOf: function() {return 1}} != true) === false -language/expressions/equals/S11.9.1_A7.8.js # #1: ({valueOf: function() {return 1}} == true) === true -language/expressions/equals/S11.9.1_A7.9.js # #1: (true == {valueOf: function() {return 1}}) === true -language/expressions/equals/S9.1_A1_T3.js # #1: var object = {valueOf: function() {return 1}, toString: function() {return 0}}; object + "" === built-ins/Object/freeze/throws-when-false.js # Unknown identifier 'Proxy'. built-ins/Object/getPrototypeOf/15.2.3.2-2-2.js # … cannot be constructed: user-defined constructors and classes are not supported. built-ins/Object/isExtensible/15.2.3.13-2-24.js # … cannot be constructed: user-defined constructors and classes are not supported. @@ -1104,3 +1095,5 @@ built-ins/Array/prototype/some/15.4.4.17-1-15.js # TypeError: Array.prototype.s built-ins/Array/prototype/some/15.4.4.17-7-c-i-25.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments. built-ins/Array/prototype/some/15.4.4.17-7-c-i-26.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments. built-ins/Array/prototype/some/15.4.4.17-7-c-i-27.js # TypeError: Array.prototype.some called on incompatible receiver an Arguments. +built-ins/parseFloat/S15.1.2.3_A7.5.js # TypeError: Cannot read properties of undefined (reading '…'). +built-ins/parseInt/S15.1.2.2_A9.5.js # TypeError: Cannot read properties of undefined (reading '…'). diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 0c26d29d5ec..13d9869af6d 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -354,3 +354,17 @@ describe("tools.search alias", () => { expect(await value(runtime, `return await tools.search({})`)).toBe("custom") }) }) + +describe("tool references under ==", () => { + test("compare by identity against data objects without converting them", async () => { + const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } }) + expect( + await value( + runtime, + `let calls = 0 + const o = { valueOf() { calls++; return 1 } } + return [o == tools.probe, tools == { a: 1 }, tools.probe == null, calls]`, + ), + ).toEqual([false, false, false, 0]) + }) +})