From 10ae321bf33a384ff4efa5ad5cd4d464aeac4a16 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:36:35 -0500 Subject: [PATCH] refactor(codemode): give programs their own objects and arrays (#48527) --- packages/codemode/interpreter-support.md | 13 +- packages/codemode/src/data.ts | 68 +++-- packages/codemode/src/interpreter/errors.ts | 15 +- packages/codemode/src/interpreter/methods.ts | 119 ++++----- packages/codemode/src/interpreter/model.ts | 4 +- packages/codemode/src/interpreter/objects.ts | 120 +++++++++ packages/codemode/src/interpreter/promises.ts | 27 +- .../codemode/src/interpreter/references.ts | 21 +- packages/codemode/src/interpreter/runner.ts | 10 +- packages/codemode/src/interpreter/runtime.ts | 245 ++++++++---------- packages/codemode/src/stdlib/array.ts | 35 ++- packages/codemode/src/stdlib/collections.ts | 20 +- packages/codemode/src/stdlib/console.ts | 29 ++- packages/codemode/src/stdlib/json.ts | 70 ++--- packages/codemode/src/stdlib/object.ts | 82 +++--- packages/codemode/src/stdlib/regexp.ts | 51 ++-- packages/codemode/src/stdlib/url.ts | 6 +- packages/codemode/src/stdlib/value.ts | 51 ++-- .../test/object-coercion-test262.test.ts | 11 +- packages/codemode/test/parity.test.ts | 31 +-- packages/codemode/test/stdlib.test.ts | 112 +------- packages/codemode/test/test262/run.ts | 12 +- packages/codemode/test/test262/skipped.txt | 59 ++--- 23 files changed, 571 insertions(+), 640 deletions(-) create mode 100644 packages/codemode/src/interpreter/objects.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index e05fdbd9d1fc..3b3db4ffd032 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -243,7 +243,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS. - [x] `Object.is` for supported data values. - [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion - and null-prototype results. + and plain-object results. - [ ] `Object.prototype` methods on values: `toString`, `toLocaleString`, `valueOf`, and `hasOwnProperty`. ## Arrays @@ -264,12 +264,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `length`, numeric indexing, index assignment, spread, and `for...of`. - [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows. - [x] `Array.prototype.toSpliced`. -- [x] Canonical array/string index parsing: keys such as `"01"` remain non-index properties rather than aliasing index - `1`; arbitrary array-property assignment remains unsupported. +- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index + `1`. - [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements, like JavaScript. -- [ ] Assigning `length` to truncate or extend an array. -- [ ] Non-index own properties on arrays (`arr.foo = 1`, `arr.constructor = null`). +- [x] Assigning `length` to truncate or extend an array; invalid lengths throw `RangeError`. +- [x] Non-index own properties on arrays (`arr.foo = 1`, `arr.constructor = null`). They are excluded from the JSON + form, like `JSON.stringify`. - [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join` separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for @@ -358,7 +359,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262 - [x] `test`, `exec`, and `toString`. - [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`, `unicodeSets`, and `dotAll`. -- [x] Captures, named groups, match `.index`, and stateful global matching. +- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching. - [x] Integration with supported String methods, including function replacers. - [x] Writable `lastIndex`. - [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results. diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 4eb98f25d347..3cd2fff21871 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -1,11 +1,9 @@ export * as Data from "./data.js" import type { DiagnosticKind } from "./codemode.js" +import { ownEntries, parseArrayIndex, ProgramArray, ProgramObject, set } from "./interpreter/objects.js" import { Values } from "./values.js" -/** A null-prototype object owned by the program. */ -export type SafeObject = Record - const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { @@ -23,15 +21,15 @@ export class ToolRuntimeError extends Error { } /** - * Brings a host-produced runtime value into the program: runtime values pass through, their host - * counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become - * null-prototype copies. Arrays keep extra enumerable properties such as `index` and `groups`. + * Brings a host-produced value into the program: program and runtime values pass through, their + * host counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and host objects + * and arrays are copied. */ export const toProgram = (value: unknown, label: string): unknown => copy(value, label, "program", 0, new Set()) /** * Brings host data into the program: Date and URL become strings, other host collections become - * empty objects, and objects become null-prototype copies. Used for tool results and parsed JSON. + * empty objects, and objects become program copies. Used for tool results and parsed JSON. */ export const fromData = (value: unknown, label: string): unknown => copy(value, label, "data", 0, new Set()) @@ -44,8 +42,7 @@ export const fromData = (value: unknown, label: string): unknown => copy(value, export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown => copy(value, label, undefinedAs, 0, new Set()) -// "program" and "data" build program-owned null-prototype objects; "json" and "result" build -// ordinary objects for the host. +// "program" and "data" build program objects; "json" and "result" build ordinary objects for the host. type Mode = "program" | "data" | "json" | "result" const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Set): unknown => { @@ -65,8 +62,9 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se ) } + const plain = mode === "program" || mode === "data" if (mode === "program") { - if (Values.isValue(value)) return value + if (value instanceof ProgramObject || Values.isValue(value)) return value if (value instanceof Date) return new Values.Date(value.getTime()) if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags) if (value instanceof Map) { @@ -85,7 +83,6 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value)) } - const plain = mode === "program" || mode === "data" if (value instanceof Values.Date) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null if (value instanceof Values.URL) return value.url.href @@ -98,7 +95,7 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se value instanceof Set || value instanceof URLSearchParams ) { - return plain ? (Object.create(null) as SafeObject) : {} + return plain ? new ProgramObject() : {} } if (seen.has(value)) { @@ -106,27 +103,48 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se } seen.add(value) - if (Array.isArray(value)) { - // Host output densifies holes to null like JSON; program copies keep them. - const copied = plain - ? value.map((item) => copy(item, label, mode, depth + 1, seen)) - : Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null) - if (mode === "program") { - for (const [key, item] of Object.entries(value)) { - if (Object.hasOwn(copied, key)) continue - define(copied, key, copy(item, label, mode, depth + 1, seen)) - } + if (value instanceof ProgramArray) { + const copied = Array.from(value.items, (item) => copy(item, label, mode, depth + 1, seen) ?? null) + seen.delete(value) + return copied + } + if (value instanceof ProgramObject) { + const copied: Record = {} + for (const [key, item] of ownEntries(value)) { + const next = copy(item, label, mode, depth + 1, seen) + if (next === undefined && mode === "json") continue + define(copied, key, next) } seen.delete(value) return copied } + if (Array.isArray(value)) { + if (plain) { + const copied = new ProgramArray(value.map((item) => copy(item, label, mode, depth + 1, seen))) + for (const [key, item] of Object.entries(value)) { + if (parseArrayIndex(key) === undefined) set(copied, key, copy(item, label, mode, depth + 1, seen)) + } + seen.delete(value) + return copied + } + const copied = Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null) + seen.delete(value) + return copied + } + const prototype = Object.getPrototypeOf(value) if (prototype !== Object.prototype && prototype !== null) { throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) } - const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {} + if (plain) { + const copied = new ProgramObject() + for (const [key, item] of Object.entries(value)) set(copied, key, copy(item, label, mode, depth + 1, seen)) + seen.delete(value) + return copied + } + const copied: Record = {} for (const [key, item] of Object.entries(value)) { const next = copy(item, label, mode, depth + 1, seen) if (next === undefined && mode === "json") continue @@ -136,8 +154,8 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se return copied } -// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or -// array never reaches the Object.prototype setter. +// Own data property regardless of the target's prototype, so a "__proto__" key on a host object +// never reaches the Object.prototype setter. const define = (target: object, key: string, value: unknown): void => { Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) } diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 45fe0c67ad52..3367e2183c0c 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -1,10 +1,11 @@ import { Effect } from "effect" import type { Diagnostic } from "../codemode.js" import { ToolError } from "../tool-error.js" -import { type SafeObject, toData, ToolRuntimeError } from "../data.js" +import { toData, ToolRuntimeError } from "../data.js" import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js" import { containsRuntimeReference } from "./references.js" import { type HostCall, HostFunction } from "./host.js" +import { get, ProgramError, ProgramObject } from "./objects.js" import { type Runner } from "./runner.js" import { coerceToString, @@ -44,12 +45,8 @@ export const normalizeError = (error: unknown): Diagnostic => { message = "a non-data value" } else if (typeof value === "string") { message = value - } else if ( - value !== null && - typeof value === "object" && - typeof (value as { message?: unknown }).message === "string" - ) { - message = (value as { message: string }).message + } else if (value instanceof ProgramObject && typeof get(value, "message") === "string") { + message = get(value, "message") as string } else { try { message = JSON.stringify(toData(value, "Thrown value")) ?? String(value) @@ -87,14 +84,14 @@ export const caughtErrorValue = (thrown: unknown): unknown => { return createErrorValue(name, normalizeError(thrown).message) } -const constructErrorValue = (name: string, args: Array): SafeObject => +const constructErrorValue = (name: string, args: Array): ProgramError => createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0])) const constructAggregateErrorValue = ( runner: Runner, args: Array, node: AstNode, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const cursor = yield* runner.syncIterator(args[0], node) if (cursor === undefined) { diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index 5d0c4bdbb415..40b9ea95272e 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -1,13 +1,14 @@ import { Effect } from "effect" -import { type SafeObject, toProgram } from "../data.js" +import { toProgram } from "../data.js" import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js" import { invokeNumberMethod } from "../stdlib/number.js" import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js" import { invokeURLMethod, uriArgument } from "../stdlib/url.js" -import { coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js" +import { coerceToNumber, coerceToString } from "../stdlib/value.js" import { compareText } from "../tool-runtime.js" import { Values } from "../values.js" import { type AstNode, IntrinsicReference, InterpreterRuntimeError } from "./model.js" +import { get, ProgramArray, ProgramObject, record } from "./objects.js" import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js" import { applyCollectionCallback, isSupportedCallback, type Runner, toPrimitive } from "./runner.js" @@ -16,6 +17,14 @@ export const invokeIntrinsic = ( ref: IntrinsicReference, args: Array, node: AstNode, +): Effect.Effect => + Effect.map(invoke(runner, ref, args, node), (result) => (Array.isArray(result) ? new ProgramArray(result) : result)) + +const invoke = ( + runner: Runner, + ref: IntrinsicReference, + args: Array, + node: AstNode, ): Effect.Effect => { if (typeof ref.receiver === "string") { if (ref.name === "replace" || ref.name === "replaceAll") { @@ -32,7 +41,7 @@ export const invokeIntrinsic = ( if (typeof ref.receiver === "number") { return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) } - if (Array.isArray(ref.receiver)) { + if (ref.receiver instanceof ProgramArray) { return invokeArrayMethod(runner, ref.receiver, ref.name, args, node) } if (ref.receiver instanceof Values.Date) { @@ -215,7 +224,7 @@ const invokeStringMethod = (value: string, name: string, args: Array, n node, ) } - return Array.from(value.matchAll(pattern), matchToValue) + return new ProgramArray(Array.from(value.matchAll(pattern), matchToValue)) } case "search": { result = value.search(toHostRegex(args[0], name, node)) @@ -288,13 +297,7 @@ const invokeStringReplacer = ( if (typeof match !== "string" || typeof offset !== "number") { throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node) } - if (hasGroups) { - const safeGroups: SafeObject = Object.create(null) as SafeObject - for (const [key, group] of Object.entries(groups)) { - safeGroups[key] = group - } - callbackArgs[callbackArgs.length - 1] = safeGroups - } + if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(groups as Record) matches.push({ match, offset, args: callbackArgs }) return match } @@ -320,14 +323,11 @@ const invokeStringReplacer = ( let end = 0 for (const match of matches) { const replacement = yield* apply(match.args) - // Error values are branded plain objects; toProgram would strip the brand before coercion. output.push( value.slice(end, match.offset), replacement instanceof Values.Promise ? "[object Promise]" - : errorBrandName(replacement) - ? coerceToString(replacement) - : coerceToString(toProgram(replacement, `String.${name} replacer result`)), + : coerceToString(toProgram(replacement, `String.${name} replacer result`)), ) end = match.offset + match.match.length } @@ -365,7 +365,7 @@ const invokeMapMethod = ( case "values": return Effect.sync(() => Array.from(target.map.values())) case "entries": - return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) + return Effect.sync(() => Array.from(target.map.entries(), ([key, item]) => new ProgramArray([key, item]))) case "forEach": { const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node) return Effect.gen(function* () { @@ -404,7 +404,7 @@ const invokeSetMethod = ( case "values": return Effect.sync(() => Array.from(target.set.values())) case "entries": - return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) + return Effect.sync(() => Array.from(target.set.values(), (item) => new ProgramArray([item, item]))) case "forEach": { const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node) return Effect.gen(function* () { @@ -518,28 +518,27 @@ const loadSetRecord = (runner: Runner, source: unknown, name: string, node keys: () => Effect.succeed(source.map.keys()), }) } - if (source === null || typeof source !== "object" || Values.isValue(source)) { + if (!(source instanceof ProgramObject)) { throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError") } - const object = source as Record return Effect.gen(function* () { - const size = yield* coerceNumericArgument(runner, object.size, node) + const size = yield* coerceNumericArgument(runner, get(source, "size"), node) if (Number.isNaN(size)) { throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node).as( "TypeError", ) } - if (!isSupportedCallback(object.has) || !isSupportedCallback(object.keys)) { + const has = get(source, "has") + const keys = get(source, "keys") + if (!isSupportedCallback(has) || !isSupportedCallback(keys)) { throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node).as("TypeError") } - const has = object.has - const keys = object.keys return { size: Math.max(Math.trunc(size), 0), has: (item: unknown) => Effect.map(runner.invokeCallable(has, [item], node), Boolean), keys: () => Effect.flatMap(runner.invokeCallable(keys, [], node), (result) => { - if (Array.isArray(result)) return Effect.succeed(result) + if (result instanceof ProgramArray) return Effect.succeed(result.items) throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node).as("TypeError") }), } @@ -604,7 +603,7 @@ const invokeURLSearchParamsMethod = ( case "values": return Effect.sync(() => Array.from(target.params.values())) case "entries": - return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array => [key, value])) + return Effect.sync(() => Array.from(target.params.entries(), ([key, value]) => new ProgramArray([key, value]))) case "toString": return Effect.sync(() => target.params.toString()) case "forEach": { @@ -622,11 +621,12 @@ const invokeURLSearchParamsMethod = ( const invokeArrayMethod = ( runner: Runner, - target: Array, + receiver: ProgramArray, name: string, args: Array, node: AstNode, ): Effect.Effect => { + const target = receiver.items const optNumber = (value: unknown, label: string): number | undefined => { if (value === undefined) return undefined if (typeof value !== "number") @@ -638,9 +638,8 @@ const invokeArrayMethod = ( if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) } - const input = toProgram(target, "Array.join input") as Array return Effect.succeed( - input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), + target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)), ) } case "includes": @@ -660,11 +659,15 @@ const invokeArrayMethod = ( case "slice": return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) case "concat": - return Effect.succeed(target.concat(...args)) - case "flat": - return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) + return Effect.succeed(target.concat(...args.map((item) => (item instanceof ProgramArray ? item.items : item)))) + case "flat": { + const flatten = (items: Array, depth: number): Array => + items.flatMap((item) => (item instanceof ProgramArray && depth > 0 ? flatten(item.items, depth - 1) : [item])) + return Effect.succeed(flatten(target, optNumber(args[0], "depth") ?? 1)) + } case "reverse": - return Effect.succeed(target.reverse()) + target.reverse() + return Effect.succeed(receiver) case "sort": { const length = target.length const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length @@ -676,7 +679,7 @@ const invokeArrayMethod = ( Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => { Reflect.deleteProperty(target, index) }) - return target + return receiver }) } case "toSorted": @@ -695,12 +698,12 @@ const invokeArrayMethod = ( } case "push": { // Validate all insertions before mutating to avoid partial cyclic updates. - for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node) + for (const item of args) rejectCircularInsertion(receiver, item, "Array.push result", node) target.push(...args) return Effect.succeed(target.length) } case "unshift": { - for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node) + for (const item of args) rejectCircularInsertion(receiver, item, "Array.unshift result", node) target.unshift(...args) return Effect.succeed(target.length) } @@ -714,7 +717,7 @@ const invokeArrayMethod = ( if (args.length === 1) return Effect.succeed(target.splice(start)) const deleteCount = optNumber(args[1], "delete count") ?? 0 const inserted = args.slice(2) - for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node) + for (const item of inserted) rejectCircularInsertion(receiver, item, "Array.splice result", node) return Effect.succeed(target.splice(start, deleteCount, ...inserted)) } case "toSpliced": { @@ -731,23 +734,23 @@ const invokeArrayMethod = ( return Effect.succeed(copied) } case "fill": { - rejectCircularInsertion(target, args[0], "Array.fill result", node) - return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + rejectCircularInsertion(receiver, args[0], "Array.fill result", node) + target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")) + return Effect.succeed(receiver) } case "copyWithin": - return Effect.succeed( - target.copyWithin( - optNumber(args[0], "target index") ?? 0, - optNumber(args[1], "start") ?? 0, - optNumber(args[2], "end"), - ), + target.copyWithin( + optNumber(args[0], "target index") ?? 0, + optNumber(args[1], "start") ?? 0, + optNumber(args[2], "end"), ) + return Effect.succeed(receiver) case "keys": return Effect.succeed(Array.from(target.keys())) case "values": return Effect.succeed([...target]) case "entries": - return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) + return Effect.succeed(Array.from(target.entries(), ([index, item]) => new ProgramArray([index, item]))) } const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node) @@ -760,7 +763,7 @@ const invokeArrayMethod = ( values.length = length for (let index = 0; index < length; index += 1) { if (!(index in target)) continue - values[index] = yield* apply([target[index], index, target]) + values[index] = yield* apply([target[index], index, receiver]) } return values } @@ -768,8 +771,8 @@ const invokeArrayMethod = ( const values: Array = [] for (let index = 0; index < length; index += 1) { if (!(index in target)) continue - const mapped = yield* apply([target[index], index, target]) - if (Array.isArray(mapped)) values.push(...mapped) + const mapped = yield* apply([target[index], index, receiver]) + if (mapped instanceof ProgramArray) values.push(...mapped.items) else values.push(mapped) } return values @@ -779,36 +782,36 @@ const invokeArrayMethod = ( for (let index = 0; index < length; index += 1) { if (!(index in target)) continue const item = target[index] - if (yield* apply([item, index, target])) values.push(item) + if (yield* apply([item, index, receiver])) values.push(item) } return values } case "find": for (let index = 0; index < length; index += 1) { const item = target[index] - if (yield* apply([item, index, target])) return item + if (yield* apply([item, index, receiver])) return item } return undefined case "findIndex": for (let index = 0; index < length; index += 1) { - if (yield* apply([target[index], index, target])) return index + if (yield* apply([target[index], index, receiver])) return index } return -1 case "some": for (let index = 0; index < length; index += 1) { if (!(index in target)) continue - if (yield* apply([target[index], index, target])) return true + if (yield* apply([target[index], index, receiver])) return true } return false case "every": for (let index = 0; index < length; index += 1) { if (!(index in target)) continue - if (!(yield* apply([target[index], index, target]))) return false + if (!(yield* apply([target[index], index, receiver]))) return false } return true case "forEach": for (let index = 0; index < length; index += 1) { - if (index in target) yield* apply([target[index], index, target]) + if (index in target) yield* apply([target[index], index, receiver]) } return undefined case "reduce": { @@ -825,7 +828,7 @@ const invokeArrayMethod = ( } for (let index = start; index < length; index += 1) { if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) + accumulator = yield* apply([accumulator, target[index], index, receiver]) } return accumulator } @@ -843,19 +846,19 @@ const invokeArrayMethod = ( } for (let index = start; index >= 0; index -= 1) { if (!(index in target)) continue - accumulator = yield* apply([accumulator, target[index], index, target]) + accumulator = yield* apply([accumulator, target[index], index, receiver]) } return accumulator } case "findLast": for (let index = length - 1; index >= 0; index -= 1) { const item = target[index] - if (yield* apply([item, index, target])) return item + if (yield* apply([item, index, receiver])) return item } return undefined case "findLastIndex": for (let index = length - 1; index >= 0; index -= 1) { - if (yield* apply([target[index], index, target])) return index + if (yield* apply([target[index], index, receiver])) return index } return -1 } diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 889a75b1e6b0..ee8e652da81c 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -1,7 +1,7 @@ import type { BlockStatement, Expression, Node, Pattern } from "acorn" import type { Effect } from "effect" import type { DiagnosticKind } from "../codemode.js" -import type { SafeObject } from "../data.js" +import type { ProgramObject } from "./objects.js" import type { Values } from "../values.js" /** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */ @@ -20,7 +20,7 @@ export type StatementResult = | { kind: "continue"; label?: string } export type MemberReference = { - target: SafeObject | Array | Values.RegExp | Values.URL + target: ProgramObject | Values.RegExp | Values.URL key: PropertyKey } diff --git a/packages/codemode/src/interpreter/objects.ts b/packages/codemode/src/interpreter/objects.ts new file mode 100644 index 000000000000..5ced5e7567fc --- /dev/null +++ b/packages/codemode/src/interpreter/objects.ts @@ -0,0 +1,120 @@ +import { AsyncIteratorSymbol, IteratorSymbol } from "./model.js" + +/** An object owned by the program: own properties plus a prototype link. */ +export class ProgramObject { + readonly props = new Map() + constructor(public proto: ProgramObject | null = null) {} +} + +export class ProgramArray extends ProgramObject { + constructor(readonly items: Array = []) { + super() + } +} + +export class ProgramError extends ProgramObject { + constructor(readonly errorName: string) { + super() + } +} + +const MAX_ARRAY_LENGTH = 4_294_967_295 + +export const parseArrayIndex = (key: string | number): number | undefined => { + const property = String(key) + if (!/^(0|[1-9]\d*)$/.test(property)) return undefined + const index = Number(property) + return index < MAX_ARRAY_LENGTH ? index : undefined +} + +const canonical = (key: PropertyKey): string | symbol => (typeof key === "symbol" ? key : String(key)) + +const index = (target: ProgramObject, key: string | symbol): number | undefined => + target instanceof ProgramArray && typeof key === "string" ? parseArrayIndex(key) : undefined + +export const hasOwn = (target: ProgramObject, key: PropertyKey): boolean => { + const name = canonical(key) + const at = index(target, name) + if (at !== undefined) return at in (target as ProgramArray).items + if (name === "length" && target instanceof ProgramArray) return true + return target.props.has(name) +} + +export const getOwn = (target: ProgramObject, key: PropertyKey): unknown => { + const name = canonical(key) + const at = index(target, name) + if (at !== undefined) return (target as ProgramArray).items[at] + if (name === "length" && target instanceof ProgramArray) return target.items.length + return target.props.get(name) +} + +export const get = (target: ProgramObject, key: PropertyKey): unknown => { + for (let current: ProgramObject | null = target; current !== null; current = current.proto) { + if (hasOwn(current, key)) return getOwn(current, key) + } + return undefined +} + +export const has = (target: ProgramObject, key: PropertyKey): boolean => { + for (let current: ProgramObject | null = target; current !== null; current = current.proto) { + if (hasOwn(current, key)) return true + } + return false +} + +export const set = (target: ProgramObject, key: PropertyKey, value: unknown): boolean => { + const name = canonical(key) + const at = index(target, name) + if (at !== undefined) { + ;(target as ProgramArray).items[at] = value + return true + } + if (name === "length" && target instanceof ProgramArray) { + const length = typeof value === "number" ? value : Number(value) + if (!Number.isInteger(length) || length < 0 || length > 4_294_967_295) return false + target.items.length = length + return true + } + target.props.set(name, value) + return true +} + +export const remove = (target: ProgramObject, key: PropertyKey): boolean => { + const name = canonical(key) + const at = index(target, name) + if (at !== undefined) return delete (target as ProgramArray).items[at] + if (name === "length" && target instanceof ProgramArray) return false + target.props.delete(name) + return true +} + +// JS order: array indexes, integer-like keys ascending, other strings, then symbols. +export const ownKeys = (target: ProgramObject): Array => { + const strings = [...target.props.keys()].filter((key): key is string => typeof key === "string") + const symbols = [...target.props.keys()].filter((key): key is symbol => typeof key === "symbol") + return [ + ...(target instanceof ProgramArray ? Object.keys(target.items) : []), + ...strings.filter((key) => parseArrayIndex(key) !== undefined).sort((a, b) => Number(a) - Number(b)), + ...strings.filter((key) => parseArrayIndex(key) === undefined), + ...symbols, + ] +} + +export const ownEntries = (target: ProgramObject): Array<[string, unknown]> => + ownKeys(target) + .filter((key): key is string => typeof key === "string") + .map((key) => [key, getOwn(target, key)]) + +export const record = (entries: Record): ProgramObject => { + const target = new ProgramObject() + for (const [key, value] of Object.entries(entries)) set(target, key, value) + return target +} + +export const assign = (target: ProgramObject, source: ProgramObject, skip?: ReadonlySet): void => { + for (const key of ownKeys(source)) { + if (skip?.has(key)) continue + if (typeof key === "symbol" && key !== IteratorSymbol && key !== AsyncIteratorSymbol) continue + set(target, key, getOwn(source, key)) + } +} diff --git a/packages/codemode/src/interpreter/promises.ts b/packages/codemode/src/interpreter/promises.ts index effad8a8e6da..e2a03481f35e 100644 --- a/packages/codemode/src/interpreter/promises.ts +++ b/packages/codemode/src/interpreter/promises.ts @@ -1,6 +1,5 @@ import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect" import type { Diagnostic } from "../codemode.js" -import type { SafeObject } from "../data.js" import { type AstNode, CodeModeFunction, @@ -8,6 +7,7 @@ import { ProgramThrow, PromiseInstanceMethodReference, } from "./model.js" +import { get, ProgramArray, ProgramObject, record } from "./objects.js" import { HostFunction, requiresNew, sync } from "./host.js" import { caughtErrorValue, normalizeError } from "./errors.js" import { typeofValue } from "./references.js" @@ -111,8 +111,8 @@ export const resolvePromiseValue = ( ): Effect.Effect => { if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node)) if (value instanceof Values.Promise) return runner.settlePromise(value) - if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value) - const then = (value as SafeObject).then + if (!(value instanceof ProgramObject)) return Effect.succeed(value) + const then = get(value, "then") if (typeofValue(then) !== "function") return Effect.succeed(value) return Effect.gen(function* () { @@ -174,10 +174,12 @@ const invokePromiseMethod = ( } if (name === "all") { - return yield* settleAfterTurn( - Effect.all( - items.map((item) => Effect.flatten(promises.await(item))), - { concurrency: "unbounded" }, + return new ProgramArray( + yield* settleAfterTurn( + Effect.all( + items.map((item) => Effect.flatten(promises.await(item))), + { concurrency: "unbounded" }, + ), ), ) } @@ -186,19 +188,14 @@ const invokePromiseMethod = ( for (const item of items) { const exit = yield* promises.await(item) if (Exit.isSuccess(exit)) { - outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) + outcomes.push(record({ status: "fulfilled", value: exit.value })) continue } if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause) - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(Cause.squash(exit.cause)), - }), - ) + outcomes.push(record({ status: "rejected", reason: caughtErrorValue(Cause.squash(exit.cause)) })) } yield* Effect.yieldNow - return outcomes + return new ProgramArray(outcomes) } if (name === "race") { if (items.length === 0) { diff --git a/packages/codemode/src/interpreter/references.ts b/packages/codemode/src/interpreter/references.ts index 318e5a70656f..a908e13a1979 100644 --- a/packages/codemode/src/interpreter/references.ts +++ b/packages/codemode/src/interpreter/references.ts @@ -3,15 +3,14 @@ import { Values } from "../values.js" import { HostFunction, HostNamespace } from "./host.js" import { type AstNode, - AsyncIteratorSymbol, CodeModeFunction, CodeModeGenerator, GeneratorMethodReference, InterpreterRuntimeError, IntrinsicReference, - IteratorSymbol, PromiseInstanceMethodReference, } from "./model.js" +import { getOwn, ownKeys, ProgramArray, ProgramObject } from "./objects.js" export const isRuntimeReference = (value: unknown): boolean => value instanceof HostFunction || @@ -26,11 +25,8 @@ export const isRuntimeReference = (value: unknown): boolean => Values.isValue(value) function* childValues(value: object): Generator { - for (const key of Reflect.ownKeys(value)) { - if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue - if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue - yield Reflect.get(value, key) - } + if (!(value instanceof ProgramObject)) return + for (const key of ownKeys(value)) yield getOwn(value, key) } // Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it. @@ -79,7 +75,7 @@ export const rejectCircularInsertion = ( export const describeValue = (value: unknown): string => { if (value === null) return "null" - if (Array.isArray(value)) return "an array" + if (value instanceof ProgramArray) return "an array" if (value instanceof Values.Promise) return "an un-awaited Promise" if (value instanceof ToolReference) return "a tool reference" if (value instanceof Values.Date) return "a Date" @@ -108,12 +104,3 @@ export const typeofValue = (value: unknown): string => { if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" return typeof value } - -const MAX_ARRAY_LENGTH = 4_294_967_295 - -export const parseArrayIndex = (key: string | number): number | undefined => { - const property = String(key) - if (!/^(0|[1-9]\d*)$/.test(property)) return undefined - const index = Number(property) - return index < MAX_ARRAY_LENGTH ? index : undefined -} diff --git a/packages/codemode/src/interpreter/runner.ts b/packages/codemode/src/interpreter/runner.ts index 55c38e884778..cb36153a9dac 100644 --- a/packages/codemode/src/interpreter/runner.ts +++ b/packages/codemode/src/interpreter/runner.ts @@ -3,6 +3,7 @@ import { Values } from "../values.js" import { coerceToString } from "../stdlib/value.js" import { HostFunction } from "./host.js" import { type AstNode, CodeModeFunction, InterpreterRuntimeError, IntrinsicReference } from "./model.js" +import { get, has, ProgramObject } from "./objects.js" import { typeofValue } from "./references.js" export type IteratorCursor = { @@ -42,13 +43,14 @@ export const toPrimitive = ( if (Values.isValue(value)) { return Effect.succeed(value instanceof Values.Date && hint === "number" ? value.time : coerceToString(value)) } - const object = value as Record + if (!(value instanceof ProgramObject)) return Effect.succeed(value) const order = hint === "number" ? ["valueOf", "toString"] : ["toString", "valueOf"] return Effect.gen(function* () { for (const method of order) { - if (method === "toString" && !Object.hasOwn(object, "toString")) return coerceToString(value) - if (!Object.hasOwn(object, method) || typeofValue(object[method]) !== "function") continue - const result = yield* runner.invokeCallable(object[method], [], node) + if (method === "toString" && !has(value, "toString")) return coerceToString(value) + const callable = get(value, method) + if (typeofValue(callable) !== "function") continue + const result = yield* runner.invokeCallable(callable, [], node) if (result === null || (typeof result !== "object" && typeof result !== "function")) return result } throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError") diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index a02d3c093561..03d97bec7ed4 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -41,7 +41,7 @@ import type { YieldExpression, } from "acorn" import { Cause, Deferred, Effect, Exit } from "effect" -import { ToolRuntimeError, type SafeObject, toProgram } from "../data.js" +import { ToolRuntimeError, toProgram } from "../data.js" import { ToolReference } from "../tool-runtime.js" import { type AstNode, @@ -55,9 +55,7 @@ import { type GeneratorRequestKind, IntrinsicReference, InterpreterRuntimeError, - isRecord, IteratorSymbol, - IteratorSymbols, type MemberReference, OptionalShortCircuit, PromiseInstanceMethodReference, @@ -69,13 +67,24 @@ import { caughtErrorValue } from "./errors.js" import { globals, type Host } from "./globals.js" import { HostFunction, HostNamespace } from "./host.js" import { invokeIntrinsic } from "./methods.js" +import { + assign, + get, + has, + ownKeys, + parseArrayIndex, + ProgramArray, + ProgramObject, + record, + remove, + set, +} from "./objects.js" import { preserveConsumerError, type Runner } from "./runner.js" import { invokePromiseInstanceMethod, PromiseRuntime, resolvePromise, resolvePromiseValue } from "./promises.js" import { containsOpaqueReference, describeValue, isRuntimeReference, - parseArrayIndex, rejectCircularInsertion, typeofValue, } from "./references.js" @@ -118,14 +127,11 @@ const calleeDescription = (callee: Expression | Super | undefined): string => { return "The called value" } -const hasOwn = (value: unknown, key: PropertyKey): boolean => - value !== null && typeof value === "object" && Object.hasOwn(value, key) - const constructorName = (value: unknown): string | undefined => { if (typeof value === "string") return "String" if (typeof value === "number") return "Number" if (typeof value === "boolean") return "Boolean" - if (Array.isArray(value)) return "Array" + if (value instanceof ProgramArray) return "Array" if (value instanceof Values.Date) return "Date" if (value instanceof Values.RegExp) return "RegExp" if (value instanceof Values.Map) return "Map" @@ -133,7 +139,7 @@ const constructorName = (value: unknown): string | undefined => { if (value instanceof Values.URL) return "URL" if (value instanceof Values.URLSearchParams) return "URLSearchParams" if (value instanceof Values.Promise) return "Promise" - if (value === null || typeof value !== "object" || isRuntimeReference(value)) return undefined + if (!(value instanceof ProgramObject)) return undefined return errorBrandName(value) ?? "Object" } @@ -230,7 +236,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for... } type CustomIterator = { - iterator: SafeObject | CodeModeGenerator + iterator: ProgramObject | CodeModeGenerator next: unknown asynchronous: boolean } @@ -247,13 +253,6 @@ const isOpaqueMemberReference = (value: unknown): value is OpaqueMemberReference value instanceof IntrinsicReference || value instanceof GeneratorMethodReference -const copyIteratorSymbols = (source: object, target: object, consumed?: ReadonlySet): void => { - for (const symbol of IteratorSymbols) { - if (!consumed?.has(symbol) && Object.hasOwn(source, symbol)) - Reflect.set(target, symbol, Reflect.get(source, symbol)) - } -} - type GeneratorRequest = { kind: GeneratorRequestKind value: unknown @@ -723,22 +722,26 @@ class Frame { } syncIterator(value: unknown, node: AstNode) { - const iterator = Array.isArray(value) - ? value[Symbol.iterator]() - : typeof value === "string" - ? value[Symbol.iterator]() - : value instanceof Values.Map - ? value.map.entries() - : value instanceof Values.Set - ? value.set.values() - : value instanceof Values.URLSearchParams - ? value.params.entries() - : undefined + const iterator = + value instanceof ProgramArray + ? value.items[Symbol.iterator]() + : typeof value === "string" + ? value[Symbol.iterator]() + : value instanceof Values.Map + ? value.map.entries() + : value instanceof Values.Set + ? value.set.values() + : value instanceof Values.URLSearchParams + ? value.params.entries() + : undefined if (iterator !== undefined) { return Effect.succeed({ next: Effect.sync(() => { const step = iterator.next() - return { done: Boolean(step.done), value: step.value } + return { + done: Boolean(step.done), + value: Array.isArray(step.value) ? new ProgramArray(step.value) : step.value, + } }), close: Effect.void, }) @@ -763,9 +766,9 @@ class Frame { asynchronous: value.asynchronous, }) } - if (!isRecord(value) || isRuntimeReference(value)) return Effect.undefined - const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined - const method = asyncMethod ?? Reflect.get(value, IteratorSymbol) + if (!(value instanceof ProgramObject)) return Effect.undefined + const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined + const method = asyncMethod ?? get(value, IteratorSymbol) if (method === undefined || method === null) return Effect.undefined const self = this return Effect.map( @@ -777,7 +780,7 @@ class Frame { next: object instanceof CodeModeGenerator ? new GeneratorMethodReference(object, "next") - : self.requireIteratorMethod(object.next, "Iterator next", node), + : self.requireIteratorMethod(get(object, "next"), "Iterator next", node), asynchronous: asyncMethod !== undefined && asyncMethod !== null, } }, @@ -793,7 +796,7 @@ class Frame { "Iterator next() result", node, ) - return { done: Boolean(object.done), value: object.value } + return { done: Boolean(get(object, "done")), value: get(object, "value") } } const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node)) @@ -804,7 +807,7 @@ class Frame { const captured = yield* Effect.exit( Effect.sync(() => { const object = self.requireIteratorObject(called.value, "Iterator next() result", node) - return { done: Boolean(object.done), value: object.value } + return { done: Boolean(get(object, "done")), value: get(object, "value") } }), ) if (!Exit.isSuccess(captured)) { @@ -824,7 +827,7 @@ class Frame { const close = iterator.iterator instanceof CodeModeGenerator ? new GeneratorMethodReference(iterator.iterator, "return") - : iterator.iterator.return + : get(iterator.iterator, "return") if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow const self = this return Effect.gen(function* () { @@ -844,7 +847,7 @@ class Frame { return yield* Effect.failCause(called.cause) } const captured = yield* Effect.exit( - Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value), + Effect.sync(() => get(self.requireIteratorObject(called.value, "Iterator return() result", node), "value")), ) if (!Exit.isSuccess(captured)) { if (awaiting) yield* Effect.yieldNow @@ -854,12 +857,12 @@ class Frame { }) } - private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject { - if (isRecord(value) && !isRuntimeReference(value)) return value + private requireIteratorObject(value: unknown, context: string, node: AstNode): ProgramObject { + if (value instanceof ProgramObject) return value throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError") } - private requireIterator(value: unknown, node: AstNode): SafeObject | CodeModeGenerator { + private requireIterator(value: unknown, node: AstNode): ProgramObject | CodeModeGenerator { return value instanceof CodeModeGenerator ? value : this.requireIteratorObject(value, "Iterator method result", node) @@ -874,7 +877,7 @@ class Frame { private enumerableKeys(value: unknown, node: AstNode): Array { if (value instanceof ToolReference) return [...this.runtime.toolKeys(value.path)] if (value === null || value === undefined) return [] - return Object.keys(enumerableSource("for...in", value, node)) + return ownKeys(enumerableSource("for...in", value, node)).filter((key): key is string => typeof key === "string") } private evaluateForInStatement( @@ -1053,7 +1056,7 @@ class Frame { } if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || isRuntimeReference(value)) { + if (!(value instanceof ProgramObject)) { throw new InterpreterRuntimeError( `Object destructuring requires a data object or array value, received ${describeValue(value)}.`, pattern, @@ -1064,11 +1067,8 @@ class Frame { const consumed = new Set() for (const property of pattern.properties) { if (property.type === "RestElement") { - const rest: SafeObject = Object.create(null) as SafeObject - for (const [key, item] of Object.entries(value as SafeObject)) { - if (!consumed.has(key)) rest[key] = item - } - copyIteratorSymbols(value, rest, consumed) + const rest = new ProgramObject() + assign(rest, value, consumed) yield* self.declarePattern(property.argument, rest, mutable, property, initialize) continue } @@ -1077,7 +1077,7 @@ class Frame { consumed.add(typeof key === "symbol" ? key : String(key)) yield* self.declarePattern( property.value, - self.destructuringPropertyValue(value as SafeObject | Array, key), + self.destructuringPropertyValue(value, key), mutable, property, initialize, @@ -1116,7 +1116,7 @@ class Frame { } if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || isRuntimeReference(value)) { + if (!(value instanceof ProgramObject)) { throw new InterpreterRuntimeError( `Object destructuring requires a data object or array value, received ${describeValue(value)}.`, pattern, @@ -1124,21 +1124,17 @@ class Frame { ) } - const source = value as SafeObject | Array const consumed = new Set() for (const property of pattern.properties) { if (property.type === "RestElement") { - const rest: SafeObject = Object.create(null) as SafeObject - for (const [key, item] of Object.entries(source)) { - if (!consumed.has(key)) rest[key] = item - } - copyIteratorSymbols(source, rest, consumed) + const rest = new ProgramObject() + assign(rest, value, consumed) yield* self.assignPattern(property.argument, rest, property) continue } const key = yield* self.destructuringPropertyKey(property) consumed.add(typeof key === "symbol" ? key : String(key)) - yield* self.assignPattern(property.value, self.destructuringPropertyValue(source, key), property) + yield* self.assignPattern(property.value, self.destructuringPropertyValue(value, key), property) } return } @@ -1172,7 +1168,7 @@ class Frame { if (element === null) continue yield* consume( element.type === "RestElement" ? element.argument : element, - element.type === "RestElement" ? [] : undefined, + element.type === "RestElement" ? new ProgramArray() : undefined, element, ) if (element.type === "RestElement") return @@ -1189,7 +1185,7 @@ class Frame { done = next.done if (!done) rest.push(next.value) } - yield* consume(element.argument, rest, element) + yield* consume(element.argument, new ProgramArray(rest), element) return } const consumed = consume(element, step.done ? undefined : step.value, pattern) @@ -1212,11 +1208,9 @@ class Frame { throw unsupportedSyntax(keyNode.type, keyNode) } - private destructuringPropertyValue(source: SafeObject | Array, key: PropertyKey): unknown { - if (!Array.isArray(source)) return Reflect.get(source, key) - if (key === "length") return source.length - if (typeof key === "number") return source[key] - if (Object.hasOwn(source, key)) return Reflect.get(source, key) + private destructuringPropertyValue(source: ProgramObject, key: PropertyKey): unknown { + if (!(source instanceof ProgramArray)) return get(source, key) + if (has(source, key)) return get(source, key) if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key) return undefined } @@ -1374,11 +1368,10 @@ class Frame { case ">>>": return (l as number) >>> (r as number) case "in": - if (rhs === null || typeof rhs !== "object") { + if (!(rhs instanceof ProgramObject)) { throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) } - // Never expose properties inherited from host prototypes. - return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) + return has(rhs, coerceOperand(lhs) as PropertyKey) default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) } @@ -1639,7 +1632,13 @@ class Frame { } for (const [index, parameter] of fn.parameters.entries()) { if (parameter.type === "RestElement") { - yield* invocation.declarePattern(parameter.argument, args.slice(index), true, parameter, true) + yield* invocation.declarePattern( + parameter.argument, + new ProgramArray(args.slice(index)), + true, + parameter, + true, + ) break } yield* invocation.declarePattern(parameter, args[index], true, parameter, true) @@ -1691,12 +1690,12 @@ class Frame { } if (state.completed) { if (kind === "throw") return Effect.fail(new ProgramThrow(value)) - return Effect.succeed({ value: kind === "return" ? value : undefined, done: true }) + return Effect.succeed(record({ value: kind === "return" ? value : undefined, done: true })) } if (!state.started && kind !== "next") { state.completed = true if (kind === "throw") return Effect.fail(new ProgramThrow(value)) - return Effect.succeed({ value, done: true }) + return Effect.succeed(record({ value, done: true })) } state.pending.push(request) @@ -1726,7 +1725,7 @@ class Frame { if (active) { Deferred.doneUnsafe( active.response, - Exit.isSuccess(exit) ? Exit.succeed({ value: exit.value, done: true }) : exit, + Exit.isSuccess(exit) ? Exit.succeed(record({ value: exit.value, done: true })) : exit, ) } yield* invocation.completeGeneratorRequests(state, asynchronous) @@ -1753,13 +1752,13 @@ class Frame { const resolved = yield* Effect.exit(self.awaitValue(pending.value)) Deferred.doneUnsafe( pending.response, - Exit.isSuccess(resolved) ? Exit.succeed({ value: resolved.value, done: true }) : resolved, + Exit.isSuccess(resolved) ? Exit.succeed(record({ value: resolved.value, done: true })) : resolved, ) continue } Deferred.doneUnsafe( pending.response, - Exit.succeed({ value: pending.kind === "return" ? pending.value : undefined, done: true }), + Exit.succeed(record({ value: pending.kind === "return" ? pending.value : undefined, done: true })), ) } }) @@ -1804,7 +1803,7 @@ class Frame { private suspendGenerator(value: unknown, node: AstNode): Effect.Effect { const state = this.generatorState if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node) - Deferred.doneUnsafe(state.active.response, Exit.succeed({ value, done: false })) + Deferred.doneUnsafe(state.active.response, Exit.succeed(record({ value, done: false }))) state.active = undefined return Effect.flatMap(this.takeGeneratorRequest(state), (request) => { state.active = request @@ -1820,7 +1819,7 @@ class Frame { const self = this return Effect.gen(function* () { if ( - Array.isArray(value) || + value instanceof ProgramArray || typeof value === "string" || value instanceof Values.Map || value instanceof Values.Set || @@ -1861,7 +1860,7 @@ class Frame { ? iterator.next : iterator.iterator instanceof CodeModeGenerator ? new GeneratorMethodReference(iterator.iterator, kind) - : iterator.iterator[kind] + : get(iterator.iterator, kind) if (method === undefined || method === null) { if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input)) yield* self.closeIterator(iterator, node, self.generatorAsync) @@ -1879,11 +1878,11 @@ class Frame { `Iterator ${kind}() result`, node, ) - const done = Boolean(result.done) + const done = Boolean(get(result, "done")) const resultValue: unknown = self.generatorAsync && !iterator.asynchronous - ? yield* self.awaitAsyncFromSyncValue(iterator, result.value, node, kind !== "return" && !done) - : result.value + ? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done) + : get(result, "value") if (done) { if (kind === "return") return yield* Effect.fail(new GeneratorReturn(resultValue)) return resultValue @@ -1905,17 +1904,15 @@ class Frame { }) } - private evaluateObjectExpression(node: ObjectExpression): Effect.Effect, unknown, R> { - const objectValue: Record = Object.create(null) as Record + private evaluateObjectExpression(node: ObjectExpression): Effect.Effect { + const objectValue = new ProgramObject() const self = this return Effect.gen(function* () { for (const property of node.properties) { if (property.type === "SpreadElement") { const spread = yield* self.evaluateExpression(property.argument) if (spread === null || spread === undefined) continue - const from = enumerableSource("Object spread", spread, property) - for (const [key, value] of Object.entries(from)) objectValue[key] = value - if (typeof from === "object") copyIteratorSymbols(from, objectValue) + assign(objectValue, enumerableSource("Object spread", spread, property)) continue } @@ -1937,14 +1934,14 @@ class Frame { throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) } - Reflect.set(objectValue, key, yield* self.evaluateExpression(property.value)) + set(objectValue, key, yield* self.evaluateExpression(property.value)) } return objectValue }) } - private evaluateArrayExpression(node: ArrayExpression): Effect.Effect, unknown, R> { + private evaluateArrayExpression(node: ArrayExpression): Effect.Effect { const values: Array = [] const self = this @@ -1969,7 +1966,7 @@ class Frame { values.push(yield* self.evaluateExpression(element)) } } - return values + return new ProgramArray(values) }) } @@ -2056,7 +2053,11 @@ class Frame { } // Values have no prototype chain, so `.constructor` resolves to the owning built-in directly. - if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) { + if ( + operation === "read" && + key === "constructor" && + !(objectValue instanceof ProgramObject && has(objectValue, key)) + ) { const name = constructorName(objectValue) if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name)) } @@ -2145,41 +2146,31 @@ class Frame { ) } - if (typeof objectValue !== "object" || objectValue === null) { + if (!(objectValue instanceof ProgramObject)) { throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) } - if (Array.isArray(objectValue)) { - if (operation === "delete") return { target: objectValue, key } - const index = typeof key === "symbol" ? undefined : parseArrayIndex(key) - if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) { - if (typeof key === "string" && Object.hasOwn(objectValue, key)) { - return new ComputedValue((objectValue as Record & Array)[key]) - } - return new ComputedValue(undefined) - } - return { target: objectValue, key: index ?? key } - } - - return { target: objectValue as SafeObject, key } + return { target: objectValue, key } }) } private readMember(node: MemberExpression): Effect.Effect { + const self = this return Effect.map(this.getMemberReference(node), (reference) => { if (reference === OptionalShortCircuit) return OptionalShortCircuit if (reference instanceof ComputedValue) return reference.value if (reference === undefined || isOpaqueMemberReference(reference)) return reference - if (Array.isArray(reference.target)) { - if (reference.key === "length") return reference.target.length - if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key) - return Reflect.get(reference.target, reference.key) + const value = self.readReferenceValue(reference, reference.key) + if ( + value === undefined && + reference.target instanceof ProgramArray && + typeof reference.key === "string" && + arrayMethods.has(reference.key) && + !has(reference.target, reference.key) + ) { + return new IntrinsicReference(reference.target, reference.key) } - if (reference.target instanceof Values.RegExp) return reference.target.lastIndex - if (reference.target instanceof Values.URL) { - return Reflect.get(reference.target.url, reference.key) - } - return Reflect.get(reference.target, reference.key) + return value }) } @@ -2205,7 +2196,7 @@ class Frame { if (reference.target instanceof Values.RegExp) { return Reflect.deleteProperty(reference.target.regex, reference.key) } - return Reflect.deleteProperty(reference.target, reference.key) + return remove(reference.target, reference.key) }) } @@ -2225,12 +2216,6 @@ class Frame { ) { throw new InterpreterRuntimeError("Only data fields may be assigned.", node) } - if (Array.isArray(reference.target)) { - if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned.", node) - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - throw new InterpreterRuntimeError("Array methods cannot be assigned.", node) - } - } const key = reference.key const { write, next, result } = yield* compute(self.readReferenceValue(reference, key)) if (write) self.assignToReference(reference, key, next, node) @@ -2243,23 +2228,10 @@ class Frame { return Reflect.get(reference.target.url, key) } if (reference.target instanceof Values.RegExp) return reference.target.lastIndex - return Reflect.get(reference.target, key) + return get(reference.target, key) } private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void { - if (Array.isArray(reference.target)) { - const target = reference.target - if (typeof key !== "number" || parseArrayIndex(key) === undefined) { - throw new InterpreterRuntimeError( - "Array assignment index must be a valid array index.", - node, - "InvalidDataValue", - ) - } - rejectCircularInsertion(target, next, "Array assignment result", node) - target[key] = next - return - } if (reference.target instanceof Values.URL) { const property = key as string if (!urlWritableProperties.has(property)) { @@ -2278,9 +2250,14 @@ class Frame { reference.target.lastIndex = next return } - const target = reference.target as SafeObject - rejectCircularInsertion(target, next, "Object assignment result", node) - Reflect.set(target, key, next) + const target = reference.target + rejectCircularInsertion( + target, + next, + target instanceof ProgramArray ? "Array assignment result" : "Object assignment result", + node, + ) + if (!set(target, key, next)) throw new InterpreterRuntimeError("Invalid array length", node).as("RangeError") } private toPropertyKey(value: unknown, node: AstNode): PropertyKey { diff --git a/packages/codemode/src/stdlib/array.ts b/packages/codemode/src/stdlib/array.ts index 32bd764e11f5..a97fd630a2c7 100644 --- a/packages/codemode/src/stdlib/array.ts +++ b/packages/codemode/src/stdlib/array.ts @@ -1,28 +1,27 @@ import { Effect } from "effect" import { HostFunction, sync, syncCall } from "../interpreter/host.js" import { type AstNode, CodeModeGenerator, InterpreterRuntimeError } from "../interpreter/model.js" +import { get, ProgramArray, ProgramObject } from "../interpreter/objects.js" import { describeValue } from "../interpreter/references.js" import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js" -const constructArray = (args: Array, node: AstNode): Array => { - if (args.length !== 1) return [...args] +const constructArray = (args: Array, node: AstNode): ProgramArray => { + if (args.length !== 1) return new ProgramArray([...args]) const first = args[0] - if (typeof first !== "number") return [first] + if (typeof first !== "number") return new ProgramArray([first]) if (!Number.isInteger(first) || first < 0 || first > 4294967295) { throw new InterpreterRuntimeError("Invalid array length.", node).as("RangeError") } // Sparse like JS: Array(3) has holes, and combinator loops already skip them. - return new Array(first) + return new ProgramArray(new Array(first)) } -const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => { - if ( - source !== null && - typeof source === "object" && - (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && - typeof (source as { length?: unknown }).length === "number" - ) { - const length = (source as { length: number }).length +const arrayLikeSource = ( + source: unknown, + node: AstNode, +): { readonly length: number; readonly source: ProgramObject } => { + if (source instanceof ProgramObject && typeof get(source, "length") === "number") { + const length = get(source, "length") as number const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length) if (normalized > 4_294_967_295) throw new RangeError("Invalid array length") return { length: normalized, source } @@ -49,16 +48,16 @@ const arrayFrom = (runner: Runner, args: Array, node: AstNode): E const arrayLike = arrayLikeSource(source, node) const values: Array = [] for (let index = 0; index < arrayLike.length; index += 1) { - const item = Reflect.get(arrayLike.source, index) + const item = get(arrayLike.source, index) values.push(apply === undefined ? item : yield* apply([item, index])) } - return values + return new ProgramArray(values) } const values: Array = [] let index = 0 while (true) { const step = yield* cursor.next - if (step.done) return values + if (step.done) return new ProgramArray(values) values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index]))) index += 1 } @@ -71,10 +70,10 @@ export const arrayGlobal = (runner: Runner) => name: "Array", call: syncCall(constructArray), construct: syncCall(constructArray), - instanceOf: (value) => Array.isArray(value), + instanceOf: (value) => value instanceof ProgramArray, members: { - isArray: sync("Array.isArray", (args) => Array.isArray(args[0])), - of: sync("Array.of", (args) => [...args]), + isArray: sync("Array.isArray", (args) => args[0] instanceof ProgramArray), + of: sync("Array.of", (args) => new ProgramArray([...args])), from: new HostFunction({ name: "Array.from", call: (args, node) => arrayFrom(runner, args, node) }), }, }) diff --git a/packages/codemode/src/stdlib/collections.ts b/packages/codemode/src/stdlib/collections.ts index 1a27433cc062..ef3f182c4497 100644 --- a/packages/codemode/src/stdlib/collections.ts +++ b/packages/codemode/src/stdlib/collections.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" -import type { SafeObject } from "../data.js" import { HostFunction, requiresNew } from "../interpreter/host.js" -import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js" +import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { getOwn, ProgramArray, ProgramObject, set } from "../interpreter/objects.js" import { describeValue, isRuntimeReference } from "../interpreter/references.js" import { applyCollectionCallback, preserveConsumerError, type Runner, toPrimitive } from "../interpreter/runner.js" import { Values } from "../values.js" @@ -108,13 +108,13 @@ export const groupBy = (runner: Runner, namespace: "Map" | "Object") => const item = step.value const key = yield* preserveConsumerError(cursor, apply([item, index])) const group = result.map.get(key) - if (group === undefined) result.map.set(key, [item]) - else (group as Array).push(item) + if (group === undefined) result.map.set(key, new ProgramArray([item])) + else (group as ProgramArray).items.push(item) index += 1 } } - const result: SafeObject = Object.create(null) as SafeObject + const result = new ProgramObject() let index = 0 while (true) { const step = yield* cursor.next @@ -124,9 +124,9 @@ export const groupBy = (runner: Runner, namespace: "Map" | "Object") => cursor, Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)), ) - const group = result[key] - if (group === undefined) result[key] = [item] - else (group as Array).push(item) + const group = getOwn(result, key) + if (group === undefined) set(result, key, new ProgramArray([item])) + else (group as ProgramArray).items.push(item) index += 1 } }) @@ -150,12 +150,12 @@ const constructMap = (runner: Runner, init: unknown, node: AstNode) => { yield* preserveConsumerError( cursor, Effect.sync(() => { - if (!isRecord(step.value) || isRuntimeReference(step.value)) { + if (!(step.value instanceof ProgramObject)) { throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node).as( "TypeError", ) } - target.map.set(step.value[0], step.value[1]) + target.map.set(getOwn(step.value, 0), getOwn(step.value, 1)) }), ) } diff --git a/packages/codemode/src/stdlib/console.ts b/packages/codemode/src/stdlib/console.ts index 93645daa83ea..14367a8890da 100644 --- a/packages/codemode/src/stdlib/console.ts +++ b/packages/codemode/src/stdlib/console.ts @@ -1,5 +1,6 @@ import { toData, toProgram } from "../data.js" import { HostNamespace, sync } from "../interpreter/host.js" +import { get, ownEntries, ProgramArray, ProgramObject } from "../interpreter/objects.js" import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js" import { Values } from "../values.js" import { coerceToString } from "./value.js" @@ -51,8 +52,8 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s if (value instanceof Values.Map) { seen.add(value) try { - const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) - return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}` + const entries = Array.from(value.map.entries(), ([key, item]) => new ProgramArray([key, item])) + return `Map(${value.map.size}) ${formatConsoleValue(new ProgramArray(entries), seen, depth + 1)}` } finally { seen.delete(value) } @@ -60,7 +61,7 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s if (value instanceof Values.Set) { seen.add(value) try { - return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + return `Set(${value.set.size}) ${formatConsoleValue(new ProgramArray([...value.set.values()]), seen, depth + 1)}` } finally { seen.delete(value) } @@ -68,10 +69,11 @@ const formatConsoleValue = (value: unknown, seen: Set, depth: number): s if (isRuntimeReference(value)) return "[opaque reference]" seen.add(value) try { - if (Array.isArray(value)) { - return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]` + if (value instanceof ProgramArray) { + return `[${value.items.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]` } - return `{${Object.entries(value) + if (!(value instanceof ProgramObject)) return "[object Object]" + return `{${ownEntries(value) .map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`) .join(",")}}` } finally { @@ -104,20 +106,19 @@ const consoleTableRows = ( data: unknown, columns: ReadonlyArray | undefined, ): Array<{ readonly index: string; readonly values: Record }> => { - if (Array.isArray(data)) { - return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) + if (data instanceof ProgramArray) { + return data.items.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) })) } - if (data !== null && typeof data === "object" && !Values.isValue(data)) { - return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) })) + if (data instanceof ProgramObject) { + return ownEntries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) })) } return [{ index: "0", values: { Value: data } }] } const consoleTableValues = (value: unknown, columns: ReadonlyArray | undefined): Record => { - if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) { - const source = value as Record - if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) - return Object.fromEntries(Object.entries(source)) + if (value instanceof ProgramObject && !(value instanceof ProgramArray)) { + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, get(value, column)])) + return Object.fromEntries(ownEntries(value)) } return { Value: value } } diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index e8789ca94ba0..a3cb1744b32b 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -3,7 +3,8 @@ import { HostFunction, HostNamespace } from "../interpreter/host.js" import { applyCollectionCallback, type Runner } from "../interpreter/runner.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { typeofValue } from "../interpreter/references.js" -import { fromData, type SafeObject, toData, toProgram } from "../data.js" +import { fromData, toData, toProgram } from "../data.js" +import { get, ownKeys, ProgramArray, ProgramObject, record, remove, set } from "../interpreter/objects.js" import { Values } from "../values.js" export const jsonGlobal = (runner: Runner) => @@ -29,28 +30,19 @@ const parse = (runner: Runner, args: Array, node: AstNode): Effec if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed) const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node) - const root: SafeObject = Object.create(null) as SafeObject - root[""] = parsed - const visit = (holder: SafeObject | Array, key: string): Effect.Effect => + const visit = (holder: ProgramObject, key: string): Effect.Effect => Effect.gen(function* () { - const value = holder[key as keyof typeof holder] - if (Array.isArray(value)) { - const length = value.length - for (let index = 0; index < length; index += 1) { - const revived = yield* visit(value, String(index)) - if (revived === undefined) Reflect.deleteProperty(value, index) - else value[index] = revived - } - } else if (isPlainObject(value)) { - for (const name of Object.keys(value)) { - const revived = yield* visit(value, name) - if (revived === undefined) Reflect.deleteProperty(value, name) - else value[name] = revived + const value = get(holder, key) + if (value instanceof ProgramObject) { + for (const name of ownKeys(value)) { + const revived = yield* visit(value, name as string) + if (revived === undefined) remove(value, name) + else set(value, name, revived) } } return yield* apply([key, value]) }) - return visit(root, "") + return visit(record({ "": parsed }), "") } const stringify = (runner: Runner, args: Array, node: AstNode): Effect.Effect => { @@ -59,44 +51,41 @@ const stringify = (runner: Runner, args: Array, node: AstNode): E const replacer = args[1] if (typeofValue(replacer) !== "function") { - const properties = Array.isArray(replacer) - ? replacer - .filter((item): item is string | number => typeof item === "string" || typeof item === "number") - .map(String) - : null + const properties = + replacer instanceof ProgramArray + ? replacer.items + .filter((item): item is string | number => typeof item === "string" || typeof item === "number") + .map(String) + : null return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value"), properties, indent)) } // Validate up front; the replacer walk below reads the original value. toProgram(args[0], "JSON.stringify value") const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node) - const root: SafeObject = Object.create(null) as SafeObject - root[""] = args[0] const stack = new Set() - const visit = (holder: SafeObject | Array, key: string): Effect.Effect => + const visit = (holder: ProgramObject, key: string): Effect.Effect => Effect.gen(function* () { - const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])]) + const value = yield* apply([key, toJSONValue(get(holder, key))]) if (value === undefined || typeofValue(value) === "function") return undefined toProgram(value, "JSON.stringify replacer result") if (typeof value === "number") return Number.isFinite(value) ? value : null if (value === null || typeof value === "string" || typeof value === "boolean") return value - if (Array.isArray(value)) { - if (stack.has(value)) - throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError") - stack.add(value) + if (!(value instanceof ProgramObject)) return {} + if (stack.has(value)) + throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError") + stack.add(value) + if (value instanceof ProgramArray) { const result: Array = [] - for (let index = 0; index < value.length; index += 1) { + for (let index = 0; index < value.items.length; index += 1) { result.push((yield* visit(value, String(index))) ?? null) } stack.delete(value) return result } - if (!isPlainObject(value)) return {} - if (stack.has(value)) - throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError") - stack.add(value) - const result: SafeObject = Object.create(null) as SafeObject - for (const name of Object.keys(value)) { + const result: Record = Object.create(null) + for (const name of ownKeys(value)) { + if (typeof name !== "string") continue const item = yield* visit(value, name) if (item !== undefined) result[name] = item } @@ -104,7 +93,7 @@ const stringify = (runner: Runner, args: Array, node: AstNode): E return result }) - return Effect.map(visit(root, ""), (value) => JSON.stringify(value, null, indent)) + return Effect.map(visit(record({ "": args[0] }), ""), (value) => JSON.stringify(value, null, indent)) } const toJSONValue = (value: unknown): unknown => { @@ -114,6 +103,3 @@ const toJSONValue = (value: unknown): unknown => { if (value instanceof Values.URL) return value.url.href return value } - -const isPlainObject = (value: unknown): value is SafeObject => - value !== null && typeof value === "object" && !Values.isValue(value) diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index bf6ddeb9f558..799b591bb980 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -2,11 +2,10 @@ import { Effect } from "effect" import { toProgram } from "../data.js" import { HostFunction, sync, syncCall } from "../interpreter/host.js" import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js" +import { getOwn, hasOwn, ownEntries, ownKeys, ProgramArray, ProgramObject, set } from "../interpreter/objects.js" import { containsOpaqueReference, describeValue, - isRuntimeReference, - parseArrayIndex, rejectCircularInsertion, typeofValue, } from "../interpreter/references.js" @@ -16,9 +15,8 @@ import { Values } from "../values.js" import { groupBy } from "./collections.js" import { coerceToString } from "./value.js" -// ToObject for enumeration. Strings return themselves: the host's Object.keys/entries/hasOwn index a -// primitive string directly. Numbers, booleans, wrappers, and functions have no own enumerable keys. -export const enumerableSource = (label: string, value: unknown, node: AstNode): Record => { +// ToObject for enumeration. +export const enumerableSource = (label: string, value: unknown, node: AstNode): ProgramObject => { if (value === null || value === undefined) { throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node).as( "TypeError", @@ -38,58 +36,41 @@ export const enumerableSource = (label: string, value: unknown, node: AstNode): "InvalidDataValue", ) } - if (typeof value === "string") return value as unknown as Record - if (typeof value !== "object" || Values.isValue(value) || isRuntimeReference(value)) return {} - return value as Record + if (typeof value === "string") return new ProgramArray([...value]) + if (value instanceof ProgramObject) return value + return new ProgramObject() } export const objectAssign = (args: Array, node: AstNode): unknown => { const target = args[0] // JS would box a primitive target; wrappers and primitives cannot hold fields here. - if (target === null || typeof target !== "object" || Values.isValue(target) || isRuntimeReference(target)) { + if (!(target instanceof ProgramObject)) { throw new InterpreterRuntimeError( `Object.assign expects a data object or array target, received ${describeValue(target)}.`, node, ).as("TypeError") } - const out = target as Record const seen = new Set() - const guardedSet = (key: PropertyKey, item: unknown): void => { - // Arrays hold only indexed elements, as with direct assignment; Reflect.set would otherwise - // reach Array's length and Object.prototype's __proto__ setter. - if (Array.isArray(out) && (typeof key === "symbol" || parseArrayIndex(key) === undefined)) { - throw new InterpreterRuntimeError( - `Object.assign cannot assign '${String(key)}' to an array: only array indexes may be assigned.`, - node, - ).as("TypeError") - } - rejectCircularInsertion(out, item, "Object.assign result", node, seen) - if (!Reflect.set(out, key, item)) - throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as( - "TypeError", - ) - } for (const source of args.slice(1)) { if (source === null || source === undefined) continue const from = enumerableSource("Object.assign(...)", source, node) - if (typeof from !== "object") { - for (const [key, item] of Object.entries(from)) guardedSet(key, item) - continue - } - for (const key of Reflect.ownKeys(from)) { + for (const key of ownKeys(from)) { if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue - if (Object.prototype.propertyIsEnumerable.call(from, key)) guardedSet(key, Reflect.get(from, key)) + rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen) + if (!set(target, key, getOwn(from, key))) { + throw new InterpreterRuntimeError("Invalid array length", node).as("RangeError") + } } } - return out + return target } const objectFromEntries = ( runner: Runner, source: unknown, node: AstNode, -): Effect.Effect, unknown, R> => { - const out: Record = Object.create(null) +): Effect.Effect => { + const out = new ProgramObject() return Effect.gen(function* () { const cursor = yield* runner.syncIterator(source, node) if (cursor === undefined) { @@ -103,21 +84,12 @@ const objectFromEntries = ( yield* preserveConsumerError( cursor, Effect.sync(() => { - if ( - step.value === null || - typeof step.value !== "object" || - Values.isValue(step.value) || - containsOpaqueReference(step.value) - ) { + if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) { throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as( "TypeError", ) } - const entry = step.value as Record - toProgram(entry[0], "Object.fromEntries key") - toProgram(entry[1], "Object.fromEntries value") - const key = coerceToString(entry[0]) - out[key] = entry[1] + set(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1)) }), ) } @@ -126,7 +98,7 @@ const objectFromEntries = ( const constructObject = (args: Array, node: AstNode): unknown => { const first = args[0] - if (first === null || first === undefined) return Object.create(null) + if (first === null || first === undefined) return new ProgramObject() if (typeof first === "object") return first throw new InterpreterRuntimeError( `Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`, @@ -147,18 +119,24 @@ export const objectGlobal = (runner: Runner, toolKeys: (path: ReadonlyArra toProgram( args[0] instanceof ToolReference ? [...toolKeys(args[0].path)] - : Object.keys(enumerableSource("Object.keys(...)", args[0], node)), + : ownKeys(enumerableSource("Object.keys(...)", args[0], node)).filter((key) => typeof key === "string"), "Object.keys result", ), ), - values: sync("Object.values", (args, node) => - Object.values(enumerableSource("Object.values(...)", args[0], node)), + values: sync( + "Object.values", + (args, node) => + new ProgramArray(ownEntries(enumerableSource("Object.values(...)", args[0], node)).map((entry) => entry[1])), ), - entries: sync("Object.entries", (args, node) => - Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]), + entries: sync( + "Object.entries", + (args, node) => + new ProgramArray( + ownEntries(enumerableSource("Object.entries(...)", args[0], node)).map((entry) => new ProgramArray(entry)), + ), ), hasOwn: sync("Object.hasOwn", (args, node) => - Object.hasOwn( + hasOwn( enumerableSource("Object.hasOwn(...)", args[0], node), args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]), ), diff --git a/packages/codemode/src/stdlib/regexp.ts b/packages/codemode/src/stdlib/regexp.ts index b17df75fe692..3d50625de6f5 100644 --- a/packages/codemode/src/stdlib/regexp.ts +++ b/packages/codemode/src/stdlib/regexp.ts @@ -1,19 +1,9 @@ import { sync, syncCall } from "../interpreter/host.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" -import type { SafeObject } from "../data.js" +import { ProgramArray, record, set } from "../interpreter/objects.js" import { Values } from "../values.js" import { coerceToNumber, coerceToString } from "./value.js" -type MatchValue = Array & { - index?: number - groups?: SafeObject - indices?: IndicesValue -} - -type IndicesValue = Array & { - groups?: SafeObject -} - export const regexpMethods = new Set(["test", "exec", "toString"]) export const regexpProperties = new Set([ @@ -56,17 +46,12 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl ) } -export const matchToValue = (match: RegExpMatchArray): Array => { - const result: MatchValue = Array.from(match, (group) => group) - if (match.index !== undefined) result.index = match.index - if (match.groups) { - const groups: SafeObject = Object.create(null) as SafeObject - for (const [key, group] of Object.entries(match.groups)) { - groups[key] = group - } - result.groups = groups - } - if (match.indices) result.indices = indicesToValue(match.indices) +export const matchToValue = (match: RegExpMatchArray): ProgramArray => { + const result = new ProgramArray(Array.from(match, (group) => group)) + if (match.index !== undefined) set(result, "index", match.index) + if (match.input !== undefined) set(result, "input", match.input) + if (match.groups) set(result, "groups", record(match.groups)) + if (match.indices) set(result, "indices", indicesToValue(match.indices)) return result } @@ -143,16 +128,16 @@ const toLength = (value: unknown): number => { return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER) } -const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => { - const result: IndicesValue = Array.from(indices, (range) => (range === undefined ? undefined : [...range])) - if (indices.groups) { - const groups: SafeObject = Object.create(null) as SafeObject - for (const [key, range] of Object.entries(indices.groups)) { - groups[key] = range === undefined ? undefined : [...range] - } - result.groups = groups - return result - } - result.groups = undefined +const indicesToValue = (indices: RegExpIndicesArray): ProgramArray => { + const range = (pair: [number, number] | undefined) => (pair === undefined ? undefined : new ProgramArray([...pair])) + const result = new ProgramArray(Array.from(indices, range)) + const groups = indices.groups + set( + result, + "groups", + groups === undefined + ? undefined + : record(Object.fromEntries(Object.entries(groups).map(([key, pair]) => [key, range(pair)]))), + ) return result } diff --git a/packages/codemode/src/stdlib/url.ts b/packages/codemode/src/stdlib/url.ts index a9cd24c0c57e..2e4cdff0f3b5 100644 --- a/packages/codemode/src/stdlib/url.ts +++ b/packages/codemode/src/stdlib/url.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { toProgram } from "../data.js" import { HostFunction, requiresNew, sync, syncCall } from "../interpreter/host.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" +import { ownEntries, ProgramObject } from "../interpreter/objects.js" import { isRuntimeReference } from "../interpreter/references.js" import { preserveConsumerError, type Runner } from "../interpreter/runner.js" import { Values } from "../values.js" @@ -183,15 +184,14 @@ const constructURLSearchParams = ( ).as("TypeError") } if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams()) - const data = toProgram(init, "new URLSearchParams input") - if (data === null || typeof data !== "object") { + if (!(init instanceof ProgramObject)) { throw new InterpreterRuntimeError( "new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.", node, ).as("TypeError") } return new Values.URLSearchParams( - new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))), + new URLSearchParams(Object.fromEntries(ownEntries(init).map(([key, value]) => [key, coerceToString(value)]))), ) }) } diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 605f9d1a3007..fbaf431dca52 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -1,6 +1,7 @@ import { type HostFunction, sync, type SyncOptions } from "../interpreter/host.js" import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" -import { type SafeObject, toProgram } from "../data.js" +import { toProgram } from "../data.js" +import { get, ProgramArray, ProgramError, set } from "../interpreter/objects.js" import { Values } from "../values.js" export const errorConstructors = new Set([ @@ -16,21 +17,21 @@ export const errorConstructors = new Set([ export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) -const ErrorBrand: unique symbol = Symbol("codemode.error") - -export const createErrorValue = (name: string, message: string): SafeObject => { - const value = Object.assign(Object.create(null) as SafeObject, { name, message }) - Object.defineProperty(value, ErrorBrand, { value: name }) +export const createErrorValue = (name: string, message: string): ProgramError => { + const value = new ProgramError(name) + set(value, "name", name) + set(value, "message", message) return value } -export const createAggregateErrorValue = (errors: Array, message: string): SafeObject => - Object.assign(createErrorValue("AggregateError", message), { errors }) +export const createAggregateErrorValue = (errors: Array, message: string): ProgramError => { + const value = createErrorValue("AggregateError", message) + set(value, "errors", new ProgramArray(errors)) + return value +} export const errorBrandName = (value: unknown): string | undefined => - value !== null && typeof value === "object" - ? ((value as Record)[ErrorBrand] as string | undefined) - : undefined + value instanceof ProgramError ? value.errorName : undefined export const coerceToString = (value: unknown): string => { if (value === null) return "null" @@ -42,29 +43,27 @@ export const coerceToString = (value: unknown): string => { if (value instanceof Values.Set) return "[object Set]" if (value instanceof Values.URL) return value.url.href if (value instanceof Values.URLSearchParams) return value.params.toString() - if (errorBrandName(value) !== undefined) { + if (value instanceof ProgramError) { // Match Error.prototype.toString: "name: message", or just one when the other is empty. - const error = value as { name?: unknown; message?: unknown } - const name = typeof error.name === "string" ? error.name : "Error" - const message = typeof error.message === "string" ? error.message : "" - if (message === "") return name - if (name === "") return message - return `${name}: ${message}` + const name = get(value, "name") + const message = get(value, "message") + const shownName = typeof name === "string" ? name : "Error" + const shownMessage = typeof message === "string" ? message : "" + if (shownMessage === "") return shownName + if (shownName === "") return shownMessage + return `${shownName}: ${shownMessage}` } - if (typeof value === "object") { - return Array.isArray(value) - ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") - : "[object Object]" + if (value instanceof ProgramArray) { + return value.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") } + if (typeof value === "object") return "[object Object]" return String(value) } export const coerceToNumber = (value: unknown): number => { if (value instanceof Values.Date) return value.time if (Values.isValue(value)) return Number.NaN - // Arrays coerce through our own string coercion: host Number(array) joins with host - // ToPrimitive, which throws on the null-prototype objects the interpreter produces. - if (Array.isArray(value)) return Number(coerceToString(value)) + if (value instanceof ProgramArray) return Number(coerceToString(value)) return value !== null && typeof value === "object" ? Number.NaN : Number(value) } @@ -78,8 +77,6 @@ const coerce = (name: Coercion, args: Array, node: AstNode): unknown => if (name === "String") return "" } const raw = args[0] - // Error values are plain SafeObjects; the toProgram path below would strip their brand. - if (name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw) if (Values.isValue(raw)) { if (name === "Boolean") return true if (name === "Number") return coerceToNumber(raw) diff --git a/packages/codemode/test/object-coercion-test262.test.ts b/packages/codemode/test/object-coercion-test262.test.ts index e8713904c390..adba9e8bd624 100644 --- a/packages/codemode/test/object-coercion-test262.test.ts +++ b/packages/codemode/test/object-coercion-test262.test.ts @@ -167,17 +167,14 @@ describe("Object.assign Test262 parity", () => { ).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0]) }) - test("array targets accept only array indexes (deviation from target-Array.js)", async () => { + test("test/built-ins/Object/assign/target-Array.js", async () => { expect( await value(` const target = [7] - const out = [] - for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) { - try { Object.assign(target, source) } catch (error) { out.push(error.name) } - } - return [out, [...target], target.length, Object.keys(target)] + for (const source of [{ x: 1 }, { "1.5": 1 }, { "-0": 1 }, { 1: 8 }, { length: 1 }]) Object.assign(target, source) + return [[...target], target.length, Object.keys(target), target.x] `), - ).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]]) + ).toEqual([[7], 1, ["0", "x", "1.5", "-0"], 1]) }) test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => { diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 7c4c657e72e9..112baeb837c5 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -81,27 +81,26 @@ describe("H3: array property access reads as undefined (not a throw)", () => { ).toEqual(["b", "b", null, null, "a", null]) }) - test("noncanonical keys cannot mutate or delete an aliased element", async () => { + test("noncanonical keys are ordinary properties that never alias an element", async () => { expect( await value(` const values = ["a", "b"] - let writes = 0 - try { values["01"] = ++writes } catch {} + values["01"] = "c" + const before = [values["01"], values[1], values.length] const removed = delete values["01"] - return [writes, removed, values] + return [before, removed, values["01"], values] `), - ).toEqual([0, true, ["a", "b"]]) + ).toEqual([["c", "b", 2], true, null, ["a", "b"]]) }) - test("the maximum array length is not accepted as an array index", async () => { + test("the maximum array length is a property, not an index", async () => { expect( await value(` const values = [] - let writes = 0 - try { values["4294967295"] = ++writes } catch {} - return [writes, values.length] + values["4294967295"] = 1 + return [values["4294967295"], values.length] `), - ).toEqual([0, 0]) + ).toEqual([1, 0]) }) }) @@ -246,16 +245,14 @@ describe("property deletion", () => { expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2]) }) - test("does not broaden unsupported array property assignment", async () => { + test("arrays accept named properties like JS, and they stay out of the JSON form", async () => { expect( await value(` - const values = [] - let rightHandSideRuns = 0 - function next() { rightHandSideRuns++; return 1 } - try { values.field = next() } catch {} - return rightHandSideRuns + const values = [1] + values.field = 2 + return [values.field, Object.keys(values), values] `), - ).toBe(0) + ).toEqual([2, ["0", "field"], [1]]) }) test("optional deletion short-circuits without evaluating the key", async () => { diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 86f631e61293..6856ecf40b05 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -17,8 +17,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" -import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js" -import { objectAssign } from "../src/stdlib/object.js" // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; // intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live @@ -770,13 +768,18 @@ describe("stdlib integration", () => { ], ]) expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([ - ["a", 1], + ["a", 1, "ba"], [ ["0", "a"], ["index", 1], + ["input", "ba"], ], ]) - expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"]) + expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual([ + "0", + "index", + "input", + ]) }) test("Object.fromEntries accepts every supported entry collection", async () => { @@ -849,86 +852,6 @@ describe("stdlib integration", () => { expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true) }) - test("Object.assign ignores non-enumerable supported symbols without reading them", () => { - const target = {} - const reads: Array = [] - const source = Object.defineProperty({}, IteratorSymbol, { - get() { - reads.push(true) - return target - }, - }) - expect(objectAssign([target, source], { type: "CallExpression", start: 0, end: 0 })).toBe(target) - expect(reads).toEqual([]) - expect(Object.hasOwn(target, IteratorSymbol)).toBe(false) - }) - - test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => { - const target = {} - const reads: Array = [] - const nested = Object.defineProperty({}, IteratorSymbol, { - get() { - reads.push(true) - return target - }, - }) - expect(objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toBe(target) - expect(reads).toEqual([]) - expect(target).toEqual({ nested }) - }) - - test("Object.assign rejects cycles through supported symbols on nested arrays", () => { - const target = {} - const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target }) - expect(() => objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toThrow( - "Object.assign result contains a circular value.", - ) - expect(Object.hasOwn(target, "nested")).toBe(false) - }) - - test("Object.assign cycle checks traverse sparse keys lazily", () => { - const target = {} - const reads: Array = [] - const nested = Object.defineProperties([], { - 4294967294: { enumerable: true, value: target }, - later: { - enumerable: true, - get() { - reads.push(true) - return null - }, - }, - }) - expect(() => objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toThrow( - "Object.assign result contains a circular value.", - ) - expect(reads).toEqual([]) - }) - - test("Object.assign stops after a supported symbol write fails", () => { - const previous = () => ({ done: true }) - const target = Object.defineProperty({}, IteratorSymbol, { value: previous }) - const reads: Array = [] - const source = Object.defineProperties( - {}, - { - [IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) }, - [AsyncIteratorSymbol]: { - enumerable: true, - get() { - reads.push(true) - return () => ({ done: true }) - }, - }, - }, - ) - expect(() => objectAssign([target, source], { type: "CallExpression", start: 0, end: 0 })).toThrow( - "Object.assign could not assign property", - ) - expect(Reflect.get(target, IteratorSymbol)).toBe(previous) - expect(reads).toEqual([]) - }) - test("Object.assign rejects direct and nested cycles", async () => { expect( await value(` @@ -1000,27 +923,6 @@ describe("stdlib integration", () => { ).toEqual([true, true, true, 2]) }) - test("Object.assign traverses shared aliases once", () => { - const reads: Array = [] - const shared = Object.defineProperty({}, "value", { - enumerable: true, - get() { - reads.push(true) - return 1 - }, - }) - const target = {} - expect( - objectAssign([target, { left: shared, right: shared }], { - type: "CallExpression", - start: 0, - end: 0, - }), - ).toBe(target) - expect(target).toEqual({ left: shared, right: shared }) - expect(reads).toEqual([true]) - }) - test("assignment resolves and reads its left side before evaluating the right side", async () => { expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6) expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1]) diff --git a/packages/codemode/test/test262/run.ts b/packages/codemode/test/test262/run.ts index 9f90cdf74b65..7d82de149e1d 100644 --- a/packages/codemode/test/test262/run.ts +++ b/packages/codemode/test/test262/run.ts @@ -8,6 +8,7 @@ import { executeProgram } from "../../src/interpreter/execute.js" import type { Host } from "../../src/interpreter/globals.js" import { HostFunction } from "../../src/interpreter/host.js" import { ProgramThrow } from "../../src/interpreter/model.js" +import { get, ProgramArray, ProgramObject } from "../../src/interpreter/objects.js" import { createErrorValue, errorBrandName } from "../../src/stdlib/value.js" import { ToolRuntime } from "../../src/tool-runtime.js" @@ -67,7 +68,10 @@ const harness = (host: Host, onDone: (error: unknown) => void): ReadonlyAr const fail = (message: string) => Effect.fail(new ProgramThrow(createErrorValue("Test262Error", message))) const prefix = (message: unknown) => (message === undefined ? "" : `${String(message)} `) const compare = (a: unknown, b: unknown) => - Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, i) => Object.is(value, b[i])) + a instanceof ProgramArray && + b instanceof ProgramArray && + a.items.length === b.items.length && + a.items.every((value, i) => Object.is(value, b.items[i])) const test262Error = new HostFunction({ name: "Test262Error", call: (args) => Effect.succeed(createErrorValue("Test262Error", args[0] === undefined ? "" : String(args[0]))), @@ -154,9 +158,9 @@ const harness = (host: Host, onDone: (error: unknown) => void): ReadonlyAr const show = (value: unknown): string => { if (typeof value === "string") return JSON.stringify(value) if (Object.is(value, -0)) return "-0" - if (Array.isArray(value)) return `[${value.map(show).join(", ")}]` + if (value instanceof ProgramArray) return `[${value.items.map(show).join(", ")}]` if (value instanceof HostFunction) return value.name - if (value === null || typeof value !== "object") return String(value) - const message = (value as { message?: unknown }).message + if (!(value instanceof ProgramObject)) return String(value) + const message = get(value, "message") return typeof message === "string" ? `${errorBrandName(value) ?? "object"}: ${message}` : "object" } diff --git a/packages/codemode/test/test262/skipped.txt b/packages/codemode/test/test262/skipped.txt index ed32d06b0305..ca8a46093fc1 100644 --- a/packages/codemode/test/test262/skipped.txt +++ b/packages/codemode/test/test262/skipped.txt @@ -1,6 +1,6 @@ -built-ins/Array/prototype/concat/create-ctor-non-object.js # Only data fields may be assigned. +built-ins/Array/prototype/concat/create-ctor-non-object.js # a.concat() throws a TypeError exception Expected a TypeError to be thrown but no exception was throw built-ins/Array/prototype/copyWithin/coerced-values-end.js # Array.copyWithin expects end to be a number. -built-ins/Array/prototype/copyWithin/coerced-values-start-change-target.js # Array length cannot be assigned. +built-ins/Array/prototype/copyWithin/coerced-values-start-change-target.js # Array.copyWithin expects start to be a number. built-ins/Array/prototype/copyWithin/coerced-values-start.js # Array.copyWithin expects start to be a number. built-ins/Array/prototype/copyWithin/coerced-values-target.js # Array.copyWithin expects target index to be a number. built-ins/Array/prototype/copyWithin/fill-holes.js # .hasOwnProperty is not a function. @@ -9,18 +9,15 @@ built-ins/Array/prototype/copyWithin/return-abrupt-from-start.js # Expected a T built-ins/Array/prototype/copyWithin/return-abrupt-from-target.js # Expected a Test262Error but got a Error built-ins/Array/prototype/entries/iteration-mutable.js # .next is not a function. built-ins/Array/prototype/entries/iteration.js # .next is not a function. -built-ins/Array/prototype/every/15.4.4.16-7-4.js # Array length cannot be assigned. built-ins/Array/prototype/every/15.4.4.16-7-7.js # .hasOwnProperty is not a function. -built-ins/Array/prototype/every/15.4.4.16-8-13.js # Only data fields may be assigned. +built-ins/Array/prototype/every/15.4.4.16-8-13.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. built-ins/Array/prototype/fill/coerced-indexes.js # Array.fill expects start to be a number. built-ins/Array/prototype/fill/return-abrupt-from-end.js # Expected a Test262Error but got a Error built-ins/Array/prototype/fill/return-abrupt-from-start.js # Expected a Test262Error but got a Error -built-ins/Array/prototype/filter/15.4.4.20-10-4.js # Only data fields may be assigned. -built-ins/Array/prototype/filter/15.4.4.20-9-4.js # Array length cannot be assigned. -built-ins/Array/prototype/filter/create-ctor-non-object.js # Only data fields may be assigned. +built-ins/Array/prototype/filter/15.4.4.20-10-4.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. +built-ins/Array/prototype/filter/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all built-ins/Array/prototype/flat/non-numeric-depth-should-not-throw.js # Array.flat expects depth to be a number. -built-ins/Array/prototype/forEach/15.4.4.18-7-3.js # Array length cannot be assigned. -built-ins/Array/prototype/forEach/15.4.4.18-8-12.js # Only data fields may be assigned. +built-ins/Array/prototype/forEach/15.4.4.18-8-12.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. built-ins/Array/prototype/includes/length-zero-returns-false.js # Array.includes expects a value and optional start index. built-ins/Array/prototype/includes/no-arg.js # Array.includes expects a value and optional start index. built-ins/Array/prototype/includes/return-abrupt-tointeger-fromindex.js # Expected a Test262Error but got a Error @@ -40,15 +37,13 @@ built-ins/Array/prototype/indexOf/15.4.4.14-5-3.js # Array.indexOf expects star built-ins/Array/prototype/indexOf/15.4.4.14-5-5.js # Array.indexOf expects start index to be a number. built-ins/Array/prototype/indexOf/15.4.4.14-9-7.js # Array assignment result contains a circular value. built-ins/Array/prototype/indexOf/15.4.4.14-9-8.js # .toString is not a function. -built-ins/Array/prototype/indexOf/15.4.4.14-9-9.js # Only data fields may be assigned. -built-ins/Array/prototype/indexOf/15.4.4.14-9-a-3.js # Array length cannot be assigned. -built-ins/Array/prototype/indexOf/15.4.4.14-9-a-6.js # Array length cannot be assigned. +built-ins/Array/prototype/indexOf/15.4.4.14-9-a-3.js # Array.indexOf expects start index to be a number. +built-ins/Array/prototype/indexOf/15.4.4.14-9-a-6.js # Array.indexOf expects start index to be a number. built-ins/Array/prototype/indexOf/length-zero-returns-minus-one.js # Array.indexOf expects start index to be a number. -built-ins/Array/prototype/join/S15.4.4.5_A1.1_T1.js # Array length cannot be assigned. built-ins/Array/prototype/join/S15.4.4.5_A1.2_T2.js # Array.join expects zero arguments or one string separator. built-ins/Array/prototype/join/S15.4.4.5_A3.1_T1.js # Array.join expects zero arguments or one string separator. built-ins/Array/prototype/join/S15.4.4.5_A3.1_T2.js # Array.join expects zero arguments or one string separator. -built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # Array.join input must contain plain objects only. +built-ins/Array/prototype/join/S15.4.4.5_A3.2_T2.js # x.join() must return "*" Expected SameValue(«"[object Object]"», «"*"») to be true built-ins/Array/prototype/keys/iteration-mutable.js # .next is not a function. built-ins/Array/prototype/keys/iteration.js # .next is not a function. built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-1.js # Array.lastIndexOf expects start index to be a number. @@ -67,36 +62,24 @@ built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-4.js # a.lastIndexOf(2,undefi built-ins/Array/prototype/lastIndexOf/15.4.4.15-5-5.js # Array.lastIndexOf expects start index to be a number. built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-7.js # Array assignment result contains a circular value. built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-8.js # .toString is not a function. -built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-9.js # Only data fields may be assigned. -built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-a-3.js # Array length cannot be assigned. -built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-a-6.js # Array length cannot be assigned. +built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-a-3.js # Array.lastIndexOf expects start index to be a number. +built-ins/Array/prototype/lastIndexOf/15.4.4.15-8-a-6.js # Array.lastIndexOf expects start index to be a number. built-ins/Array/prototype/lastIndexOf/length-zero-returns-minus-one.js # Array.lastIndexOf expects start index to be a number. -built-ins/Array/prototype/map/15.4.4.19-8-4.js # Array length cannot be assigned. built-ins/Array/prototype/map/15.4.4.19-8-7.js # .toString is not a function. -built-ins/Array/prototype/map/15.4.4.19-9-4.js # Only data fields may be assigned. -built-ins/Array/prototype/map/create-ctor-non-object.js # Only data fields may be assigned. -built-ins/Array/prototype/pop/S15.4.4.6_A1.1_T1.js # Array length cannot be assigned. -built-ins/Array/prototype/pop/S15.4.4.6_A1.2_T1.js # Array length cannot be assigned. -built-ins/Array/prototype/push/S15.4.4.7_A3.js # Array length cannot be assigned. -built-ins/Array/prototype/reduce/15.4.4.21-10-8.js # Only data fields may be assigned. -built-ins/Array/prototype/reduce/15.4.4.21-8-c-2.js # Array length cannot be assigned. -built-ins/Array/prototype/reduce/15.4.4.21-9-4.js # Array length cannot be assigned. +built-ins/Array/prototype/map/15.4.4.19-9-4.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. +built-ins/Array/prototype/map/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all +built-ins/Array/prototype/push/S15.4.4.7_A3.js # The value of x[4294967295] is expected to be "x" Expected SameValue(«undefined», «"x"») to be true +built-ins/Array/prototype/reduce/15.4.4.21-10-8.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. built-ins/Array/prototype/reduce/15.4.4.21-9-7.js # .hasOwnProperty is not a function. -built-ins/Array/prototype/reduceRight/15.4.4.22-10-8.js # Only data fields may be assigned. -built-ins/Array/prototype/reduceRight/15.4.4.22-8-c-2.js # Array length cannot be assigned. -built-ins/Array/prototype/reduceRight/15.4.4.22-9-4.js # Array length cannot be assigned. +built-ins/Array/prototype/reduceRight/15.4.4.22-10-8.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. built-ins/Array/prototype/reduceRight/15.4.4.22-9-7.js # .hasOwnProperty is not a function. -built-ins/Array/prototype/reverse/S15.4.4.8_A1_T2.js # Array length cannot be assigned. -built-ins/Array/prototype/shift/S15.4.4.9_A1.1_T1.js # Array length cannot be assigned. -built-ins/Array/prototype/shift/S15.4.4.9_A1.2_T1.js # Array length cannot be assigned. -built-ins/Array/prototype/slice/create-ctor-non-object.js # Only data fields may be assigned. -built-ins/Array/prototype/some/15.4.4.17-7-4.js # Array length cannot be assigned. -built-ins/Array/prototype/some/15.4.4.17-8-13.js # Only data fields may be assigned. -built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T3.js # isNaN input must contain plain objects only. -built-ins/Array/prototype/sort/S15.4.4.11_A2.2_T3.js # String input must contain plain objects only. +built-ins/Array/prototype/slice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all +built-ins/Array/prototype/some/15.4.4.17-8-13.js # Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator. +built-ins/Array/prototype/sort/S15.4.4.11_A2.1_T3.js # #1: Check ToString operator +built-ins/Array/prototype/sort/S15.4.4.11_A2.2_T3.js # #1: Check ToString operator built-ins/Array/prototype/sort/bug_596_1.js # #1: [object, object].sort(); counter < 2. Actual: 0 built-ins/Array/prototype/sort/bug_596_2.js # .hasOwnProperty is not a function. -built-ins/Array/prototype/splice/create-ctor-non-object.js # Only data fields may be assigned. +built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # .toLocaleString is not a function. built-ins/Array/prototype/toString/S15.4.4.2_A1_T1.js # .toString is not a function. built-ins/Array/prototype/toString/S15.4.4.2_A1_T2.js # .toString is not a function.