From db45c950a94e2b4224ba134dfe9d0f3c52ecb7db Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:16:55 -0500 Subject: [PATCH] fix(codemode): honor Uint8Array.from mappers, compare byte searches without coercion, skip flatMap holes, and truncate fractional radixes (#57285) Co-authored-by: r266-tech Co-authored-by: sdivyanshu90 Co-authored-by: icearia0219 <133481415+icearia0219@users.noreply.github.com> --- packages/codemode/interpreter-support.md | 13 ++-- packages/codemode/src/stdlib/array.ts | 8 +- packages/codemode/src/stdlib/bytes.ts | 42 +++++++---- packages/codemode/src/stdlib/number.ts | 9 +-- .../test/array-callbacks-test262.test.ts | 14 ++++ packages/codemode/test/stdlib.test.ts | 74 +++++++++++++++++++ 6 files changed, 131 insertions(+), 29 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index db8cba20c6a..e02a1c4e0fc 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -359,8 +359,8 @@ reject }` object. ## Arrays - [x] The `Array` constructor with or without `new`: `Array(a, b)` collects arguments and `Array(n)` creates a sparse - array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, and JSON handle holes like - JavaScript, and host results normalize holes to `null`. + array of that length; invalid lengths throw `RangeError`. Iteration, spread, join, `flatMap`, and JSON handle + holes like JavaScript, and host results normalize holes to `null`. - [x] Static methods: `Array.isArray`, `Array.of`, and `Array.from`, including the `Array.from` mapper form with `(value, index)` arguments and stepwise synchronous iterator consumption. - [x] Iteration/transformation: `map`, `filter`, `flatMap`, and `forEach`. @@ -426,6 +426,8 @@ reject }` object. `Number.parseInt`, and `Number.parseFloat`. - [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which always formats as `en-US` (`"1,234.5"`) so output does not depend on the host. +- [x] `Number.prototype.toString` truncates a fractional radix before validating the 2–36 range; invalid radixes + throw a catchable `RangeError`. - [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`, `POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`. - [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`. @@ -552,13 +554,14 @@ The only binary type. Bytes stay inside the program or cross to extensions as co with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`). - [x] `new Uint8Array(length | array | iterable | Uint8Array)`, `Uint8Array.from`, `Uint8Array.of`, `fromBase64`, - and `fromHex`. Lengths are capped like arrays. + and `fromHex`. Lengths are capped like arrays. The `Uint8Array.from` mapper and `thisArg` see the original + values, so `Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))` decodes base64. - [x] Index reads and writes with JS byte semantics: values wrap modulo 256, out-of-range writes are ignored, indexes cannot be deleted. `length` is a prototype accessor, so `Object.keys` lists only indexes. - [x] `at`, `slice`, `subarray` (a view on the same bytes), `set`, `fill`, `reverse`, `indexOf`, `lastIndexOf`, `includes`, `join`, `toString`, `toBase64`, `toHex`, and live `keys`, `values`, `entries`, and `[Symbol.iterator]` - iterators. Start indexes coerce as for arrays, and `lastIndexOf(x, undefined)` searches from index 0 while - `lastIndexOf(x)` searches from the end, as in JS. + iterators. Start indexes coerce as for arrays, search values do not (`bytes.includes("1")` is `false`), and + `lastIndexOf(x, undefined)` searches from index 0 while `lastIndexOf(x)` searches from the end, as in JS. - [x] Spread, destructuring, `for...of`, `yield*`, `Array.from`, and `new Set(bytes)`. `Array.isArray` is false. - [x] String coercion joins with commas; `JSON.stringify` gives `{"0":1,...}`; `console.log` prints `Uint8Array(n) [...]`. diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index 6f483a50820..64a8d253ac8 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -375,8 +375,12 @@ export const arrayGlobal = (ctx: Interpreter) => { for (let index = 0; index < length; index += 1) { if (!(index in target.items)) continue const mapped = yield* apply([target.items[index], index, target], args[1]) - if (mapped instanceof Arr) values.push(...mapped.items) - else values.push(mapped) + if (mapped instanceof Arr) { + // forEach skips holes, as flattening does; spreading would turn them into undefined. + mapped.items.forEach((item) => values.push(item)) + continue + } + values.push(mapped) } return wrap(values) }) diff --git a/packages/codemode/src/stdlib/bytes.ts b/packages/codemode/src/stdlib/bytes.ts index 9e972f82ac4..e909784df5c 100644 --- a/packages/codemode/src/stdlib/bytes.ts +++ b/packages/codemode/src/stdlib/bytes.ts @@ -17,12 +17,23 @@ import { type Value, } from "../interpreter/objects.js" import { describeValue } from "../interpreter/references.js" +import { applyCollectionCallback } from "../interpreter/callback.js" import type { Interpreter } from "../interpreter/interpreter.js" import { callbackMethods, sortArray } from "./array.js" -/** The bytes a Uint8Array, array, or other iterable of numbers describes; the host array clamps each value. */ -const collectBytes = (ctx: Interpreter, source: Value, name: string): Effect.Effect => { - if (source instanceof Bytes) return Effect.succeed(new Uint8Array(source.bytes)) +/** + * The bytes a Uint8Array, array, or other iterable of numbers describes; the host array clamps each value. As in + * TypedArray.from, a mapper sees the original values, after the source is consumed in full. + */ +const collectBytes = ( + ctx: Interpreter, + source: Value, + name: string, + mapper?: Value, + thisArg?: Value, +): Effect.Effect => { + const apply = mapper === undefined ? undefined : applyCollectionCallback(ctx, mapper, name) + if (source instanceof Bytes && apply === undefined) return Effect.succeed(new Uint8Array(source.bytes)) return Effect.gen(function* () { const cursor = yield* ctx.iterate(source) if (cursor === undefined) { @@ -30,13 +41,16 @@ const collectBytes = (ctx: Interpreter, source: Value, name: string): Effe `${name} expects a Uint8Array, an array, or an iterable of numbers, received ${describeValue(source)}.`, ) } - const values: Array = [] + const values: Array = [] while (true) { const step = yield* cursor.next - if (step.done) return Uint8Array.from(values) - values.push(coerceToNumber(step.value)) + if (step.done) break + values.push(step.value) checkArrayLength(values.length) } + const mapped = + apply === undefined ? values : yield* Effect.forEach(values, (value, index) => apply([value, index], thisArg)) + return Uint8Array.from(mapped, coerceToNumber) }) } @@ -70,7 +84,7 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { } } methods(builtins, uint8Array, [ - ["from", 1, (_, args) => Effect.map(collectBytes(ctx, args[0], "Uint8Array.from"), wrap)], + ["from", 1, (_, args) => Effect.map(collectBytes(ctx, args[0], "Uint8Array.from", args[1], args[2]), wrap)], ["of", 0, (_, args) => wrap(Uint8Array.from(args, coerceToNumber))], ["fromBase64", 1, (_, args) => decode("fromBase64", args, (text) => Uint8Array.fromBase64(text))], ["fromHex", 1, (_, args) => decode("fromHex", args, (text) => Uint8Array.fromHex(text))], @@ -78,6 +92,8 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { const self = (thisValue: Value, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`) const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value)) + // Uint8Array cannot contain NaN, so non-number search values never match a byte. + const search = (value: Value) => (typeof value === "number" ? value : NaN) const wrapAll = (items: Array) => new Arr(builtins.Array, items) defineAccessor(proto, "length", (thisValue) => self(thisValue, "length").bytes.length) methods(builtins, proto, [ @@ -142,25 +158,21 @@ export const uint8ArrayGlobal = (ctx: Interpreter) => { }) }, ], - [ - "indexOf", - 1, - (thisValue, args) => self(thisValue, "indexOf").bytes.indexOf(coerceToNumber(args[0]), optNumber(args[1])), - ], + ["indexOf", 1, (thisValue, args) => self(thisValue, "indexOf").bytes.indexOf(search(args[0]), optNumber(args[1]))], [ "lastIndexOf", 1, (thisValue, args) => { const target = self(thisValue, "lastIndexOf").bytes return args.length < 2 - ? target.lastIndexOf(coerceToNumber(args[0])) - : target.lastIndexOf(coerceToNumber(args[0]), optNumber(args[1])) + ? target.lastIndexOf(search(args[0])) + : target.lastIndexOf(search(args[0]), optNumber(args[1])) }, ], [ "includes", 1, - (thisValue, args) => self(thisValue, "includes").bytes.includes(coerceToNumber(args[0]), optNumber(args[1])), + (thisValue, args) => self(thisValue, "includes").bytes.includes(search(args[0]), optNumber(args[1])), ], [ "join", diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index d7f9358d4d2..0a6573e0fef 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,6 +1,6 @@ import { constructor, constants, type Method, methods } from "../interpreter/native.js" import { coerceToNumber, type Value } from "../interpreter/objects.js" -import { rangeError, typeError } from "../interpreter/model.js" +import { typeError } from "../interpreter/model.js" import type { Interpreter } from "../interpreter/interpreter.js" import { withPrimitives } from "../interpreter/callback.js" import { coerce, coercion } from "./value.js" @@ -51,12 +51,7 @@ export const numberGlobal = (ctx: Interpreter) => { ["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")], formatting("toExponential", (value, digits) => value.toExponential(digits)), formatting("toPrecision", (value, digits) => (digits === undefined ? value.toString() : value.toPrecision(digits))), - formatting("toString", (value, radix) => { - if (radix !== undefined && (radix < 2 || radix > 36)) { - throw rangeError("Number.toString radix must be between 2 and 36.") - } - return value.toString(radix) - }), + formatting("toString", (value, radix) => value.toString(radix)), ["valueOf", 0, (thisValue) => self(thisValue, "valueOf")], ]) return number diff --git a/packages/codemode/test/array-callbacks-test262.test.ts b/packages/codemode/test/array-callbacks-test262.test.ts index c34137abd01..4dd98009516 100644 --- a/packages/codemode/test/array-callbacks-test262.test.ts +++ b/packages/codemode/test/array-callbacks-test262.test.ts @@ -337,6 +337,20 @@ describe("Test262 Array callback adaptations", () => { }) describe("Array callback regressions", () => { + test("flatMap skips holes in callback results while retaining explicit undefined", async () => { + expect( + await value(` + return [ + [1].flatMap(() => [, 2]), + [1, 2].flatMap(() => Array(2)), + [1].flatMap(() => [undefined, 2]), + [1].flatMap(() => [[, 2]])[0].length, + 0 in [1].flatMap(() => [[, 2]])[0] + ] + `), + ).toEqual([[2], [], [null, 2], 2, false]) + }) + test("reduce and reduceRight find the first present element", async () => { expect( await value(` diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 8668dde7574..c3a5e9ebd75 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -36,6 +36,26 @@ const error = async (code: string) => { } describe("Number and Math", () => { + test("Number.toString truncates the radix before checking its range", async () => { + expect( + await value(`return [36.1, 36.9, "36.9", { valueOf() { return 36.9 } }].map(radix => (35).toString(radix))`), + ).toEqual(["z", "z", "z", "z"]) + }) + + test("Number.toString keeps invalid radixes catchable as RangeError", async () => { + expect( + await value(`return [0, 1.9, 37, NaN, Infinity, -Infinity].map(radix => { + try { return (35).toString(radix) } + catch (error) { return error instanceof RangeError } + })`), + ).toEqual([true, true, true, true, true, true]) + expect(await value(`return [(35).toString(), (35).toString(undefined), (35).toString(2.9)]`)).toEqual([ + "35", + "35", + "100011", + ]) + }) + test("Math.random returns a number in [0, 1)", async () => { expect(await value(`const n = Math.random(); return typeof n === "number" && n >= 0 && n < 1`)).toBe(true) }) @@ -1003,6 +1023,36 @@ describe("Set", () => { }) describe("Uint8Array", () => { + test("from maps the original values with thisArg, after consuming the source", async () => { + expect( + await value(` + const events = [] + function* source() { + events.push("start") + yield 300 + events.push("end") + } + return [ + [...Uint8Array.from([300, 2], (value, index) => value / 2 + index)], + [...Uint8Array.from(new Uint8Array([2, 3]), (value, index) => value + 255 + index)], + [...Uint8Array.from([1, 2], function (value, index) { return value + index + this.offset }, { offset: 5 })], + [...Uint8Array.from(atob("aGk="), (c) => c.charCodeAt(0))], + [...Uint8Array.from([300, 2], undefined)], + [...Uint8Array.from(source(), (value) => { events.push("map"); return value })], + events, + ] + `), + ).toEqual([[150, 2], [1, 3], [6, 8], [104, 105], [44, 2], [44], ["start", "end", "map"]]) + }) + + test("from rejects a non-callable mapper even for an empty source", async () => { + for (const mapper of ["42", "null", "{}"]) { + expect((await error(`return Uint8Array.from([], ${mapper}).length`)).message).toContain( + "TypeError: Uint8Array.from expects a function callback", + ) + } + }) + test("constructs from a length, an array, an iterable, or another Uint8Array", async () => { expect( await value(` @@ -1927,6 +1977,30 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => { }) describe("Uint8Array callback methods", () => { + test.each(["'1'", "true", "false", "null", "{}", "undefined", "NaN"])( + "typed-array searches do not coerce %s into a byte", + async (search) => { + expect( + await value(` + const bytes = new Uint8Array([0, 1, 2, 1]) + return [bytes.indexOf(${search}), bytes.lastIndexOf(${search}), bytes.includes(${search})] + `), + ).toEqual([-1, -1, false]) + }, + ) + + test("typed-array searches keep numeric matching and start-index coercion", async () => { + expect( + await value(` + const bytes = new Uint8Array([0, 1, 2, 1]) + return [ + bytes.indexOf(1, "2"), bytes.lastIndexOf(1, "2"), bytes.includes(2, "3"), + bytes.indexOf(-0), bytes.includes(257), bytes.indexOf(1, -1) + ] + `), + ).toEqual([3, 1, false, 0, false, 3]) + }) + test("map and filter return new Uint8Arrays with clamped bytes", async () => { expect( await value(`