Skip to content

Commit 962c14a

Browse files
authored
fix(codemode): honor thisArg, program toString in computed keys, and ToPrimitive in String and Number arguments (anomalyco#51264)
1 parent 85b98e7 commit 962c14a

11 files changed

Lines changed: 496 additions & 357 deletions

File tree

‎packages/codemode/interpreter-support.md‎

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
131131
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
132132
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
133133
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
134-
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
134+
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
135135
because `includes` is called without a string `this`.
136136
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
137137
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
@@ -155,8 +155,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
155155
array, an array-like object (its `length` clamped and capped like `Array.from`), or `null`/`undefined`. A
156156
bound function is named `bound f`, has its remaining `length`, and is not constructible.
157157
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
158-
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
159-
accepted but not yet passed as `this`; callbacks run with `this` undefined.
158+
- [x] The optional `thisArg` of the Array, Uint8Array, and `Array.from` callback methods and of Map, Set,
159+
URLSearchParams, and Headers `forEach` is the callback's `this`: `[1, 2].forEach(function () { this.n++ }, c)`
160+
increments `c.n` twice. Arrows ignore it, as in JS; `reduce`/`reduceRight` take an initial value instead.
160161
- [ ] User-defined constructor calls.
161162
- [ ] Classes and private fields.
162163
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
@@ -241,11 +242,19 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
241242
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
242243
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
243244
behavior (`date + 1` concatenates, `date - date` subtracts).
244-
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric built-in arguments outside
245-
`Math` and `Date` (`at`, `indexOf` start, `toFixed` digits) still use the built-in form (`NaN`,
246-
`"[object Object]"`) and ignore own methods.
247-
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
248-
string keys.
245+
- [x] String and Number method arguments convert through ToPrimitive in spec order, receiver first: search strings,
246+
separators, fills, and replacements with the string hint, indexes, counts, digits, and radixes with the number
247+
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
248+
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
249+
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
250+
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
251+
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
252+
ignore own methods.
253+
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
254+
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
255+
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
256+
`o[{ toString() { return "id" } }] += 1` updates `o.id`. A nullish base throws before the key converts, as
257+
in JS. Opaque values (functions, promises, tool references) keep their built-in string form.
249258

250259
## Promises and tools
251260

@@ -306,8 +315,9 @@ reject }` object.
306315
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
307316
primitive wrapper objects (`Object(1)`) are rejected explicitly.
308317
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
309-
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
310-
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
318+
two confined symbols as themselves, data objects through their own `toString` (`o[[1, 2]]` is `o["1,2"]`), and
319+
everything else as its string form (`o[null]` is `o["null"]`), in reads, writes, literals, `in`, and
320+
destructuring.
311321
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
312322
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
313323
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
@@ -368,7 +378,6 @@ reject }` object.
368378
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
369379
difference.
370380
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
371-
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
372381
- [x] `Array.prototype.toSpliced`.
373382
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
374383
`1`.
@@ -381,9 +390,9 @@ reject }` object.
381390
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
382391
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
383392
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
384-
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, and
385-
`parseInt` consult a program object's own `valueOf`/`toString`; the array and number methods do not yet (see
386-
ToPrimitive above).
393+
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, `parseInt`,
394+
and the String and Number methods consult a program object's own `valueOf`/`toString`; the array methods do not
395+
yet (see ToPrimitive above).
387396

388397
## Strings
389398

@@ -400,14 +409,15 @@ reject }` object.
400409
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
401410
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
402411
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
403-
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
404-
`repeat` still requires a finite non-negative count.
412+
expressions with a native-style `TypeError`. Data objects convert through their own `toString`/`valueOf` (see
413+
ToPrimitive above). Opaque runtime references still reject as data errors, and `repeat` still requires a finite
414+
non-negative count.
405415
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
406416
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
407417
`toString`.
408418
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
409-
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
410-
Objects use their built-in string form until ToPrimitive lands.
419+
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, `undefined` is the empty pattern, and
420+
an object supplies its own `toString`.
411421

