feat(codemode): native coercion parity in interpreter (#37608)

This commit is contained in:
Aiden Cline
2026-07-18 12:01:10 -05:00
committed by GitHub
parent 7d4496eafc
commit 5f437a09b0
10 changed files with 323 additions and 48 deletions
+16 -10
View File
@@ -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
+37 -21
View File
@@ -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* () {
+1 -1
View File
@@ -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 {
+73 -15
View File
@@ -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) {
+2
View File
@@ -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
View File
@@ -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": {
+2
View File
@@ -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]
+2
View File
@@ -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 {
+14 -1
View File
@@ -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") {
+174
View File
@@ -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")
})
})