diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 4c4ac044ed6..7cc781ed1bc 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -213,9 +213,10 @@ ultimate source of truth. synchronous iterator support for `fromEntries`. - [x] `Object.keys` over arrays and tool references. - [x] Object identity is preserved by in-CodeMode Object helpers. -- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. Prototype machinery is not observable: - data objects have no prototype, so `({}).constructor` and `[].__proto__` read as `undefined` and `o.__proto__ = x` - sets an own field. +- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. `x.constructor` without an own key resolves + to the owning built-in (`[].constructor === Array`, `new TypeError().constructor === TypeError`); prototype objects + are not observable, so `[].__proto__` and `Object.prototype` read as `undefined` and `o.__proto__ = x` sets an own + field. - [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 diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 8651a1960ce..4a1e1dbb865 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -85,7 +85,7 @@ import { numberMethods } from "../stdlib/number.js" import { constructRegExp, regexpMethods, regexpProperties } from "../stdlib/regexp.js" import { stringMethods } from "../stdlib/string.js" import { uriArgument, urlMethods, urlProperties, urlSearchParamsMethods, urlWritableProperties } from "../stdlib/url.js" -import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js" +import { coerceToNumber, coerceToString, compoundOperators, errorBrandName } from "../stdlib/value.js" import { Values } from "../values.js" const MAX_ARRAY_LENGTH = 4_294_967_295 @@ -113,6 +113,25 @@ 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 Values.Date) return "Date" + if (value instanceof Values.RegExp) return "RegExp" + if (value instanceof Values.Map) return "Map" + if (value instanceof Values.Set) return "Set" + 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 + return errorBrandName(value) ?? "Object" +} + const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { if (rhs instanceof HostFunction && rhs.instanceOf !== undefined) return rhs.instanceOf(lhs) throw new InterpreterRuntimeError( @@ -206,6 +225,8 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, en /** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */ export class Runtime { readonly runner: Runner + /** Built-in globals by name, unaffected by program shadowing. */ + readonly builtins: ReadonlyMap private readonly root: Frame constructor( @@ -224,7 +245,8 @@ export class Runtime { settlePromise: (promise) => this.root.settlePromise(promise), syncIterator: (value, node) => this.root.syncIterator(value, node), } - for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value }) + this.builtins = new Map(globals(this)) + for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value }) } run(program: Program): Effect.Effect { @@ -2002,7 +2024,7 @@ class Frame { private getMemberReference( node: MemberExpression, - operation: "read" | "delete" = "read", + operation: "read" | "write" | "delete" = "read", ): Effect.Effect< | MemberReference | ToolReference @@ -2043,6 +2065,12 @@ class Frame { return new ComputedValue(objectValue.member(key, propertyNode)) } + // Values have no prototype chain, so `.constructor` resolves to the owning built-in directly. + if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) { + const name = constructorName(objectValue) + if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name)) + } + if (typeof objectValue === "string") { if (key === "length") return new ComputedValue(objectValue.length) const index = typeof key === "symbol" ? undefined : parseArrayIndex(key) @@ -2198,7 +2226,7 @@ class Frame { ): Effect.Effect { const self = this return Effect.gen(function* () { - const reference = yield* self.getMemberReference(node) + const reference = yield* self.getMemberReference(node, "write") if ( reference === OptionalShortCircuit || reference instanceof ComputedValue || diff --git a/packages/codemode/test/constructor-test262.test.ts b/packages/codemode/test/constructor-test262.test.ts new file mode 100644 index 00000000000..c038f3f21c3 --- /dev/null +++ b/packages/codemode/test/constructor-test262.test.ts @@ -0,0 +1,66 @@ +/** + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/built-ins/RegExp/S15.10.7_A3_T1.js + * - test/built-ins/RegExp/S15.10.7_A3_T2.js + * - test/built-ins/Object/S15.2.2.1_A1_T1.js + * + * Copyright 2009 the Sputnik authors. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + * + * Only the instance-side assertions are ported. Test262 otherwise reaches `constructor` through + * `X.prototype.constructor`, boxed primitives (`new Object(1)`), `Function`, `isPrototypeOf`, or + * `.call`, none of which CodeMode exposes: values have no prototype chain, so `x.constructor` + * resolves directly to the owning built-in. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("constructor Test262 parity", () => { + test("test/built-ins/RegExp/S15.10.7_A3_T1.js", async () => { + expect( + await value(` + const __re = /[^a]*/ + return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp] + `), + ).toEqual(["object", true, true]) + }) + + test("test/built-ins/RegExp/S15.10.7_A3_T2.js", async () => { + expect( + await value(` + const __re = new RegExp() + return [typeof __re, __re.constructor === RegExp, __re instanceof RegExp] + `), + ).toEqual(["object", true, true]) + }) + + test("test/built-ins/Object/S15.2.2.1_A1_T1.js", async () => { + expect( + await value(` + const obj = new Object() + return [obj !== undefined, obj.constructor === Object] + `), + ).toEqual([true, true]) + }) + + test("every built-in reports itself for its own values", async () => { + expect( + await value(` + return [ + [].constructor === Array, "".constructor === String, (1).constructor === Number, true.constructor === Boolean, + new Date(0).constructor === Date, new Map().constructor === Map, new Set().constructor === Set, + new URL("https://a.b/").constructor === URL, new URLSearchParams("a=1").constructor === URLSearchParams, + Promise.resolve(1).constructor === Promise, new TypeError("x").constructor === TypeError, + new RangeError("x").constructor === RangeError, new AggregateError([]).constructor === AggregateError, + ] + `), + ).toEqual(Array(13).fill(true)) + }) +}) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 5be9122648a..f6af2b8a1cf 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -722,6 +722,17 @@ describe("Set", () => { }) describe("stdlib integration", () => { + test("constructor follows own keys, shadowing, writes, and new", async () => { + expect( + await value(`return [JSON.parse('{"constructor":"Foo"}').constructor, ({ constructor: 1 }).constructor]`), + ).toEqual(["Foo", 1]) + expect(await value(`const Array = 5; return [].constructor.isArray([])`)).toBe(true) + expect(await value(`const o = {}; o.constructor = 7; return o.constructor`)).toBe(7) + expect(await value(`return new ([].constructor)(3).length`)).toBe(3) + expect(await value(`return typeof ({}).constructor`)).toBe("function") + expect(await value(`return ({}).constructor.constructor`)).toBeNull() + }) + test("new dispatches on the constructor value, not its name", async () => { expect(await value(`const D = Date; return new D(0) instanceof Date`)).toBe(true) expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1) diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index f1b00f85e99..f5948925d6b 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -185,17 +185,17 @@ describe("blocked member names on tool paths", () => { const array = [] object.__proto__ = { polluted: true } return [ - object.constructor, array.constructor, "".constructor, Math.constructor, - object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor, - typeof ({}).constructor, typeof [].__proto__, + object.constructor === Object, array.constructor === Array, "".constructor === String, Math.constructor, + object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor === Object, + ({}).constructor.constructor, [].constructor.__proto__, typeof [].__proto__, ] `, ), - ).toEqual([null, null, null, null, true, null, null, null, null, "undefined", "undefined"]) + ).toEqual([true, true, true, null, true, null, null, null, true, null, null, "undefined"]) expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain( "Cannot read properties of a function", ) - const escape = await failure(runtime, `return ({}).constructor.constructor("return 1")()`) + const escape = await failure(runtime, `return ({}).constructor.constructor.constructor("return 1")()`) expect(escape.message).toContain("Cannot access a property on a non-object value") const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`) expect(poisoned.message).toContain("Cannot access a property on a non-object value")