From 47c884a0ed3fd59d2b15d908be3509800ebead59 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 10:54:39 -0500 Subject: [PATCH 1/3] fix(codemode): align string, array, and Date behavior --- packages/codemode/interpreter-support.md | 13 +++++------ packages/codemode/src/interpreter/methods.ts | 21 +++++++++++------- packages/codemode/src/interpreter/runtime.ts | 4 ++-- packages/codemode/src/stdlib/date.ts | 2 +- packages/codemode/test/callbacks.test.ts | 19 ++++++++++++++-- packages/codemode/test/stdlib.test.ts | 23 ++++++++++++++------ 6 files changed, 55 insertions(+), 27 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index eebef60302f5..8ae03199a9ae 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -91,8 +91,8 @@ ultimate source of truth. like JS. - [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an arrow function. -- [ ] Stop automatically awaiting promise-returning string replacers; match JavaScript's synchronous callback-result - coercion. +- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are + not automatically awaited. - [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so ignoring it matches JS arrow-function semantics exactly. - [ ] `this` in non-arrow CodeMode functions and callbacks. @@ -199,8 +199,8 @@ ultimate source of truth. - [ ] `Array.prototype.toSpliced`. - [ ] Canonical array/string index parsing: a key such as `"01"` must remain an ordinary property key rather than aliasing index `1`. -- [ ] `Array.prototype.sort` and `toSorted` must preserve trailing holes; they currently turn holes into own - `undefined` elements. +- [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements, + like JavaScript. ## Strings @@ -267,10 +267,9 @@ ultimate source of truth. - [x] Date values serialize to ISO strings; invalid dates serialize to `null`. - [ ] Date setters. - [ ] `Date.prototype.toUTCString` and its `toGMTString` alias. -- [ ] Native one-argument Date coercion; unsupported boolean/object inputs currently become invalid dates instead of - being coerced. +- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects. - [ ] Native Date loose-equality and default primitive-coercion semantics. -- [ ] Native `RangeError` branding for invalid `toISOString()` calls. +- [x] Native `RangeError` branding for invalid `toISOString()` calls. ## Regular expressions diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 6eb592e7deb6..511a43b95cfd 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -428,16 +428,14 @@ const invokeStringReplacer = ( let end = 0 for (const match of matches) { const replacement = yield* apply(match.args) - const resolved = - args[1] instanceof CodeModeFunction && args[1].async && replacement instanceof CodeModePromise - ? yield* runner.settlePromise(replacement) - : replacement // Error values are branded plain objects; boundedData would strip the brand before coercion. output.push( value.slice(end, match.offset), - errorBrandName(resolved) - ? coerceToString(resolved) - : coerceToString(boundedData(resolved, `String.${name} replacer result`)), + replacement instanceof CodeModePromise + ? "[object Promise]" + : errorBrandName(replacement) + ? coerceToString(replacement) + : coerceToString(boundedData(replacement, `String.${name} replacer result`)), ) end = match.offset + match.match.length } @@ -664,11 +662,18 @@ const invokeArrayMethod = ( return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) case "reverse": return Effect.succeed(target.reverse()) - case "sort": + case "sort": { + const holeCount = Array.from({ length: target.length }, (_, index) => Object.hasOwn(target, index)).filter( + (own) => !own, + ).length return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => { target.splice(0, target.length, ...sorted) + Array.from({ length: holeCount }, (_, index) => target.length - index - 1).forEach((index) => { + Reflect.deleteProperty(target, index) + }) return target }) + } case "toSorted": return sortArray(runner, target, args[0], "Array.toSorted", node) case "toReversed": diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 5bf574014cfa..e7b09766dba4 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1138,9 +1138,9 @@ export class Interpreter { if (args.length === 1) { const arg = args[0] if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time) - if (typeof arg === "number") return new CodeModeDate(new Date(arg).getTime()) if (typeof arg === "string") return new CodeModeDate(Date.parse(arg)) - return new CodeModeDate(Number.NaN) + if (arg !== null && typeof arg === "object") return new CodeModeDate(Date.parse(coerceToString(arg))) + return new CodeModeDate(new Date(coerceToNumber(arg)).getTime()) } const parts = args.map((arg) => coerceToNumber(arg)) return new CodeModeDate(new Date(...(parts as [number, number])).getTime()) diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index 6ca1009c89cf..a6a1a059f48b 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -45,7 +45,7 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod case "valueOf": return value.time case "toISOString": - if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) + if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node).as("RangeError") return hosted.toISOString() case "toJSON": return Number.isFinite(value.time) ? hosted.toISOString() : null diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index 6391448a7060..c2af842d4747 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -131,8 +131,23 @@ describe("constructors callable without new, like JS", () => { expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError") }) - test("sort densifies trailing holes into undefined (documented divergence)", async () => { - expect(await value(`return Array(2).sort().map(() => 1)`)).toEqual([1, 1]) + test("sort preserves trailing holes while toSorted densifies them", async () => { + expect( + await value(` + const defaultSorted = [2, , 1] + const compared = [2, , 1] + const copied = defaultSorted.toSorted() + defaultSorted.sort() + compared.sort((a, b) => a - b) + return [ + Object.hasOwn(defaultSorted, 2), + Object.hasOwn(compared, 2), + Object.hasOwn(copied, 2), + ] + `), + ).toEqual([false, false, true]) + + expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true) }) test("returned sparse arrays normalize holes to null at the host boundary", async () => { diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 94d8c57b079b..ed4537cdbae6 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -55,6 +55,16 @@ describe("Date", () => { expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000) }) + test("one-argument construction coerces supported values like JavaScript", async () => { + expect( + await value(`return [new Date(true).getTime(), new Date(false).getTime(), new Date(null).getTime()]`), + ).toEqual([1, 0, 0]) + expect(await value(`return Number.isNaN(new Date(undefined).getTime())`)).toBe(true) + expect(await value(`return Number.isNaN(new Date([]).getTime())`)).toBe(true) + expect(await value(`return new Date(["1970-01-01T00:00:00.000Z"]).getTime()`)).toBe(0) + expect(await value(`return Number.isNaN(new Date({}).getTime())`)).toBe(true) + }) + test("date arithmetic and comparison use the time value", async () => { expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000) expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) @@ -74,9 +84,9 @@ describe("Date", () => { expect(await value(`return new Date("garbage").toJSON()`)).toBeNull() }) - test("toISOString on an invalid date is a catchable error", async () => { - expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe( - "caught", + test("toISOString on an invalid date throws RangeError", async () => { + expect(await value(`try { new Date("garbage").toISOString() } catch (error) { return error.name }`)).toBe( + "RangeError", ) }) @@ -198,7 +208,7 @@ describe("RegExp", () => { ).toBe("7null[object Object]") }) - test("function replacers can await effectful tool calls", async () => { + test("promise-returning string replacers are coerced synchronously", async () => { const decorate = Tool.make({ description: "Decorate a string", input: Schema.String, @@ -211,7 +221,7 @@ describe("RegExp", () => { code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`, }), ) - expect(result.ok && result.value).toBe("a[1]b[22]") + expect(result.ok && result.value).toBe("a[object Promise]b[object Promise]") const missingAwait = await Effect.runPromise( CodeMode.execute({ @@ -219,8 +229,7 @@ describe("RegExp", () => { code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`, }), ) - expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue") - expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise") + expect(missingAwait.ok && missingAwait.value).toBe("a[object Promise]") }) test("replaceAll without the g flag is a catchable error", async () => { From 8c3ca7cf581252c7aa960d0f842f1c85f98f436a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 20:57:52 -0500 Subject: [PATCH 2/3] fix(codemode): preserve coercion and sort side effects --- packages/codemode/src/interpreter/methods.ts | 12 ++++--- packages/codemode/src/interpreter/runtime.ts | 36 ++++++++++++++----- packages/codemode/test/callbacks.test.ts | 33 +++++++++++++++++ packages/codemode/test/stdlib.test.ts | 37 ++++++++++++++++++++ 4 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 511a43b95cfd..44f80eb80e11 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -663,12 +663,14 @@ const invokeArrayMethod = ( case "reverse": return Effect.succeed(target.reverse()) case "sort": { - const holeCount = Array.from({ length: target.length }, (_, index) => Object.hasOwn(target, index)).filter( - (own) => !own, - ).length + const length = target.length + const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length + const itemCount = length - holeCount return Effect.map(sortArray(runner, target, args[0], "Array.sort", node), (sorted) => { - target.splice(0, target.length, ...sorted) - Array.from({ length: holeCount }, (_, index) => target.length - index - 1).forEach((index) => { + sorted.slice(0, itemCount).forEach((item, index) => { + target[index] = item + }) + Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => { Reflect.deleteProperty(target, index) }) return target diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index e7b09766dba4..7f92cb93bffc 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -1095,7 +1095,7 @@ export class Interpreter { const args = yield* self.evaluateCallArguments(argNodes) switch (name) { case "Date": - return self.constructDate(args) + return yield* self.constructDate(args, node) case "RegExp": return self.constructRegExp(args, node) case "Map": @@ -1133,17 +1133,37 @@ export class Interpreter { ) } - private constructDate(args: Array): CodeModeDate { - if (args.length === 0) return new CodeModeDate(Date.now()) + private constructDate(args: Array, node: AstNode): Effect.Effect { + if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now())) if (args.length === 1) { const arg = args[0] - if (arg instanceof CodeModeDate) return new CodeModeDate(arg.time) - if (typeof arg === "string") return new CodeModeDate(Date.parse(arg)) - if (arg !== null && typeof arg === "object") return new CodeModeDate(Date.parse(coerceToString(arg))) - return new CodeModeDate(new Date(coerceToNumber(arg)).getTime()) + if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time)) + return Effect.map(this.toDatePrimitive(arg, node), (value) => + typeof value === "string" + ? new CodeModeDate(Date.parse(value)) + : new CodeModeDate(new Date(coerceToNumber(value)).getTime()), + ) } const parts = args.map((arg) => coerceToNumber(arg)) - return new CodeModeDate(new Date(...(parts as [number, number])).getTime()) + return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime())) + } + + private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect { + if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value) + const object = value as Record + const self = this + return Effect.gen(function* () { + if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") { + const result = yield* self.runner.invokeCallable(object.valueOf, [], node) + if (result === null || (typeof result !== "object" && typeof result !== "function")) return result + } + if (!Object.hasOwn(object, "toString")) return coerceToString(value) + if (typeofValue(object.toString) === "function") { + const result = yield* self.runner.invokeCallable(object.toString, [], node) + if (result === null || (typeof result !== "object" && typeof result !== "function")) return result + } + throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError") + }) } private constructRegExp(args: Array, node: AstNode): CodeModeRegExp { diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index c2af842d4747..37e90273285c 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -150,6 +150,39 @@ describe("constructors callable without new, like JS", () => { expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true) }) + // Adapted from test262's sort/precise-{getter,setter}-{appends,pops}-elements.js side-effect cases. + test("sort writes its snapshot without discarding comparator length mutations", async () => { + expect( + await value(` + const values = [3, 2, 1] + let first = true + values.sort((a, b) => { + if (first) { + first = false + values.push("kept") + } + return a - b + }) + return values + `), + ).toEqual([1, 2, 3, "kept"]) + + expect( + await value(` + const values = [3, , 1, , 2] + let first = true + values.sort((a, b) => { + if (first) { + first = false + values.splice(0) + } + return a - b + }) + return { values, owns: values.map((_, index) => Object.hasOwn(values, index)) } + `), + ).toEqual({ values: [1, 2, 3], owns: [true, true, true] }) + }) + test("returned sparse arrays normalize holes to null at the host boundary", async () => { expect(await value(`return Array(3)`)).toEqual([null, null, null]) }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index ed4537cdbae6..da97afb99863 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -65,6 +65,43 @@ describe("Date", () => { expect(await value(`return Number.isNaN(new Date({}).getTime())`)).toBe(true) }) + // Adapted to CodeMode's supported syntax from test262 Date/value-to-primitive-result-{non-string-prim,string}.js. + test("one-argument construction uses valueOf then toString for objects", async () => { + expect( + await value(` + const calls = [] + const number = { valueOf: () => 8 } + const text = { + valueOf: () => { calls.push("valueOf"); return {} }, + toString: () => { calls.push("toString"); return "2016-06-05T18:40:00.000Z" }, + } + return [new Date(number).getTime(), new Date(text).getTime(), calls] + `), + ).toEqual([8, 1465152000000, ["valueOf", "toString"]]) + + expect( + await value(` + const values = [ + { valueOf: () => undefined }, + { valueOf: () => true }, + { valueOf: () => false }, + { valueOf: () => null }, + ] + return values.map((item) => new Date(item).getTime()) + `), + ).toEqual([null, 1, 0, 0]) + + expect( + await value(` + try { + new Date({ valueOf: () => ({}), toString: () => ({}) }) + } catch (error) { + return error.name + } + `), + ).toBe("TypeError") + }) + test("date arithmetic and comparison use the time value", async () => { expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000) expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) From f3abb68f1fb9203e9e0960f97b98ceef04d9fc5f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 21:05:45 -0500 Subject: [PATCH 3/3] test(codemode): clarify parity test provenance --- packages/codemode/test/callbacks.test.ts | 37 ++++++++++++------------ packages/codemode/test/stdlib.test.ts | 12 +++++++- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index 37e90273285c..613933699ac7 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -131,6 +131,24 @@ describe("constructors callable without new, like JS", () => { expect((await error(`try { Array(-1) } catch (e) { throw Error(e.name) }`)).message).toContain("RangeError") }) + test("returned sparse arrays normalize holes to null at the host boundary", async () => { + expect(await value(`return Array(3)`)).toEqual([null, null, null]) + }) + + test("RegExp with non-string flags throws a SyntaxError, like JS", async () => { + expect((await error(`try { RegExp("a", 0) } catch (e) { throw Error(e.name) }`)).message).toContain("SyntaxError") + }) + + test("new-requiring constructors throw a TypeError when called", async () => { + expect((await error(`return Map()`)).message).toContain("Constructor Map requires 'new'") + expect((await error(`return [1].map(Set)`)).message).toContain("Constructor Set requires 'new'") + expect((await error(`return Promise(() => 1)`)).message).toContain("Constructor Promise requires 'new'") + // As a reaction handler the TypeError rejects the derived promise catchably, like JS. + expect(await value(`return await Promise.resolve(1).then(Map).catch((e) => e.name)`)).toBe("TypeError") + }) +}) + +describe("sort accepts the unified callback set", () => { test("sort preserves trailing holes while toSorted densifies them", async () => { expect( await value(` @@ -150,7 +168,6 @@ describe("constructors callable without new, like JS", () => { expect(await value(`const values = [2, undefined, 1]; values.sort(); return Object.hasOwn(values, 2)`)).toBe(true) }) - // Adapted from test262's sort/precise-{getter,setter}-{appends,pops}-elements.js side-effect cases. test("sort writes its snapshot without discarding comparator length mutations", async () => { expect( await value(` @@ -183,24 +200,6 @@ describe("constructors callable without new, like JS", () => { ).toEqual({ values: [1, 2, 3], owns: [true, true, true] }) }) - test("returned sparse arrays normalize holes to null at the host boundary", async () => { - expect(await value(`return Array(3)`)).toEqual([null, null, null]) - }) - - test("RegExp with non-string flags throws a SyntaxError, like JS", async () => { - expect((await error(`try { RegExp("a", 0) } catch (e) { throw Error(e.name) }`)).message).toContain("SyntaxError") - }) - - test("new-requiring constructors throw a TypeError when called", async () => { - expect((await error(`return Map()`)).message).toContain("Constructor Map requires 'new'") - expect((await error(`return [1].map(Set)`)).message).toContain("Constructor Set requires 'new'") - expect((await error(`return Promise(() => 1)`)).message).toContain("Constructor Promise requires 'new'") - // As a reaction handler the TypeError rejects the derived promise catchably, like JS. - expect(await value(`return await Promise.resolve(1).then(Map).catch((e) => e.name)`)).toBe("TypeError") - }) -}) - -describe("sort accepts the unified callback set", () => { test("sort and toSorted take built-in comparators", async () => { expect(await value(`return [0, 1, 0].sort(Boolean)`)).toEqual([0, 0, 1]) expect(await value(`return [0, 1, 0].toSorted(Boolean)`)).toEqual([0, 0, 1]) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index da97afb99863..48c9fd691668 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1,3 +1,14 @@ +/* + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/Date/value-to-primitive-result-non-string-prim.js + * - test/built-ins/Date/value-to-primitive-result-string.js + * + * CodeMode does not support Symbol.toPrimitive, so these cases exercise the same Date-constructor primitive-result + * handling through supported own valueOf and toString functions. + * + * Copyright (C) 2016 the V8 project authors. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + */ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" @@ -65,7 +76,6 @@ describe("Date", () => { expect(await value(`return Number.isNaN(new Date({}).getTime())`)).toBe(true) }) - // Adapted to CodeMode's supported syntax from test262 Date/value-to-primitive-result-{non-string-prim,string}.js. test("one-argument construction uses valueOf then toString for objects", async () => { expect( await value(`