412422
## Numbers and Math
413423

‎packages/codemode/src/interpreter/callback.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,24 @@ export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
6767
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
6868

6969
/**
70-
* Runs a synchronous native body on its arguments after ToPrimitive, in order, with one hint for all positions or
71-
* one per position. Primitive arguments skip the Effect entirely.
70+
* Runs a native body on its arguments after ToPrimitive, in order, with one hint for all positions or one per
71+
* position. Primitive arguments skip the Effect entirely.
7272
*/
7373
export const withPrimitives = <R>(
7474
ctx: Interpreter<R>,
7575
hints: Hint | ReadonlyArray<Hint>,
7676
values: Array<Value>,
77-
body: (primitives: Array<Value>) => Value,
77+
body: (primitives: Array<Value>) => Value | Effect.Effect<Value, unknown, R>,
7878
): Value | Effect.Effect<Value, unknown, R> => {
7979
if (!values.some((value) => value instanceof Obj)) return body(values)
80-
return Effect.map(
80+
return Effect.flatMap(
8181
Effect.forEach(values, (value, index) =>
8282
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
8383
),
84-
body,
84+
(primitives) => {
85+
const result = body(primitives)
86+
return Effect.isEffect(result) ? result : Effect.succeed(result)
87+
},
8588
)
8689
}
8790

‎packages/codemode/src/interpreter/interpreter.ts‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ const primitiveOperators = new Set([
123123
">>>",
124124
])
125125

126+
/** ToPropertyKey on a primitive (or an opaque value, which keeps its built-in string form). */
127+
const propertyKey = (value: Value): PropertyKey =>
128+
typeof value === "string" || typeof value === "number" || typeof value === "symbol" ? value : coerceToString(value)
129+
126130
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
127131
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
128132
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
@@ -1277,7 +1281,7 @@ class Frame<R> {
12771281
}
12781282
const keyNode = property.key
12791283
if (property.computed) {
1280-
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
1284+
return Effect.flatMap(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
12811285
}
12821286
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
12831287
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
@@ -1400,6 +1404,10 @@ class Frame<R> {
14001404
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
14011405
// references, which are not Obj) compare by identity.
14021406
const equality = operator === "==" || operator === "!="
1407+
// `in` checks the right operand before ToPropertyKey on the left, so a bad right side wins over a bad key.
1408+
if (operator === "in" && lhs instanceof Obj && !isOpaque(lhs) && rhs instanceof Obj) {
1409+
return Effect.map(this.toPropertyKey(lhs, node), (key) => has(rhs, key))
1410+
}
14031411
const other = lhs instanceof Obj ? rhs : lhs
14041412
const converts =
14051413
primitiveOperators.has(operator) ||
@@ -1419,9 +1427,7 @@ class Frame<R> {
14191427
if (operator === "!==") return lhs !== rhs
14201428
if (operator === "==") return this.looselyEqual(lhs, rhs, node)
14211429
if (operator === "!=") return !this.looselyEqual(lhs, rhs, node)
1422-
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) {
1423-
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
1424-
}
1430+
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) return has(rhs, propertyKey(lhs))
14251431
if (isOpaque(lhs) || isOpaque(rhs)) {
14261432
throw invalidData("Binary operators require data values.", node)
14271433
}
@@ -2046,11 +2052,11 @@ class Frame<R> {
20462052
let key: PropertyKey
20472053

20482054
if (property.computed) {
2049-
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
2055+
key = yield* self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
20502056
} else if (keyNode.type === "Identifier") {
20512057
key = keyNode.name
20522058
} else if (keyNode.type === "Literal") {
2053-
key = self.toPropertyKey(literal(keyNode))
2059+
key = propertyKey(literal(keyNode))
20542060
} else {
20552061
throw typeError("Unsupported object property key shape.", keyNode)
20562062
}
@@ -2178,11 +2184,16 @@ class Frame<R> {
21782184
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
21792185
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
21802186

2181-
const key = node.computed
2182-
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
2183-
: propertyNode.type === "Identifier"
2187+
const keyValue =
2188+
!node.computed && propertyNode.type === "Identifier"
21842189
? propertyNode.name
2185-
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
2190+
: yield* self.evaluateExpression(propertyNode)
2191+
// GetValue applies ToObject to the base before ToPropertyKey, so a nullish base throws before the key's own
2192+
// toString runs.
2193+
if (objectValue === null || objectValue === undefined) {
2194+
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
2195+
}
2196+
const key = yield* self.toPropertyKey(keyValue, propertyNode)
21862197
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
21872198
})
21882199
}
@@ -2314,9 +2325,10 @@ class Frame<R> {
23142325
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
23152326
}
23162327

2317-
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
2318-
private toPropertyKey(value: Value): PropertyKey {
2319-
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
2320-
return coerceToString(value)
2328+
// ToPropertyKey: a data object converts through its own `toString`/`valueOf` first; anything else becomes its
2329+
// string form synchronously, so `counts[row.category]` works when the field is null.
2330+
private toPropertyKey(value: Value, node: AstNode): Effect.Effect<PropertyKey, unknown, R> {
2331+
if (!(value instanceof Obj)) return Effect.succeed(propertyKey(value))
2332+
return Effect.map(this.toPrimitive(value, "string", node), propertyKey)
23212333
}
23222334
}

‎packages/codemode/src/stdlib/array.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
4949
const values: Array<Value> = []
5050
for (let index = 0; index < arrayLike.length; index += 1) {
5151
const item = get(arrayLike.source, index)
52-
values.push(apply === undefined ? item : yield* apply([item, index]))
52+
values.push(apply === undefined ? item : yield* apply([item, index], args[2]))
5353
}
5454
return new Arr(proto, values)
5555
}
@@ -59,7 +59,9 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
5959
const step = yield* cursor.next
6060
if (step.done) return new Arr(proto, values)
6161
values.push(
62-
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
62+
apply === undefined
63+
? step.value
64+
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
6365
)
6466
index += 1
6567
}
@@ -372,7 +374,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
372374
const values: Array<Value> = []
373375
for (let index = 0; index < length; index += 1) {
374376
if (!(index in target.items)) continue
375-
const mapped = yield* apply([target.items[index], index, target])
377+
const mapped = yield* apply([target.items[index], index, target], args[1])
376378
if (mapped instanceof Arr) values.push(...mapped.items)
377379
else values.push(mapped)
378380
}
@@ -411,7 +413,10 @@ export const callbackMethods = <R, T extends Obj>(
411413
length,
412414
(thisValue, args) => {
413415
const target = self(thisValue, name)
414-
return body(elements(target), target, applyCollectionCallback(ctx, args[0], `${label}.${name}`), args)
416+
const call = applyCollectionCallback(ctx, args[0], `${label}.${name}`)
417+
// reduce and reduceRight take an initial value where the others take a thisArg.
418+
const thisArg = name.startsWith("reduce") ? undefined : args[1]
419+
return body(elements(target), target, (callbackArgs) => call(callbackArgs, thisArg), args)
415420
},
416421
]
417422
return [

‎packages/codemode/src/stdlib/collections.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
188188
const target = self(thisValue, "forEach")
189189
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
190190
return Effect.gen(function* () {
191-
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
191+
for (const [key, item] of target.map.entries()) yield* apply([item, key, target], args[1])
192192
return undefined
193193
})
194194
},
@@ -386,7 +386,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
386386
const target = self(thisValue, "forEach")
387387
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
388388
return Effect.gen(function* () {
389-
for (const item of target.set.values()) yield* apply([item, item, target])
389+
for (const item of target.set.values()) yield* apply([item, item, target], args[1])
390390
return undefined
391391
})
392392
},

‎packages/codemode/src/stdlib/headers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
117117
const target = self(thisValue, "forEach")
118118
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
119119
return Effect.gen(function* () {
120-
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
120+
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target], args[1])
121121
return undefined
122122
})
123123
},

0 commit comments

Comments
 (0)