mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
feat(codemode): native coercion parity in interpreter (#37608)
This commit is contained in:
@@ -80,7 +80,7 @@ ultimate source of truth.
|
||||
- [x] Expression and block function bodies.
|
||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
||||
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
|
||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
||||
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
||||
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
||||
@@ -210,9 +210,12 @@ ultimate source of truth.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
|
||||
reject instead of coercing.
|
||||
- [ ] Native no-argument parity for `match()` and `search()`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||
`repeat` still requires a finite non-negative count.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
|
||||
arguments must still be a regular expression or string pattern.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
@@ -226,13 +229,16 @@ ultimate source of truth.
|
||||
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
||||
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
|
||||
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
||||
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
|
||||
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
|
||||
`Number(...)` directly.
|
||||
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
|
||||
during property access.
|
||||
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
|
||||
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
|
||||
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
|
||||
use their epoch time) and reject opaque runtime references as data errors.
|
||||
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||
example `Math.sumPrecise is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
||||
and unknown `Promise` statics keep their descriptive error.
|
||||
- [ ] `Math.sumPrecise`.
|
||||
- [ ] Global coercing `isFinite` and `isNaN`.
|
||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||
|
||||
## JSON and console
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PromiseNamespace,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
@@ -137,21 +137,31 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
||||
if (containsOpaqueReference(arg)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} expects argument ${index + 1} to be a data value.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
// Coerce arguments like native JS; opaque runtime references still reject.
|
||||
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
|
||||
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
const rejectRegex = (): void => {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
@@ -187,8 +197,11 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
if (args[0] === undefined) {
|
||||
const requestedLimit = optNum(1)
|
||||
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
@@ -203,12 +216,15 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
rejectRegex()
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
rejectRegex()
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
rejectRegex()
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
@@ -263,7 +279,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
@@ -301,6 +317,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
export const arrayStatics = new Set(["isArray", "of", "from"])
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
@@ -400,11 +418,9 @@ const invokeStringReplacer = <R>(
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
if (typeof pattern !== "string") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern, collect)
|
||||
else value.replaceAll(pattern, collect)
|
||||
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
|
||||
if (name === "replace") value.replace(search, collect)
|
||||
else value.replaceAll(search, collect)
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class GlobalMethodReference {
|
||||
}
|
||||
|
||||
export class CoercionFunction {
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||
}
|
||||
|
||||
export class UriFunction {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
type GlobalNamespaceName,
|
||||
getArray,
|
||||
getBoolean,
|
||||
getNode,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
||||
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||
import {
|
||||
constructPromise,
|
||||
invokePromiseInstanceMethod,
|
||||
@@ -46,10 +47,11 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
|
||||
import { ScopeStack } from "./scope.js"
|
||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||
import { dateMethods } from "../stdlib/date.js"
|
||||
import { mathConstants } from "../stdlib/math.js"
|
||||
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
||||
import { jsonStatics } from "../stdlib/json.js"
|
||||
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
||||
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||
import { promiseStatics } from "../stdlib/promise.js"
|
||||
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
||||
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
urlMethods,
|
||||
urlProperties,
|
||||
urlSearchParamsMethods,
|
||||
urlStatics,
|
||||
urlWritableProperties,
|
||||
invokeUriFunction,
|
||||
uriArgument,
|
||||
@@ -83,6 +86,32 @@ import {
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||
Object: objectStatics,
|
||||
Math: mathMethods,
|
||||
JSON: jsonStatics,
|
||||
Array: arrayStatics,
|
||||
console: consoleMethods,
|
||||
Date: dateStatics,
|
||||
URL: urlStatics,
|
||||
}
|
||||
|
||||
const calleeDescription = (callee: AstNode): string => {
|
||||
if (callee.type === "Identifier") return getString(callee, "name")
|
||||
if (callee.type === "MemberExpression") {
|
||||
const object = getNode(callee, "object")
|
||||
const property = getNode(callee, "property")
|
||||
const key =
|
||||
callee.computed !== true && property.type === "Identifier"
|
||||
? getString(property, "name")
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (object.type === "Identifier" && key !== undefined) return `${getString(object, "name")}.${key}`
|
||||
}
|
||||
return "The called value"
|
||||
}
|
||||
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
if (rhs instanceof ErrorConstructorReference) {
|
||||
const brand = errorBrandName(lhs)
|
||||
@@ -199,6 +228,8 @@ export class Interpreter<R> {
|
||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||
globalScope.set("isFinite", { mutable: false, value: new CoercionFunction("isFinite") })
|
||||
globalScope.set("isNaN", { mutable: false, value: new CoercionFunction("isNaN") })
|
||||
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||
@@ -1454,10 +1485,23 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError(`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.
|
||||
const operand = (current: unknown): number => {
|
||||
if (containsOpaqueReference(current)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`'${operator}' requires a data value in CodeMode.`,
|
||||
argument,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
return coerceToNumber(current)
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = getString(argument, "name")
|
||||
const current = Number(this.scopes.get(name, argument))
|
||||
const current = operand(this.scopes.get(name, argument))
|
||||
const next = current + increment
|
||||
this.scopes.set(name, next, argument)
|
||||
return prefix ? next : current
|
||||
@@ -1466,7 +1510,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = Number(current)
|
||||
const value = operand(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
@@ -1563,6 +1607,9 @@ export class Interpreter<R> {
|
||||
callable.settle(args[0])
|
||||
return undefined
|
||||
}
|
||||
if (callable === undefined || callable === null) {
|
||||
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
}
|
||||
@@ -1833,16 +1880,18 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
||||
propertyNode,
|
||||
)
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||
}
|
||||
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
}
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
// Unknown static members read as undefined so feature detection works like native JS.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
@@ -1858,12 +1907,21 @@ export class Interpreter<R> {
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
||||
if (objectValue instanceof CoercionFunction) {
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||
}
|
||||
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) {
|
||||
return new GlobalMethodReference("Number", key)
|
||||
}
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) {
|
||||
return new GlobalMethodReference("String", key)
|
||||
}
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
|
||||
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
|
||||
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now":
|
||||
|
||||
@@ -2,6 +2,8 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
|
||||
import { typeofValue } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["parse", "stringify"])
|
||||
|
||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
|
||||
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
|
||||
|
||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
|
||||
@@ -19,6 +19,8 @@ export const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
// Native parity: an undefined pattern behaves as an empty pattern.
|
||||
if (arg === undefined) return new RegExp("", extraFlags)
|
||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
|
||||
@@ -61,10 +61,19 @@ export const coerceToString = (value: unknown): string => {
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof CodeModeDate) return value.time
|
||||
if (isCodeModeValue(value)) return Number.NaN
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
// Arrays coerce through our own string coercion: host Number(array) joins with host
|
||||
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
|
||||
if (Array.isArray(value)) return Number(coerceToString(value))
|
||||
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
// 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) {
|
||||
if (ref.name === "Number") return 0
|
||||
if (ref.name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||
@@ -72,12 +81,16 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(raw, `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
|
||||
@@ -669,3 +669,177 @@ describe("destructuring assignment", () => {
|
||||
expect(err.message).toContain("Property key must be a string or number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: zero-argument coercion functions", () => {
|
||||
test("Number() is 0 and String() is empty, unlike their undefined-argument forms", async () => {
|
||||
expect(await value(`return Number()`)).toBe(0)
|
||||
expect(await value(`return String()`)).toBe("")
|
||||
expect(await value(`return Boolean()`)).toBe(false)
|
||||
expect(await value(`return Number.isNaN(Number(undefined))`)).toBe(true)
|
||||
expect(await value(`return String(undefined)`)).toBe("undefined")
|
||||
})
|
||||
|
||||
test("parseInt() and parseFloat() stay NaN with no argument", async () => {
|
||||
expect(await value(`return Number.isNaN(parseInt())`)).toBe(true)
|
||||
expect(await value(`return Number.isNaN(parseFloat())`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: global isFinite and isNaN", () => {
|
||||
test("coerce their argument like native JS, unlike the Number statics", async () => {
|
||||
expect(await value(`return isFinite("42")`)).toBe(true)
|
||||
expect(await value(`return Number.isFinite("42")`)).toBe(false)
|
||||
expect(await value(`return isNaN("oops")`)).toBe(true)
|
||||
expect(await value(`return isNaN("42")`)).toBe(false)
|
||||
expect(await value(`return isFinite(Infinity)`)).toBe(false)
|
||||
expect(await value(`return isNaN(null)`)).toBe(false)
|
||||
})
|
||||
|
||||
test("zero-argument forms match native", async () => {
|
||||
expect(await value(`return isFinite()`)).toBe(false)
|
||||
expect(await value(`return isNaN()`)).toBe(true)
|
||||
})
|
||||
|
||||
test("read as functions", async () => {
|
||||
expect(await value(`return typeof isFinite`)).toBe("function")
|
||||
expect(await value(`return typeof isNaN`)).toBe("function")
|
||||
})
|
||||
|
||||
test("work as array callbacks", async () => {
|
||||
expect(await value(`return [1, "2", "x", Infinity].filter(isFinite)`)).toEqual([1, "2"])
|
||||
expect(await value(`return ["1", "x"].map(isNaN)`)).toEqual([false, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: arrays coerce to numbers through their string form", () => {
|
||||
test("arrays with objects become NaN instead of crashing on host ToPrimitive", async () => {
|
||||
expect(await value(`let x = [{}]; x++; return Number.isNaN(x)`)).toBe(true)
|
||||
expect(await value(`return isFinite([{}])`)).toBe(false)
|
||||
expect(await value(`return "abc".slice([{}])`)).toBe("abc")
|
||||
})
|
||||
|
||||
test("single-element and empty arrays match native Number()", async () => {
|
||||
expect(await value(`return Number([5])`)).toBe(5)
|
||||
expect(await value(`return Number([])`)).toBe(0)
|
||||
expect(await value(`return Number.isNaN(Number([1, 2]))`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: String method arguments coerce like native JS", () => {
|
||||
test("includes and indexOf coerce numbers", async () => {
|
||||
expect(await value(`return "v1.2".includes(1)`)).toBe(true)
|
||||
expect(await value(`return "a2b".indexOf(2)`)).toBe(1)
|
||||
expect(await value(`return "abc".includes("d")`)).toBe(false)
|
||||
})
|
||||
|
||||
test("slice, repeat, and padStart coerce numeric strings", async () => {
|
||||
expect(await value(`return "abc".slice("1")`)).toBe("bc")
|
||||
expect(await value(`return "ab".repeat("2")`)).toBe("abab")
|
||||
expect(await value(`return "7".padStart("3", 0)`)).toBe("007")
|
||||
})
|
||||
|
||||
test("split coerces separators but treats undefined as absent", async () => {
|
||||
expect(await value(`return "a1b".split(1)`)).toEqual(["a", "b"])
|
||||
expect(await value(`return "a,b".split(undefined)`)).toEqual(["a,b"])
|
||||
expect(await value(`return "a,b".split()`)).toEqual(["a,b"])
|
||||
expect(await value(`return "a,b".split(undefined, 0)`)).toEqual([])
|
||||
expect(await value(`return "a,b".split(undefined, 1)`)).toEqual(["a,b"])
|
||||
})
|
||||
|
||||
test("replace coerces search and replacement values", async () => {
|
||||
expect(await value(`return "a1b".replace(1, 2)`)).toBe("a2b")
|
||||
expect(await value(`return "a1b".replace(1, () => "x")`)).toBe("axb")
|
||||
})
|
||||
|
||||
test("repeat rejections carry the native RangeError name", async () => {
|
||||
expect(await value(`try { "a".repeat(-1) } catch (e) { return e.name }`)).toBe("RangeError")
|
||||
})
|
||||
|
||||
test("includes, startsWith, and endsWith reject regular expressions with a TypeError", async () => {
|
||||
expect(await value(`try { "abc".includes(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
expect(await value(`try { "abc".startsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
expect(await value(`try { "abc".endsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("opaque runtime references still reject as data errors", async () => {
|
||||
const err = await error(`const f = () => 1; return "abc".includes(f)`)
|
||||
expect(err.message).toContain("data value")
|
||||
const replacerErr = await error(`const f = () => 1; return "a".replace(f, () => "x")`)
|
||||
expect(replacerErr.message).toContain("data value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: match() and search() with no argument", () => {
|
||||
test("behave as an empty pattern like native JS", async () => {
|
||||
expect(await value(`return "abc".search()`)).toBe(0)
|
||||
expect(await value(`const m = "abc".match(); return { first: m[0], index: m.index }`)).toEqual({
|
||||
first: "",
|
||||
index: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
|
||||
test("numeric strings increment like native JS", async () => {
|
||||
expect(await value(`let x = "5"; x++; return x`)).toBe(6)
|
||||
expect(await value(`let x = "5"; return ++x`)).toBe(6)
|
||||
expect(await value(`const o = { n: "2" }; o.n--; return o.n`)).toBe(1)
|
||||
})
|
||||
|
||||
test("dates increment through their epoch time", async () => {
|
||||
expect(await value(`let d = new Date(5); d++; return d`)).toBe(6)
|
||||
})
|
||||
|
||||
test("plain data objects become NaN instead of crashing", async () => {
|
||||
expect(await value(`let x = {}; x++; return Number.isNaN(x)`)).toBe(true)
|
||||
expect(await value(`const o = { a: {} }; o.a++; return Number.isNaN(o.a)`)).toBe(true)
|
||||
})
|
||||
|
||||
test("opaque runtime references reject with a clear error", async () => {
|
||||
const err = await error(`let f = () => 1; f++`)
|
||||
expect(err.message).toContain("data value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: unknown static members read as undefined", () => {
|
||||
test("feature detection on missing statics works like native JS", async () => {
|
||||
expect(await value(`return typeof Math.sumPrecise`)).toBe("undefined")
|
||||
expect(await value(`return Object.groupBy === undefined`)).toBe(true)
|
||||
expect(await value(`return RegExp.escape === undefined`)).toBe(true)
|
||||
expect(await value(`return Number.range === undefined`)).toBe(true)
|
||||
expect(await value(`return String.raw === undefined`)).toBe(true)
|
||||
expect(await value(`return isFinite.something === undefined`)).toBe(true)
|
||||
expect(await value(`return console.group === undefined`)).toBe(true)
|
||||
expect(await value(`return Date.moment === undefined`)).toBe(true)
|
||||
expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
|
||||
expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
|
||||
expect(await value(`return Map.groupBy === undefined`)).toBe(true)
|
||||
expect(await value(`return Math.sumPrecise?.([1]) ?? "fallback"`)).toBe("fallback")
|
||||
})
|
||||
|
||||
test("known statics still resolve and run", async () => {
|
||||
expect(await value(`return typeof Math.max`)).toBe("function")
|
||||
expect(await value(`return typeof console.log`)).toBe("function")
|
||||
expect(await value(`return typeof Date.now`)).toBe("function")
|
||||
expect(await value(`return Math.max(1, 2)`)).toBe(2)
|
||||
expect(await value(`return URL.canParse("https://example.com")`)).toBe(true)
|
||||
expect(await value(`return Number.isInteger(3)`)).toBe(true)
|
||||
expect(await value(`return Number.MAX_SAFE_INTEGER`)).toBe(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
test("calling an unknown static reports a native-style TypeError", async () => {
|
||||
expect(await value(`try { Math.sumPrecise([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
|
||||
"TypeError: Math.sumPrecise is not a function.",
|
||||
)
|
||||
expect(await value(`try { Math["sumPrecise"]([1]) } catch (e) { return e.message }`)).toBe(
|
||||
"Math.sumPrecise is not a function.",
|
||||
)
|
||||
})
|
||||
|
||||
test("blocked members still throw instead of reading as undefined", async () => {
|
||||
const err = await error(`return Math.constructor`)
|
||||
expect(err.message).toContain("not available")
|
||||
const coercionErr = await error(`return Number.constructor`)
|
||||
expect(coercionErr.message).toContain("Number.constructor is not available")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user