Skip to content

Commit 5f437a0

Browse files
authored
feat(codemode): native coercion parity in interpreter (#37608)
1 parent 7d4496e commit 5f437a0

10 files changed

Lines changed: 323 additions & 48 deletions

File tree

packages/codemode/interpreter-support.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ ultimate source of truth.
8080
- [x] Expression and block function bodies.
8181
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
8282
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
83-
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
83+
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
8484
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
8585
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
8686
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.
210210
- [x] `localeCompare`; locale and options arguments are currently ignored.
211211
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
212212
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
213-
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
214-
reject instead of coercing.
215-
- [ ] Native no-argument parity for `match()` and `search()`.
213+
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
214+
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
215+
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
216+
`repeat` still requires a finite non-negative count.
217+
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
218+
arguments must still be a regular expression or string pattern.
216219

217220
## Numbers and Math
218221

@@ -226,13 +229,16 @@ ultimate source of truth.
226229
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
227230
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
228231
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
229-
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
230-
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
231-
`Number(...)` directly.
232-
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
233-
during property access.
232+
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
233+
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
234+
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
235+
use their epoch time) and reject opaque runtime references as data errors.
236+
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
237+
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
238+
example `Math.sumPrecise is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
239+
and unknown `Promise` statics keep their descriptive error.
234240
- [ ] `Math.sumPrecise`.
235-
- [ ] Global coercing `isFinite` and `isNaN`.
241+
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
236242

237243
## JSON and console
238244

packages/codemode/src/interpreter/methods.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
PromiseNamespace,
1313
UriFunction,
1414
} from "./model.js"
15-
import { rejectCircularInsertion, typeofValue } from "./references.js"
15+
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
1616
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
1717
import {
1818
CodeModeDate,
@@ -137,21 +137,31 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
137137
return invokeJsonMethod(ref.name, args, node)
138138
}
139139

140-
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
141-
const str = (index: number): string => {
142-
const arg = args[index]
143-
if (typeof arg !== "string")
144-
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
145-
return arg
146-
}
147-
const num = (index: number): number => {
148-
const arg = args[index]
149-
if (typeof arg !== "number")
150-
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
151-
return arg
140+
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
141+
if (containsOpaqueReference(arg)) {
142+
throw new InterpreterRuntimeError(
143+
`String.${name} expects argument ${index + 1} to be a data value.`,
144+
node,
145+
"InvalidDataValue",
146+
)
152147
}
148+
return arg
149+
}
150+
151+
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
152+
// Coerce arguments like native JS; opaque runtime references still reject.
153+
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
154+
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
153155
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
154156
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
157+
const rejectRegex = (): void => {
158+
if (args[0] instanceof CodeModeRegExp) {
159+
throw new InterpreterRuntimeError(
160+
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
161+
node,
162+
).as("TypeError")
163+
}
164+
}
155165

156166
let result: unknown
157167
switch (name) {
@@ -187,8 +197,11 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
187197
break
188198
}
189199
case "split": {
190-
if (args.length === 0) {
191-
result = [value]
200+
// Native: an undefined separator returns the whole string, not a split on "undefined",
201+
// unless the limit truncates to zero.
202+
if (args[0] === undefined) {
203+
const requestedLimit = optNum(1)
204+
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
192205
break
193206
}
194207
if (args[0] instanceof CodeModeRegExp) {
@@ -203,12 +216,15 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
203216
result = value.slice(optNum(0), optNum(1))
204217
break
205218
case "includes":
219+
rejectRegex()
206220
result = value.includes(str(0), optNum(1))
207221
break
208222
case "startsWith":
223+
rejectRegex()
209224
result = value.startsWith(str(0), optNum(1))
210225
break
211226
case "endsWith":
227+
rejectRegex()
212228
result = value.endsWith(str(0), optNum(1))
213229
break
214230
case "indexOf":
@@ -263,7 +279,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
263279
case "repeat": {
264280
const count = num(0)
265281
if (!Number.isFinite(count) || count < 0)
266-
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
282+
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
267283
result = value.repeat(count)
268284
break
269285
}
@@ -301,6 +317,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
301317
return boundedData(result, `String.${name} result`)
302318
}
303319

320+
export const arrayStatics = new Set(["isArray", "of", "from"])
321+
304322
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
305323
switch (name) {
306324
case "isArray":
@@ -400,11 +418,9 @@ const invokeStringReplacer = <R>(
400418
if (name === "replace") value.replace(pattern.regex, collect)
401419
else value.replaceAll(pattern.regex, collect)
402420
} else {
403-
if (typeof pattern !== "string") {
404-
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
405-
}
406-
if (name === "replace") value.replace(pattern, collect)
407-
else value.replaceAll(pattern, collect)
421+
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
422+
if (name === "replace") value.replace(search, collect)
423+
else value.replaceAll(search, collect)
408424
}
409425

410426
return Effect.gen(function* () {

packages/codemode/src/interpreter/model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export class GlobalMethodReference {
105105
}
106106

107107
export class CoercionFunction {
108-
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
108+
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
109109
}
110110

111111
export class UriFunction {

packages/codemode/src/interpreter/runtime.ts

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ErrorConstructorReference,
1111
GlobalMethodReference,
1212
GlobalNamespace,
13+
type GlobalNamespaceName,
1314
getArray,
1415
getBoolean,
1516
getNode,
@@ -34,7 +35,7 @@ import {
3435
UriFunction,
3536
} from "./model.js"
3637
import { caughtErrorValue, constructErrorValue } from "./errors.js"
37-
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
38+
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
3839
import {
3940
constructPromise,
4041
invokePromiseInstanceMethod,
@@ -46,17 +47,19 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
4647
import { ScopeStack } from "./scope.js"
4748
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
4849
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
49-
import { dateMethods } from "../stdlib/date.js"
50-
import { mathConstants } from "../stdlib/math.js"
50+
import { dateMethods, dateStatics } from "../stdlib/date.js"
51+
import { jsonStatics } from "../stdlib/json.js"
52+
import { mathConstants, mathMethods } from "../stdlib/math.js"
5153
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
52-
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
54+
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
5355
import { promiseStatics } from "../stdlib/promise.js"
5456
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
5557
import { stringMethods, stringStatics } from "../stdlib/string.js"
5658
import {
5759
urlMethods,
5860
urlProperties,
5961
urlSearchParamsMethods,
62+
urlStatics,
6063
urlWritableProperties,
6164
invokeUriFunction,
6265
uriArgument,
@@ -83,6 +86,32 @@ import {
8386
CodeModeURLSearchParams,
8487
} from "../values.js"
8588

89+
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
90+
Object: objectStatics,
91+
Math: mathMethods,
92+
JSON: jsonStatics,
93+
Array: arrayStatics,
94+
console: consoleMethods,
95+
Date: dateStatics,
96+
URL: urlStatics,
97+
}
98+
99+
const calleeDescription = (callee: AstNode): string => {
100+
if (callee.type === "Identifier") return getString(callee, "name")
101+
if (callee.type === "MemberExpression") {
102+
const object = getNode(callee, "object")
103+
const property = getNode(callee, "property")
104+
const key =
105+
callee.computed !== true && property.type === "Identifier"
106+
? getString(property, "name")
107+
: property.type === "Literal" && typeof property.value === "string"
108+
? property.value
109+
: undefined
110+
if (object.type === "Identifier" && key !== undefined) return `${getString(object, "name")}.${key}`
111+
}
112+
return "The called value"
113+
}
114+
86115
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
87116
if (rhs instanceof ErrorConstructorReference) {
88117
const brand = errorBrandName(lhs)
@@ -199,6 +228,8 @@ export class Interpreter<R> {
199228
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
200229
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
201230
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
231+
globalScope.set("isFinite", { mutable: false, value: new CoercionFunction("isFinite") })
232+
globalScope.set("isNaN", { mutable: false, value: new CoercionFunction("isNaN") })
202233
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
203234
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
204235
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
@@ -1454,10 +1485,23 @@ export class Interpreter<R> {
14541485
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
14551486
}
14561487

1488+
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
1489+
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
1490+
const operand = (current: unknown): number => {
1491+
if (containsOpaqueReference(current)) {
1492+
throw new InterpreterRuntimeError(
1493+
`'${operator}' requires a data value in CodeMode.`,
1494+
argument,
1495+
"InvalidDataValue",
1496+
)
1497+
}
1498+
return coerceToNumber(current)
1499+
}
1500+
14571501
if (argument.type === "Identifier") {
14581502
return Effect.sync(() => {
14591503
const name = getString(argument, "name")
1460-
const current = Number(this.scopes.get(name, argument))
1504+
const current = operand(this.scopes.get(name, argument))
14611505
const next = current + increment
14621506
this.scopes.set(name, next, argument)
14631507
return prefix ? next : current
@@ -1466,7 +1510,7 @@ export class Interpreter<R> {
14661510

14671511
if (argument.type === "MemberExpression") {
14681512
return this.modifyMember(argument, (current) => {
1469-
const value = Number(current)
1513+
const value = operand(current)
14701514
const next = value + increment
14711515
return Effect.succeed({ write: true, next, result: prefix ? next : value })
14721516
})
@@ -1563,6 +1607,9 @@ export class Interpreter<R> {
15631607
callable.settle(args[0])
15641608
return undefined
15651609
}
1610+
if (callable === undefined || callable === null) {
1611+
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
1612+
}
15661613
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
15671614
})
15681615
}
@@ -1833,16 +1880,18 @@ export class Interpreter<R> {
18331880
}
18341881

18351882
if (objectValue instanceof GlobalNamespace) {
1836-
if (typeof key !== "string" || isBlockedMember(key)) {
1837-
throw new InterpreterRuntimeError(
1838-
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
1839-
propertyNode,
1840-
)
1883+
if (typeof key === "string" && isBlockedMember(key)) {
1884+
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
18411885
}
1886+
if (typeof key !== "string") return new ComputedValue(undefined)
18421887
if (objectValue.name === "Math" && mathConstants.has(key)) {
18431888
return new ComputedValue((Math as unknown as Record<string, number>)[key])
18441889
}
1845-
return new GlobalMethodReference(objectValue.name, key)
1890+
if (globalStaticMembers[objectValue.name]?.has(key)) {
1891+
return new GlobalMethodReference(objectValue.name, key)
1892+
}
1893+
// Unknown static members read as undefined so feature detection works like native JS.
1894+
return new ComputedValue(undefined)
18461895
}
18471896

18481897
if (typeof objectValue === "string") {
@@ -1858,12 +1907,21 @@ export class Interpreter<R> {
18581907
return new ComputedValue(undefined)
18591908
}
18601909

1861-
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
1910+
if (objectValue instanceof CoercionFunction) {
1911+
if (typeof key === "string" && isBlockedMember(key)) {
1912+
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
1913+
}
1914+
if (typeof key !== "string") return new ComputedValue(undefined)
18621915
if (objectValue.name === "Number" && numberConstants.has(key)) {
18631916
return new ComputedValue((Number as unknown as Record<string, number>)[key])
18641917
}
1865-
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
1866-
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
1918+
if (objectValue.name === "Number" && numberStatics.has(key)) {
1919+
return new GlobalMethodReference("Number", key)
1920+
}
1921+
if (objectValue.name === "String" && stringStatics.has(key)) {
1922+
return new GlobalMethodReference("String", key)
1923+
}
1924+
return new ComputedValue(undefined)
18671925
}
18681926

18691927
if (objectValue instanceof CodeModeDate) {

packages/codemode/src/stdlib/date.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
2323
"getTimezoneOffset",
2424
])
2525

26+
export const dateStatics = new Set(["now", "parse", "UTC"])
27+
2628
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
2729
switch (name) {
2830
case "now":

packages/codemode/src/stdlib/json.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
22
import { typeofValue } from "../interpreter/references.js"
33
import { copyIn, copyOut } from "../tool-runtime.js"
44

5+
export const jsonStatics = new Set(["parse", "stringify"])
6+
57
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
68
switch (name) {
79
case "stringify": {

packages/codemode/src/stdlib/object.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { boundedData, coerceToString } from "./value.js"
66

77
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
88

9+
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
10+
911
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
1012
const requireObject = (): Record<string, unknown> => {
1113
const input = args[0]

packages/codemode/src/stdlib/regexp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const escapeRegexHint =
1919
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
2020

2121
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
22+
// Native parity: an undefined pattern behaves as an empty pattern.
23+
if (arg === undefined) return new RegExp("", extraFlags)
2224
if (arg instanceof CodeModeRegExp) return arg.regex
2325
if (typeof arg === "string") {
2426
try {

0 commit comments

Comments
 (0)