diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 600d2b5b2a9f..9683944ded78 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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 diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 21d66ea3c54c..6eb592e7deb6 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -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, 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 +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, node: AstNode): unknown => { + // 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, 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, 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, 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, n return boundedData(result, `String.${name} result`) } +export const arrayStatics = new Set(["isArray", "of", "from"]) + const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { switch (name) { case "isArray": @@ -400,11 +418,9 @@ const invokeStringReplacer = ( 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* () { diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index d9a2b25ef359..a70bdbe0a9d8 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -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 { diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 332389d966af..5bf574014cfa 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -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>> = { + 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 { 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 { 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 { 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 { 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 { } 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)[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 { 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)[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) { diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index 91c89d37a60b..6ca1009c89cf 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -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, node: AstNode): number => { switch (name) { case "now": diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index d65da7fe20e8..6b96b2672924 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -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, node: AstNode): unknown => { switch (name) { case "stringify": { diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index a47370e4c87b..361c0aad9651 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -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, node: AstNode): unknown => { const requireObject = (): Record => { const input = args[0] diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts index 29281c2371db..84c0d0425f51 100644 --- a/packages/codemode/src/stdlib/regexp.ts +++ b/packages/codemode/src/stdlib/regexp.ts @@ -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 { diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 2b5ff00f4845..68817ea7d73b 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -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, 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, 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") { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 7ab0fbf34875..78f61b6b75bf 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -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") + }) +})