Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions packages/codemode/interpreter-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
25 changes: 16 additions & 9 deletions packages/codemode/src/interpreter/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,16 +428,14 @@ const invokeStringReplacer = <R>(
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
}
Expand Down Expand Up @@ -664,11 +662,20 @@ const invokeArrayMethod = <R>(
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
case "reverse":
return Effect.succeed(target.reverse())
case "sort":
case "sort": {
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)
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
})
}
case "toSorted":
return sortArray(runner, target, args[0], "Array.toSorted", node)
case "toReversed":
Expand Down
36 changes: 28 additions & 8 deletions packages/codemode/src/interpreter/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,7 @@ export class Interpreter<R> {
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":
Expand Down Expand Up @@ -1133,17 +1133,37 @@ export class Interpreter<R> {
)
}

private constructDate(args: Array<unknown>): CodeModeDate {
if (args.length === 0) return new CodeModeDate(Date.now())
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
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 === "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 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<unknown, unknown, R> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return Effect.succeed(value)
const object = value as Record<string, unknown>
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<unknown>, node: AstNode): CodeModeRegExp {
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/src/stdlib/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions packages/codemode/test/callbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,6 @@ 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("returned sparse arrays normalize holes to null at the host boundary", async () => {
expect(await value(`return Array(3)`)).toEqual([null, null, null])
})
Expand All @@ -153,6 +149,57 @@ describe("constructors callable without new, like JS", () => {
})

describe("sort accepts the unified callback set", () => {
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("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("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])
Expand Down
70 changes: 63 additions & 7 deletions packages/codemode/test/stdlib.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -55,6 +66,52 @@ 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("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)
Expand All @@ -74,9 +131,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",
)
})

Expand Down Expand Up @@ -198,7 +255,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,
Expand All @@ -211,16 +268,15 @@ 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({
tools: { host: { decorate } },
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 () => {
Expand Down
Loading