Quellcode durchsuchen

feat(codemode): complete date parity (#37960)

Aiden Cline vor 4 Wochen
Ursprung
Commit
98c29075b4

+ 4 - 2
packages/codemode/interpreter-support.md

@@ -265,10 +265,12 @@ ultimate source of truth.
       `getUTCSeconds`, and `getUTCMilliseconds`.
 - [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
 - [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
-- [ ] Date setters.
+- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
+      `TimeClip` behavior.
 - [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
 - [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.
+- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
+      representation for the string primitive.
 - [x] Native `RangeError` branding for invalid `toISOString()` calls.
 
 ## Regular expressions

+ 40 - 2
packages/codemode/src/interpreter/methods.ts

@@ -22,8 +22,9 @@ import {
   CodeModeSet,
   CodeModeURL,
   CodeModeURLSearchParams,
+  isCodeModeValue,
 } from "../values.js"
-import { invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
+import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
 import { invokeJsonMethod } from "../stdlib/json.js"
 import { invokeMathMethod } from "../stdlib/math.js"
 import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
@@ -96,7 +97,17 @@ export const invokeIntrinsic = <R>(
     return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
   }
   if (ref.receiver instanceof CodeModeDate) {
-    return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
+    const target = ref.receiver
+    const argumentCount = dateSetterArgumentCount(ref.name)
+    if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
+    // Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
+    const initialTime = target.time
+    return Effect.map(
+      Effect.forEach(args.slice(0, argumentCount), (arg) => coerceDateSetterArgument(runner, arg, node), {
+        concurrency: 1,
+      }),
+      (values) => invokeDateMethod(target, ref.name, values, node, initialTime),
+    )
   }
   if (ref.receiver instanceof CodeModeRegExp) {
     return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
@@ -116,6 +127,33 @@ export const invokeIntrinsic = <R>(
   throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
 }
 
+const coerceDateSetterArgument = <R>(
+  runner: CallbackRunner<R>,
+  value: unknown,
+  node: AstNode,
+): Effect.Effect<number, unknown, R> => {
+  if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
+    return Effect.succeed(coerceToNumber(value))
+  }
+  const object = value as Record<string, unknown>
+  return Effect.gen(function* () {
+    if (Object.hasOwn(object, "valueOf") && typeofValue(object.valueOf) === "function") {
+      const result = yield* runner.invokeCallable(object.valueOf, [], node)
+      if (result === null || (typeof result !== "object" && typeof result !== "function")) {
+        return coerceToNumber(result)
+      }
+    }
+    if (!Object.hasOwn(object, "toString")) return coerceToNumber(value)
+    if (typeofValue(object.toString) === "function") {
+      const result = yield* runner.invokeCallable(object.toString, [], node)
+      if (result === null || (typeof result !== "object" && typeof result !== "function")) {
+        return coerceToNumber(result)
+      }
+    }
+    throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
+  })
+}
+
 export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
   if (ref.namespace === "console")
     throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)

+ 4 - 2
packages/codemode/src/interpreter/runtime.ts

@@ -1324,9 +1324,11 @@ export class Interpreter<R> {
       throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
     }
     // Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
-    // Dates use string coercion for `+` and epoch time elsewhere.
+    // Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
     const coerceOperand = (operand: unknown): unknown => {
-      if (operand instanceof CodeModeDate) return operator === "+" ? coerceToString(operand) : operand.time
+      if (operand instanceof CodeModeDate) {
+        return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
+      }
       return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
     }
     const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"

+ 86 - 5
packages/codemode/src/stdlib/date.ts

@@ -1,3 +1,25 @@
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import { CodeModeDate } from "../values.js"
+import { coerceToNumber, coerceToString } from "./value.js"
+
+const dateSetterArguments = new Map<string, number>([
+  ["setTime", 1],
+  ["setMilliseconds", 1],
+  ["setUTCMilliseconds", 1],
+  ["setSeconds", 2],
+  ["setUTCSeconds", 2],
+  ["setMinutes", 3],
+  ["setUTCMinutes", 3],
+  ["setHours", 4],
+  ["setUTCHours", 4],
+  ["setDate", 1],
+  ["setUTCDate", 1],
+  ["setMonth", 2],
+  ["setUTCMonth", 2],
+  ["setFullYear", 3],
+  ["setUTCFullYear", 3],
+])
+
 export const dateMethods = new Set([
   "getTime",
   "valueOf",
@@ -23,6 +45,7 @@ export const dateMethods = new Set([
   "getUTCSeconds",
   "getUTCMilliseconds",
   "getTimezoneOffset",
+  ...dateSetterArguments.keys(),
 ])
 
 export const dateStatics = new Set(["now", "parse", "UTC"])
@@ -40,8 +63,16 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
   }
 }
 
-export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNode): unknown => {
-  const hosted = new Date(value.time)
+export const dateSetterArgumentCount = (name: string): number | undefined => dateSetterArguments.get(name)
+
+export const invokeDateMethod = (
+  value: CodeModeDate,
+  name: string,
+  args: Array<number>,
+  node: AstNode,
+  initialTime = value.time,
+): unknown => {
+  const hosted = new Date(initialTime)
   switch (name) {
     case "getTime":
     case "valueOf":
@@ -90,10 +121,60 @@ export const invokeDateMethod = (value: CodeModeDate, name: string, node: AstNod
       return hosted.getUTCMilliseconds()
     case "getTimezoneOffset":
       return hosted.getTimezoneOffset()
+    case "setTime":
+      return updateDate(value, hosted.setTime(args[0]))
+    case "setMilliseconds":
+      return updateDate(value, hosted.setMilliseconds(args[0]))
+    case "setUTCMilliseconds":
+      return updateDate(value, hosted.setUTCMilliseconds(args[0]))
+    case "setSeconds":
+      if (args.length < 2) return updateDate(value, hosted.setSeconds(args[0]))
+      return updateDate(value, hosted.setSeconds(args[0], args[1]))
+    case "setUTCSeconds":
+      if (args.length < 2) return updateDate(value, hosted.setUTCSeconds(args[0]))
+      return updateDate(value, hosted.setUTCSeconds(args[0], args[1]))
+    case "setMinutes":
+      if (args.length < 2) return updateDate(value, hosted.setMinutes(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setMinutes(args[0], args[1]))
+      return updateDate(value, hosted.setMinutes(args[0], args[1], args[2]))
+    case "setUTCMinutes":
+      if (args.length < 2) return updateDate(value, hosted.setUTCMinutes(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setUTCMinutes(args[0], args[1]))
+      return updateDate(value, hosted.setUTCMinutes(args[0], args[1], args[2]))
+    case "setHours":
+      if (args.length < 2) return updateDate(value, hosted.setHours(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setHours(args[0], args[1]))
+      if (args.length < 4) return updateDate(value, hosted.setHours(args[0], args[1], args[2]))
+      return updateDate(value, hosted.setHours(args[0], args[1], args[2], args[3]))
+    case "setUTCHours":
+      if (args.length < 2) return updateDate(value, hosted.setUTCHours(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setUTCHours(args[0], args[1]))
+      if (args.length < 4) return updateDate(value, hosted.setUTCHours(args[0], args[1], args[2]))
+      return updateDate(value, hosted.setUTCHours(args[0], args[1], args[2], args[3]))
+    case "setDate":
+      return updateDate(value, hosted.setDate(args[0]))
+    case "setUTCDate":
+      return updateDate(value, hosted.setUTCDate(args[0]))
+    case "setMonth":
+      if (args.length < 2) return updateDate(value, hosted.setMonth(args[0]))
+      return updateDate(value, hosted.setMonth(args[0], args[1]))
+    case "setUTCMonth":
+      if (args.length < 2) return updateDate(value, hosted.setUTCMonth(args[0]))
+      return updateDate(value, hosted.setUTCMonth(args[0], args[1]))
+    case "setFullYear":
+      if (args.length < 2) return updateDate(value, hosted.setFullYear(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setFullYear(args[0], args[1]))
+      return updateDate(value, hosted.setFullYear(args[0], args[1], args[2]))
+    case "setUTCFullYear":
+      if (args.length < 2) return updateDate(value, hosted.setUTCFullYear(args[0]))
+      if (args.length < 3) return updateDate(value, hosted.setUTCFullYear(args[0], args[1]))
+      return updateDate(value, hosted.setUTCFullYear(args[0], args[1], args[2]))
     default:
       throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
   }
 }
-import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
-import { CodeModeDate } from "../values.js"
-import { coerceToNumber, coerceToString } from "./value.js"
+
+const updateDate = (value: CodeModeDate, time: number): number => {
+  value.time = time
+  return time
+}

+ 1 - 1
packages/codemode/src/values.ts

@@ -5,7 +5,7 @@ export class CodeModePromise {
 }
 
 export class CodeModeDate {
-  constructor(readonly time: number) {}
+  constructor(public time: number) {}
 }
 
 export class CodeModeRegExp {

+ 266 - 0
packages/codemode/test/date-test262.test.ts

@@ -0,0 +1,266 @@
+/*
+ * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75:
+ * - test/built-ins/Date/prototype/setTime/arg-to-number.js
+ * - test/built-ins/Date/prototype/setTime/arg-to-number-err.js
+ * - test/built-ins/Date/prototype/setTime/new-value-time-clip.js
+ * - test/built-ins/Date/prototype/setMilliseconds/this-value-valid-date.js
+ * - test/built-ins/Date/prototype/setUTCMilliseconds/this-value-valid-date.js
+ * - test/built-ins/Date/prototype/setSeconds/this-value-valid-date-sec.js
+ * - test/built-ins/Date/prototype/setSeconds/this-value-valid-date-ms.js
+ * - test/built-ins/Date/prototype/setUTCSeconds/this-value-valid-date-sec.js
+ * - test/built-ins/Date/prototype/setUTCSeconds/this-value-valid-date-ms.js
+ * - test/built-ins/Date/prototype/setMinutes/this-value-valid-date.js
+ * - test/built-ins/Date/prototype/setUTCMinutes/this-value-valid-date.js
+ * - test/built-ins/Date/prototype/setHours/this-value-valid-date-hour.js
+ * - test/built-ins/Date/prototype/setHours/this-value-valid-date-min.js
+ * - test/built-ins/Date/prototype/setHours/this-value-valid-date-sec.js
+ * - test/built-ins/Date/prototype/setHours/this-value-valid-date-ms.js
+ * - test/built-ins/Date/prototype/setUTCHours/this-value-valid-date-hour.js
+ * - test/built-ins/Date/prototype/setUTCHours/this-value-valid-date-min.js
+ * - test/built-ins/Date/prototype/setUTCHours/this-value-valid-date-sec.js
+ * - test/built-ins/Date/prototype/setUTCHours/this-value-valid-date-ms.js
+ * - test/built-ins/Date/prototype/setDate/this-value-valid-date.js
+ * - test/built-ins/Date/prototype/setUTCDate/date-value-read-before-tonumber-when-date-is-valid.js
+ * - test/built-ins/Date/prototype/setMonth/this-value-valid-date-month.js
+ * - test/built-ins/Date/prototype/setMonth/this-value-valid-date-date.js
+ * - test/built-ins/Date/prototype/setUTCMonth/this-value-valid-date-month.js
+ * - test/built-ins/Date/prototype/setUTCMonth/this-value-valid-date-date.js
+ * - test/built-ins/Date/prototype/setFullYear/this-value-valid-date-year.js
+ * - test/built-ins/Date/prototype/setFullYear/this-value-valid-date-month.js
+ * - test/built-ins/Date/prototype/setFullYear/this-value-valid-date-date.js
+ * - test/built-ins/Date/prototype/setFullYear/this-value-invalid-date.js
+ * - test/built-ins/Date/prototype/setFullYear/arg-year-to-number-err.js
+ * - test/built-ins/Date/prototype/setFullYear/arg-month-to-number-err.js
+ * - test/built-ins/Date/prototype/setFullYear/arg-date-to-number-err.js
+ * - test/built-ins/Date/prototype/setUTCFullYear/date-value-read-before-tonumber-when-date-is-invalid.js
+ * - test/built-ins/Date/prototype/setMonth/arg-coercion-order.js
+ * - test/language/expressions/addition/S11.6.1_A2.2_T2.js
+ *
+ * Copyright (C) 2016 the V8 project authors. All rights reserved.
+ * Copyright (C) 2021 Kevin Gibbons. All rights reserved.
+ * Copyright (C) 2024 André Bargull. All rights reserved.
+ * Copyright 2009 the Sputnik authors. All rights reserved.
+ * Test262 portions are governed by the BSD license in LICENSE.test262.
+ */
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { CodeMode } from "../src/index.js"
+
+const value = async (code: string) => {
+  const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
+  if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
+  return result.value
+}
+
+describe("Date setter Test262 parity", () => {
+  test("UTC setters mutate every supported component and return the new time", async () => {
+    expect(
+      await value(`
+        const create = () => new Date(Date.UTC(2000, 0, 2, 3, 4, 5, 6))
+        const result = []
+        let date = create()
+        result.push([date.setTime(1234) === date.getTime(), date.getTime()])
+        date = create()
+        result.push([date.setUTCMilliseconds(10) === date.getTime(), date.getUTCMilliseconds()])
+        date = create()
+        result.push([date.setUTCSeconds(10, 11) === date.getTime(), date.getUTCSeconds(), date.getUTCMilliseconds()])
+        date = create()
+        result.push([date.setUTCMinutes(10, 11, 12) === date.getTime(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()])
+        date = create()
+        result.push([date.setUTCHours(10, 11, 12, 13) === date.getTime(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), date.getUTCMilliseconds()])
+        date = create()
+        result.push([date.setUTCDate(10) === date.getTime(), date.getUTCDate()])
+        date = create()
+        result.push([date.setUTCMonth(6, 10) === date.getTime(), date.getUTCMonth(), date.getUTCDate()])
+        date = create()
+        result.push([date.setUTCFullYear(2020, 6, 10) === date.getTime(), date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()])
+        return result
+      `),
+    ).toEqual([
+      [true, 1234],
+      [true, 10],
+      [true, 10, 11],
+      [true, 10, 11, 12],
+      [true, 10, 11, 12, 13],
+      [true, 10],
+      [true, 6, 10],
+      [true, 2020, 6, 10],
+    ])
+  })
+
+  test("local setters mutate every supported component", async () => {
+    expect(
+      await value(`
+        const create = () => new Date(2000, 0, 2, 3, 4, 5, 6)
+        const result = []
+        let date = create()
+        date.setMilliseconds(10)
+        result.push(date.getMilliseconds())
+        date = create()
+        date.setSeconds(10, 11)
+        result.push([date.getSeconds(), date.getMilliseconds()])
+        date = create()
+        date.setMinutes(10, 11, 12)
+        result.push([date.getMinutes(), date.getSeconds(), date.getMilliseconds()])
+        date = create()
+        date.setHours(10, 11, 12, 13)
+        result.push([date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()])
+        date = create()
+        date.setDate(10)
+        result.push(date.getDate())
+        date = create()
+        date.setMonth(6, 10)
+        result.push([date.getMonth(), date.getDate()])
+        date = create()
+        date.setFullYear(2020, 6, 10)
+        result.push([date.getFullYear(), date.getMonth(), date.getDate()])
+        return result
+      `),
+    ).toEqual([10, [10, 11], [10, 11, 12], [10, 11, 12, 13], 10, [6, 10], [2020, 6, 10]])
+  })
+
+  test("setter arguments use numeric coercion in source order", async () => {
+    expect(
+      await value(`
+        const calls = []
+        const date = new Date(0)
+        const returned = date.setUTCSeconds(
+          { valueOf: () => { calls.push("seconds"); return "2" } },
+          { valueOf: () => { calls.push("milliseconds"); return true } },
+        )
+        const fallback = new Date(0)
+        fallback.setTime({ valueOf: () => ({}), toString: () => "3" })
+        return [returned === date.getTime(), date.getUTCSeconds(), date.getUTCMilliseconds(), fallback.getTime(), calls]
+      `),
+    ).toEqual([true, 2, 1, 3, ["seconds", "milliseconds"]])
+  })
+
+  test("setters snapshot the Date before coercing arguments", async () => {
+    expect(
+      await value(`
+        const date = new Date(Date.UTC(2000, 5, 15, 1, 2, 3, 4))
+        date.setUTCFullYear({
+          valueOf: () => {
+            date.setTime(Date.UTC(2020, 11, 20))
+            return 2001
+          },
+        })
+
+        const invalid = new Date(NaN)
+        invalid.setUTCFullYear({ valueOf: () => { invalid.setTime(0); return 1 } })
+
+        const effects = []
+        const staysInvalid = new Date(NaN)
+        const result = staysInvalid.setUTCMonth(
+          { valueOf: () => { effects.push("month"); return 0 } },
+          { valueOf: () => { effects.push("date"); return 1 } },
+        )
+        return [
+          date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(),
+          invalid.getUTCFullYear(), invalid.getUTCMonth(), invalid.getUTCDate(),
+          Number.isNaN(result), Number.isNaN(staysInvalid.getTime()), effects,
+        ]
+      `),
+    ).toEqual([2001, 5, 15, 1, 0, 1, true, true, ["month", "date"]])
+  })
+
+  test("setFullYear recovers an invalid local Date", async () => {
+    expect(
+      await value(`
+        const year = new Date(NaN)
+        const yearResult = year.setFullYear(2016)
+        const month = new Date(NaN)
+        const monthResult = month.setFullYear(2016, 6)
+        const day = new Date(NaN)
+        const dayResult = day.setFullYear(2016, 6, 7)
+        return [
+          yearResult === year.getTime(), year.getFullYear(), year.getMonth(), year.getDate(),
+          monthResult === month.getTime(), month.getFullYear(), month.getMonth(), month.getDate(),
+          dayResult === day.getTime(), day.getFullYear(), day.getMonth(), day.getDate(),
+        ]
+      `),
+    ).toEqual([true, 2016, 0, 1, true, 2016, 6, 1, true, 2016, 6, 7])
+  })
+
+  test("abrupt argument coercion stops later coercion and preserves the Date", async () => {
+    expect(
+      await value(`
+        const calls = []
+        const failure = { valueOf: () => { throw new Error("stop") } }
+        const counter = { valueOf: () => { calls.push("counter"); return 1 } }
+        const results = []
+
+        const time = new Date(0)
+        try { time.setTime(failure) } catch (error) { results.push(error.message) }
+        results.push(time.getTime())
+
+        const year = new Date(0)
+        try { year.setFullYear(failure, counter, counter) } catch (error) { results.push(error.message) }
+        results.push(year.getTime())
+
+        const month = new Date(0)
+        try { month.setFullYear(0, failure, counter) } catch (error) { results.push(error.message) }
+        results.push(month.getTime())
+
+        const day = new Date(0)
+        try { day.setFullYear(0, 0, failure) } catch (error) { results.push(error.message) }
+        results.push(day.getTime(), calls)
+        return results
+      `),
+    ).toEqual(["stop", 0, "stop", 0, "stop", 0, "stop", 0, []])
+  })
+
+  test("omitted arguments, explicit undefined, extra arguments, and TimeClip match native behavior", async () => {
+    expect(
+      await value(`
+        const calls = []
+        const omittedOptional = new Date(0)
+        const explicitUndefined = new Date(0)
+        const noArgument = new Date(0)
+        const extra = new Date(0)
+        const clipped = new Date(0)
+        const badPrimitive = new Date(0)
+        let conversionError = ""
+        try {
+          badPrimitive.setTime({ valueOf: () => ({}), toString: () => ({}) })
+        } catch (error) {
+          conversionError = error.name
+        }
+        return [
+          omittedOptional.setUTCSeconds(1),
+          Number.isNaN(explicitUndefined.setUTCSeconds(1, undefined)),
+          Number.isNaN(noArgument.setTime()),
+          extra.setTime(1, { valueOf: () => { calls.push("extra"); return 2 } }),
+          calls,
+          Number.isNaN(clipped.setTime(8.64e15 + 1)),
+          Number.isNaN(clipped.getTime()),
+          conversionError,
+        ]
+      `),
+    ).toEqual([1000, true, true, 1, [], true, true, "TypeError"])
+  })
+})
+
+describe("Date default primitive Test262 parity", () => {
+  test("addition and loose equality use the Date string primitive", async () => {
+    expect(
+      await value(`
+        const date = new Date(0)
+        const text = String(date)
+        return [
+          date + date === text + text,
+          date + 0 === text + "0",
+          date + true === text + "true",
+          date == text,
+          text == date,
+          date == Number(date),
+          Number(date) == date,
+          date != Number(date),
+          date == date,
+          date == new Date(0),
+          date == null,
+        ]
+      `),
+    ).toEqual([true, true, true, true, true, false, false, true, true, false, false])
+  })
+})