diff --git a/AGENTS.md b/AGENTS.md index 5f2682b..5f83d96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence `expect(followsJavaScript(code)).toEqual({ maple, jsInterpreter })`, and what Maple rejects as `expect(rejected(code)).toBe(code)`. Both assert all three engines; add the reason to the README. A divergence where Maple follows JavaScript needs no discussion; one where Maple departs from it does. +- Node runs in the test process, last in every helper. A case that changes a built-in (`Math.max = ...`) restores it in a + `finally` with its original descriptor. - `@netwin/maple` maps to `packages/maple/src/index.ts` through tsconfig `paths`, which Vitest reads too. ## Conventions diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 74562b5..e8190f8 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -67,6 +67,9 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `f.bind(null, 1)`: Maple names it `bound f` with the rest of `f`'s length, js-interpreter `fBound`, 0 | A bound function's `name` and `length` are ES2015; js-interpreter follows ES5. | | `f.apply(null, 1)`, `f.apply(null, 'ab')` | A primitive is no list of arguments, so it is a `TypeError`. js-interpreter ignores a number and spreads a string. | | `JSON.parse(text, reviver)`: Maple applies the reviver, with the holder as `this` | As in JavaScript; js-interpreter ignores the reviver and returns the parsed value as it is. | +| `Object.getOwnPropertyNames(/a/g)`: Maple lists only `lastIndex`, and `delete r.source` keeps it | `source` and the flags are accessors on the prototype since ES2015; js-interpreter follows ES5, where they are own. | +| `delete f.name`: Maple leaves `''`, which cannot be written, js-interpreter `undefined`, which can | `Function.prototype.name` is `''` and read-only since ES2015; js-interpreter follows ES5, which has no `name`. | +| `delete Math.PI`, `delete r.lastIndex`: Maple throws a `TypeError` | Neither can be deleted, which strict mode reports; js-interpreter throws for neither and deletes `lastIndex`. | ### Maple departs from JavaScript diff --git a/packages/maple-parity/src/cases/errors.spec.ts b/packages/maple-parity/src/cases/errors.spec.ts index d06371d..15d875d 100644 --- a/packages/maple-parity/src/cases/errors.spec.ts +++ b/packages/maple-parity/src/cases/errors.spec.ts @@ -41,6 +41,22 @@ describe('Error.prototype.message', () => { }); }); +describe('delete:error.message', () => { + it('leaves the empty message of the prototype', () => { + expect( + parity('var e = new TypeError("m"); return [delete e.message, e.message, String(e)];') + ).toEqual([true, '', 'TypeError']); + }); + + it('takes a later assignment as a new own property', () => { + expect( + parity( + 'var e = new Error("m"); delete e.message; e.message = "n"; return [String(e), Object.keys(e)];' + ) + ).toEqual(['Error: n', ['message']]); + }); +}); + describe('instance:error.message', () => { it('Error message is converted to a string', () => { expect(parity('return [new Error(42).message, new Error(true).message];')).toEqual([ diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index f91740f..7a82197 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -297,6 +297,13 @@ describe('instance:function.length', () => { ) ).toStrictEqual([0, 2, 3]); }); + + it('can be deleted, leaving 0', () => { + expect(parity('function two(a, b) {} return [delete two.length, two.length];')).toEqual([ + true, + 0 + ]); + }); }); describe('length and name of built-in functions', () => { diff --git a/packages/maple-parity/src/cases/math.spec.ts b/packages/maple-parity/src/cases/math.spec.ts index 97da763..f1d022d 100644 --- a/packages/maple-parity/src/cases/math.spec.ts +++ b/packages/maple-parity/src/cases/math.spec.ts @@ -121,6 +121,27 @@ describe('Math.max', () => { ) ).toEqual(9); }); + it('can be replaced by a script', () => { + const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {}; + try { + expect( + parity('Math.max = function () { return 0; }; return [Math.max(1, 2), Object.keys(Math)];') + ).toEqual([0, []]); + } finally { + Object.defineProperty(Math, 'max', max); + } + }); + it('can be deleted by a script', () => { + const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {}; + try { + expect(parity('delete Math.max; return [typeof Math.max, "max" in Math];')).toEqual([ + 'undefined', + false + ]); + } finally { + Object.defineProperty(Math, 'max', max); + } + }); }); describe('Math.min', () => { diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 851d4c0..bff0422 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -275,6 +275,56 @@ describe('JSON.parse', () => { }); }); +// A RegExp's source and flags are accessors on the prototype since ES2015, so lastIndex is its one +// own property; js-interpreter follows ES5, where they are own properties of every regexp. + +describe('Object.getOwnPropertyNames:RegExp', () => { + it('lists lastIndex only', () => { + expect(followsJavaScript('return Object.getOwnPropertyNames(/a/g);')).toEqual({ + maple: ['lastIndex'], + jsInterpreter: ['lastIndex', 'source', 'global', 'ignoreCase', 'multiline'] + }); + }); + + it('keeps source when a script deletes it', () => { + expect(followsJavaScript('var r = /a/g; return [delete r.source, r.source];')).toEqual({ + maple: [true, 'a'], + jsInterpreter: [true, '(?:)'] + }); + }); +}); + +describe('delete:function.name', () => { + it("leaves Function.prototype's empty name", () => { + expect(followsJavaScript('function f() {} return [delete f.name, f.name];')).toEqual({ + maple: [true, ''], + jsInterpreter: [true, undefined] + }); + }); + + it('leaves a name that cannot be written', () => { + expect( + followsJavaScript('function f() {} delete f.name; f.name = "g"; return f.name;') + ).toEqual({ maple: MapleErrorCode.RUNTIME_ERROR, jsInterpreter: 'g' }); + }); +}); + +describe('delete:not configurable', () => { + it('throws for a constant of a namespace', () => { + expect(followsJavaScript('delete Math.PI;')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: undefined + }); + }); + + it('throws for the lastIndex of a regexp', () => { + expect(followsJavaScript('var r = /a/g; return [delete r.lastIndex, r.lastIndex];')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: [true, undefined] + }); + }); +}); + // Maple departs from JavaScript on purpose. // The object model: Maple has no script-visible prototypes. An object's behaviour comes from its diff --git a/packages/maple-parity/src/parity.ts b/packages/maple-parity/src/parity.ts index fd17962..e4992ab 100644 --- a/packages/maple-parity/src/parity.ts +++ b/packages/maple-parity/src/parity.ts @@ -2,16 +2,20 @@ import { MapleError, MapleErrorCode } from '@netwin/maple'; import { expect } from 'vitest'; import { type Outcome, runJsInterpreter, runMaple, runNode } from './engines.ts'; +// Node runs last in every helper. It runs in this process, so a case that changes a built-in +// (`Math.max = ...`) changes it here until the case restores it, and no other engine may run +// in the meantime: js-interpreter and Maple use the host's built-ins themselves. + /** * Runs `code` in all three engines, asserts that Maple returns what Node and js-interpreter * return, and hands that value back so the test can spell out what it is. */ export function parity(code: string, data?: Record): unknown { const value = valueOf(runMaple(code, data), 'Maple'); - expect(value, 'Node').toStrictEqual(valueOf(runNode(code, data), 'Node')); - expect(value, 'js-interpreter').toStrictEqual( - valueOf(runJsInterpreter(code, data), 'js-interpreter') - ); + const jsInterpreter = valueOf(runJsInterpreter(code, data), 'js-interpreter'); + const node = valueOf(runNode(code, data), 'Node'); + expect(value, 'Node').toStrictEqual(node); + expect(value, 'js-interpreter').toStrictEqual(jsInterpreter); return value; } @@ -46,8 +50,9 @@ export function allThrow(code: string, data?: Record): string { */ export function rejected(code: string, { sloppy = false } = {}): MapleErrorCode { valueOf(runJsInterpreter(code, {}, !sloppy), 'js-interpreter'); + const rejection = rejectionOf(runMaple(code)); valueOf(runNode(code, {}, !sloppy), 'Node'); - return rejectionOf(runMaple(code)); + return rejection; } /** diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index 72bb184..9304c4b 100644 --- a/packages/maple/docs/boundary.md +++ b/packages/maple/docs/boundary.md @@ -132,8 +132,10 @@ safe to pass to `JSON.stringify`. `undefined` at the top level is returned as `u A sandbox error becomes a plain object with `name`, `message` and `stack`, and with `properties` holding what the script added to it, absent when it added nothing. An error with an own `cause` -carries it as `cause`. `name` and `message` arrive as strings, as `toString()` reads them. The -properties a script added to a wrapper stay behind, since a native `Date` or `Map` takes its place. +carries it as `cause`. `name`, `message` and `stack` arrive as strings, as the script left them: a +deleted `stack` arrives as `''`, and one the script deleted and assigned again arrives in its own +field, not in `properties`. The properties a script added to a wrapper stay behind, since a native +`Date` or `Map` takes its place. A result nested deeper than 1000 levels ends the run with `RUNTIME_ERROR` and a `RangeError` without a position, since the program has already finished. In a nested run, the intrinsic receives diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 0021e73..4db94eb 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -176,6 +176,10 @@ on the right-hand side of `instanceof`, and it adds no frame to `stack`. with or without `new`. Errors Maple raises itself are the same kind of object, so a script can catch and inspect them. +`name`, `message` and `stack` can be deleted. Deleting `message` leaves `''` and deleting `name` +leaves the constructor's name, as the prototype has them in JavaScript; deleting `stack` leaves +nothing. Assigning one of them afterwards adds an own property, which `Object.keys` lists. + `stack` is read-only and fixed when the error is created. It lists the script's own frames and nothing of the host, innermost first: diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 739a8f4..fc2d1d7 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -68,8 +68,9 @@ side, a script function, a bound function or `String` included, throws a `TypeEr ## Functions -Script functions and built-in functions have JavaScript's read-only `length` and `name`, names -inferred from bindings included. Host functions, and functions bound from them, have no properties +Script functions and built-in functions have JavaScript's `length` and `name`, names inferred from +bindings included. Neither can be written, but both can be deleted, which leaves what +`Function.prototype` has: `0` and `''`, which cannot be written either. Host functions, and functions bound from them, have no properties at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply` and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script @@ -84,8 +85,10 @@ At the top level of the script `this` is `undefined`, since there is no global o function still cannot be constructed ([Constructors](#constructors)). Every function, and every namespace such as `Math`, takes the properties a script adds, as in -`fn.cache = {}`. Built-ins, host functions and namespaces are shared by every run, so what a run -adds to one, or `freeze`s on it, is gone when the next run starts. +`fn.cache = {}`. As in JavaScript, the methods of a built-in can be replaced and deleted, as in +`Math.max = fn`, while its constants, such as `Math.PI` or `Number.EPSILON`, can be neither. +Built-ins, host functions and namespaces are shared by every run, so what a run adds to one, +replaces on it, deletes from it or `freeze`s on it is gone when the next run starts. A tagged template `` tag`a${x}b` `` calls `tag` with the strings array and then each substitution as a value, not converted to a string, with `this` bound as for a call of the same form. The strings @@ -111,9 +114,11 @@ Inside the sandbox exactly these values exist: Wrappers are instances of Maple's own classes, which a script cannot construct, so a plain object can never pose as one. The native value inside is never exposed; every method goes through a -dispatch table. A wrapper has no own properties except the few listed with its type (`size`, the -RegExp flags and `lastIndex`), plus the ones the script adds, which are kept apart from the native -value. +dispatch table. A wrapper's only own property is a RegExp's `lastIndex`, plus the ones the script +adds, which are kept apart from the native value. `size` and the RegExp flags and `source` are +properties of the type, as JavaScript keeps them on the prototype: every value answers to them and +`in` finds them, but `Object.hasOwn` and `Object.getOwnPropertyNames` leave them out, and deleting +one changes nothing. ## Methods diff --git a/packages/maple/src/boundary/to-host.ts b/packages/maple/src/boundary/to-host.ts index 757b3cf..e3f48a1 100644 --- a/packages/maple/src/boundary/to-host.ts +++ b/packages/maple/src/boundary/to-host.ts @@ -29,6 +29,9 @@ type UnconvertibleKind = 'iterator' | 'WeakMap' | 'WeakSet' | 'cyclic value'; */ const DROPPED_KEYS: ReadonlySet = new Set(['__proto__', 'constructor']); +/** What an error hands the host as fields of its own, so not again among its `properties`. */ +const ERROR_FIELDS: ReadonlySet = new Set(['name', 'message', 'stack', 'cause']); + /** Thrown by a strict `toHost` for a value that must not leave the sandbox. */ export class UnconvertibleValueError extends Error { public readonly kind: UnconvertibleKind; @@ -218,8 +221,10 @@ function copyContainer(value: object, walk: Walk, depth: number): unknown { /** * `properties` is there only when the script added any, and `cause` only when the error has one, - * so an error without them keeps the shape it always had. `cause` has its own field whether it is - * enumerable or not, and never repeats in `properties`. A wrapper's added properties stay behind: + * so an error without them keeps the shape it always had. `name`, `message`, `stack` and `cause` + * have fields of their own and never repeat in `properties`, not even when the script deleted one + * and assigned it again, which makes it an added property. `cause` has its field whether it is + * enumerable or not. A wrapper's added properties stay behind: * a Date or a Map leaves as the host's own. */ function copyError(error: SandboxError, walk: Walk, depth: number): SandboxErrorInfo { @@ -231,7 +236,7 @@ function copyError(error: SandboxError, walk: Walk, depth: number): SandboxError walk.copies.set(error, copy); - const keys = error.properties.enumerableKeys().filter((key) => key !== 'cause'); + const keys = error.properties.enumerableKeys().filter((key) => !ERROR_FIELDS.has(key)); const cause = error.properties.getAdded('cause'); if (keys.length === 0 && cause === undefined) { return copy; diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index 4807f9d..28fafdd 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -258,9 +258,10 @@ describe('JSON.stringify', () => { expect(error.code).toBe('LIMIT_EXCEEDED'); }); - it('is a read-only namespace', () => { + it('is a namespace whose methods a run can replace', () => { expect(run('return typeof JSON;')).toBe('object'); expect(run('return typeof JSON.parse;')).toBe('function'); - expect(runAndCatch(maple, 'JSON.parse = 1;').sandboxError?.name).toBe('TypeError'); + expect(run('JSON.parse = 1; return JSON.parse;')).toBe(1); + expect(run('return typeof JSON.parse;')).toBe('function'); }); }); diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 2bcf204..740f41e 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -87,7 +87,7 @@ describe('Map', () => { expect(run('return new Map().forEach.length;')).toBe(1); }); - it('has no own properties besides size but takes the ones a script adds', () => { + it('has no own properties but takes the ones a script adds', () => { expect(run('return ["size" in new Map(), "get" in new Map()];')).toEqual([true, true]); expect(run('return new Map().foo;')).toBeUndefined(); expect(run('return Object.keys(new Map([[1, 2]]));')).toEqual([]); @@ -106,6 +106,18 @@ describe('Map', () => { expect(run('return JSON.stringify(new Map([[1, 2]]));')).toBe('{}'); }); + it('answers to size without having it as its own, as JavaScript keeps it on the prototype', () => { + expect(run('return Object.hasOwn(new Map(), "size");')).toBe(false); + expect(run('return Object.getOwnPropertyNames(new Map([[1, 2]]));')).toEqual([]); + expect(run('const m = new Map([[1, 2]]); return [delete m.size, m.size];')).toEqual([true, 1]); + expect(run('const m = new Map(); Object.freeze(m); return Object.isFrozen(m);')).toBe(true); + expect(run('const s = new Set([1]); return [Object.hasOwn(s, "size"), "size" in s];')).toEqual([ + false, + true + ]); + expect(run('const s = new Set([1]); return [delete s.size, s.size];')).toEqual([true, 1]); + }); + it('crosses the boundary as a native Map and is read-only from frozen input', () => { const data = { lookup: new Map([['a', { n: 1 }]]) }; diff --git a/packages/maple/src/builtins/math/math.spec.ts b/packages/maple/src/builtins/math/math.spec.ts index 008ce8b..6df065a 100644 --- a/packages/maple/src/builtins/math/math.spec.ts +++ b/packages/maple/src/builtins/math/math.spec.ts @@ -60,7 +60,7 @@ describe('Math', () => { expect(run('return Math.SQRT2;')).toBe(Math.SQRT2); }); - it('is a read-only namespace', () => { + it('is a namespace whose constants cannot be written or deleted', () => { expect(run('return typeof Math;')).toBe('object'); expect(run('return typeof Math.max;')).toBe('function'); expect(run('return Math.foo;')).toBeUndefined(); @@ -70,11 +70,37 @@ describe('Math', () => { expect(runAndCatch(maple, 'Math.PI = 3;').sandboxError?.message).toBe( "Cannot assign to read-only property 'PI' of a Math" ); - expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.name).toBe('TypeError'); + expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.message).toBe( + "Cannot delete property 'PI' of #" + ); expect(runAndCatch(maple, 'new Math();').code).toBe('DISALLOWED_SYNTAX'); expect(runAndCatch(maple, 'Math();').sandboxError?.message).toBe('Math is not a function'); }); + it('lets a run replace and delete its methods, as JavaScript does', () => { + expect( + run( + 'Math.max = () => 0; return [Math.max(1, 2), Object.keys(Math), Object.hasOwn(Math, "max")];' + ) + ).toEqual([0, [], true]); + expect(run('return [delete Math.max, Math.max, "max" in Math];')).toEqual([ + true, + undefined, + false + ]); + expect(run('delete Math.max; Math.max = 1; return [Math.max, Object.keys(Math)];')).toEqual([ + 1, + ['max'] + ]); + expect(run('const max = Math.max; Math.max = 1; return [max(1, 2), Math.min(1, 2)];')).toEqual([ + 2, 1 + ]); + expect(runAndCatch(maple, 'Object.freeze(Math); Math.max = 1;').sandboxError?.message).toBe( + "Cannot assign to read-only property 'max' of a Math" + ); + expect(run('return Math.max(1, 2);')).toBe(2); + }); + it('never leaves the sandbox', () => { expect(run('return Math;')).toBeUndefined(); expect(run('return [Math.max];')).toEqual([undefined]); diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 87c4fde..fc76362 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -180,8 +180,9 @@ describe('Object statics', () => { }); }); - it('cannot be written to', () => { - expect(runAndCatch(maple, 'Object.keys = 1;').sandboxError?.name).toBe('TypeError'); + it('can be replaced by a run', () => { + expect(run('Object.keys = 1; return Object.keys;')).toBe(1); + expect(run('return typeof Object.keys;')).toBe('function'); }); }); @@ -220,7 +221,7 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { ).toEqual([true, false, true, false]); }); - it('lists own names the way in answers them', () => { + it('lists own names, enumerable or not', () => { expect(run('return Object.getOwnPropertyNames({ a: 1, b: 2 });')).toEqual(['a', 'b']); expect(run('return Object.getOwnPropertyNames([7]);')).toEqual(['0', 'length']); expect(run('return Object.getOwnPropertyNames("ab");')).toEqual(['0', '1', 'length']); @@ -229,12 +230,13 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { 'message', 'stack' ]); - expect(run('return Object.getOwnPropertyNames(new Map());')).toEqual(['size']); + expect(run('return Object.getOwnPropertyNames(new Map());')).toEqual([]); + expect(run('return Object.getOwnPropertyNames(new Set());')).toEqual([]); expect(run('return Object.getOwnPropertyNames(() => 1);')).toEqual(['length', 'name']); expect(run('return Object.getOwnPropertyNames(Math.max);')).toEqual(['length', 'name']); expect(run('return Object.getOwnPropertyNames(host);', { host: () => 1 })).toEqual([]); expect(run('return Object.getOwnPropertyNames(5);')).toEqual([]); - expect(run('return Object.getOwnPropertyNames(/x/).includes("lastIndex");')).toBe(true); + expect(run('return Object.getOwnPropertyNames(/x/g);')).toEqual(['lastIndex']); expect(run('return Object.getOwnPropertyNames(Math).includes("max");')).toBe(true); expect(run('try { Object.getOwnPropertyNames(null); } catch (e) { return e.name; }')).toBe( 'TypeError' @@ -297,6 +299,12 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { expect(errorMessage(maple, 'const m = new Map(); m.x = 1; Object.freeze(m); delete m.x;')).toBe( "Cannot delete property 'x' of #" ); + expect( + errorMessage(maple, 'const d = new Date(0); d.x = 1; Object.freeze(d); delete d.x;') + ).toBe("Cannot delete property 'x' of [object Date]"); + expect(errorMessage(maple, 'Math.x = 1; Object.freeze(Math); delete Math.x;')).toBe( + "Cannot delete property 'x' of #" + ); expect( errorMessage(maple, 'function f() { Object.freeze(arguments); delete arguments[0]; } f(1);') ).toBe("Cannot delete property '0' of #"); @@ -322,12 +330,18 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { ).toEqual(["Cannot delete property 'x' of [object Error]", 'finally']); }); - it('still lets a delete of a fixed property that freeze did not lock change nothing', () => { - expect(run('const f = () => 1; return delete f.name;')).toBe(true); + it('deletes a configurable fixed property and refuses one that is not configurable', () => { + expect(run('const f = () => 1; return [delete f.name, f.name];')).toEqual([true, '']); expect(run('return [delete Object.freeze(new Map()).size, delete /a/.source];')).toEqual([ true, true ]); + expect(errorMessage(maple, 'delete /a/.lastIndex;')).toBe( + "Cannot delete property 'lastIndex' of [object RegExp]" + ); + expect(errorMessage(maple, 'delete Number.EPSILON;')).toBe( + "Cannot delete property 'EPSILON' of [function]" + ); }); }); @@ -434,6 +448,17 @@ describe('Object.seal and isSealed', () => { expect(run('Object.seal(Math); return Object.isSealed(Math);')).toBe(true); expect(run('Math.x = 1; return [Object.isSealed(Math), Math.x];')).toEqual([false, 1]); }); + + it('find a built-in that only takes no new properties neither sealed nor frozen', () => { + expect( + run('Object.preventExtensions(Math); return [Object.isSealed(Math), Object.isFrozen(Math)];') + ).toEqual([false, false]); + expect( + run( + 'Object.preventExtensions(Math.max); return [Object.isSealed(Math.max), Object.isFrozen(Math.max)];' + ) + ).toEqual([false, false]); + }); }); describe('Object.preventExtensions and isExtensible', () => { diff --git a/packages/maple/src/builtins/object/statics/has-own.ts b/packages/maple/src/builtins/object/statics/has-own.ts index 987ba46..fd65a7c 100644 --- a/packages/maple/src/builtins/object/statics/has-own.ts +++ b/packages/maple/src/builtins/object/statics/has-own.ts @@ -1,8 +1,16 @@ -import { hasMember, toArrayIndex, toPropertyKey } from '../../../values/members.ts'; +import { hasOwnMember, toArrayIndex, toPropertyKey } from '../../../values/members.ts'; import { NativeFunction } from '../../../values/native.ts'; import { asObjectCoercibleOrThrow } from '../utils/as-object-coercible-or-throw.ts'; -/** Also answers for strings, whose own keys are the indices and `length`. */ +/** + * `Object.hasOwn(target, key)`: whether `target` has `key` as its own property, not only through + * its type: + * + * - `Object.hasOwn([1], 'length')` is `true`; + * - `Object.hasOwn(new Map(), 'size')` is `false`. + * + * A string's own keys are its indices and `length`; `null` and `undefined` are a TypeError. + */ export const OBJECT_STATIC_HAS_OWN: NativeFunction = new NativeFunction( 'hasOwn', Object.hasOwn.length, @@ -15,6 +23,8 @@ export const OBJECT_STATIC_HAS_OWN: NativeFunction = new NativeFunction( return name === 'length' || (index !== undefined && index < object.length); } - return typeof object === 'object' && object !== null ? hasMember(object, name, context) : false; + return typeof object === 'object' && object !== null + ? hasOwnMember(object, name, context.runState) + : false; } ); diff --git a/packages/maple/src/builtins/regexp/regexp.spec.ts b/packages/maple/src/builtins/regexp/regexp.spec.ts index 61bd347..2d66a87 100644 --- a/packages/maple/src/builtins/regexp/regexp.spec.ts +++ b/packages/maple/src/builtins/regexp/regexp.spec.ts @@ -58,6 +58,18 @@ describe('RegExp', () => { expect(run('return /a/g + "";')).toBe('/a/g'); }); + it('has lastIndex as its one own property, as JavaScript keeps the rest on the prototype', () => { + expect(run('return Object.getOwnPropertyNames(/a/gi);')).toEqual(['lastIndex']); + expect(run('return [Object.hasOwn(/a/, "lastIndex"), Object.hasOwn(/a/, "source")];')).toEqual([ + true, + false + ]); + expect( + run('const r = /a/g; return [delete r.source, r.source, delete r.global, r.global];') + ).toEqual([true, 'a', true, true]); + expect(run('const r = /a/; Object.freeze(r); return Object.isFrozen(r);')).toBe(true); + }); + it('is read-only when it came from frozen input', () => { const data = { pattern: /a/g }; diff --git a/packages/maple/src/errors.spec.ts b/packages/maple/src/errors.spec.ts index ebaec88..afef2e9 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -792,3 +792,46 @@ describe('errors from conversions', () => { expect(error.line).toBeUndefined(); }); }); + +describe('deleting the properties of an error', () => { + it('leaves what the prototype has, as JavaScript does', () => { + expect( + run( + 'const e = new TypeError("m"); return [delete e.message, e.message, "message" in e, Object.hasOwn(e, "message"), String(e)];' + ) + ).toEqual([true, '', true, false, 'TypeError']); + expect( + run('const e = new TypeError("m"); e.name = "X"; delete e.name; return [e.name, String(e)];') + ).toEqual(['TypeError', 'TypeError: m']); + expect( + run('const e = new Error("m"); return [delete e.stack, e.stack, "stack" in e];') + ).toEqual([true, undefined, false]); + }); + + it('reaches the host as the script left it, without repeating a field in properties', () => { + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.stack; throw e;').sandboxError + ).toStrictEqual({ name: 'Error', message: 'm', stack: '' }); + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.stack; e.stack = "s"; throw e;') + .sandboxError + ).toStrictEqual({ name: 'Error', message: 'm', stack: 's' }); + expect( + run('const e = new Error("m"); delete e.message; e.message = "n"; e.code = 1; return e;') + ).toMatchObject({ message: 'n', properties: { code: 1 } }); + expect( + run('const e = new Error("m"); delete e.message; e.message = "n"; return e;') + ).not.toHaveProperty('properties'); + }); + + it('takes an assignment after the delete as a new own property, listed last', () => { + expect( + run( + 'const e = new Error("m"); delete e.message; e.message = "n"; return [String(e), Object.keys(e), Object.getOwnPropertyNames(e)];' + ) + ).toEqual(['Error: n', ['message'], ['name', 'stack', 'message']]); + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.message; e.message = "n"; throw e;') + ).toMatchObject({ message: 'n', sandboxError: { name: 'Error', message: 'n' } }); + }); +}); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 5cbb83a..743db96 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -246,7 +246,7 @@ describe('escape attempts', () => { expect(frozenErrorName('when.setTime(1);', data)).toBe('TypeError'); expect(frozenErrorName('lookup.set("x", 1);', data)).toBe('TypeError'); expect(frozenErrorName('obj = {};', data)).toBe('TypeError'); - expect(errorName('Math.max = 1;')).toBe('TypeError'); + expect(errorName('Number.EPSILON = 1;')).toBe('TypeError'); expect(errorName('Math.PI = 3;')).toBe('TypeError'); expect(errorName('undefined = 1;')).toBe('TypeError'); expect(data.obj.a).toEqual([1]); @@ -527,7 +527,7 @@ describe('escape attempts', () => { expect(new Maple().run('return [].push.x;').value).toBeUndefined(); }); - it('keep what a run and its nested runs add to a shared value apart', () => { + it('keep what a run and its nested runs add to, replace on or delete from a shared value apart', () => { const nesting = new Maple({ globals: { exec: intrinsic(([code], sandbox) => sandbox.run(String(sandbox.toHost(code))).value) @@ -545,6 +545,20 @@ describe('escape attempts', () => { '[].push.planted = 1; const inner = exec("[].push.planted = 2; return [].push.planted;"); return [inner, [].push.planted];' ).value ).toEqual([2, 1]); + expect(nesting.run('Math.max = 1; return exec("return typeof Math.max;");').value).toBe( + 'function' + ); + expect(nesting.run('exec("delete Math.max;"); return typeof Math.max;').value).toBe( + 'function' + ); + expect( + nesting.run('delete [].push.name; return [exec("return [].push.name;"), [].push.name];') + .value + ).toEqual(['push', '']); + expect(new Maple().run('return [typeof Math.max, [].push.name];').value).toEqual([ + 'function', + 'push' + ]); }); it('keep a shared value frozen only for the run that froze it', () => { diff --git a/packages/maple/src/evaluator/language/functions.spec.ts b/packages/maple/src/evaluator/language/functions.spec.ts index ee5a0e5..19c5e47 100644 --- a/packages/maple/src/evaluator/language/functions.spec.ts +++ b/packages/maple/src/evaluator/language/functions.spec.ts @@ -362,7 +362,31 @@ describe('functions', () => { }); it('cannot be written', () => { - expect(runAndCatch(maple, 'Math.max.name = "x";').sandboxError?.name).toBe('TypeError'); + expect(errorMessage('Math.max.name = "x";')).toBe( + "Cannot assign to read-only property 'name' of a Function" + ); + }); + + it('can be deleted for the run, leaving what Function.prototype has', () => { + expect( + maple.run('return [delete Math.max.name, Math.max.name, delete Number.name, Number.name];') + .value + ).toEqual([true, '', true, '']); + expect( + maple.run( + 'delete Math.max.name; return [Object.hasOwn(Math.max, "name"), "name" in Math.max];' + ).value + ).toEqual([false, true]); + expect( + maple.run( + 'delete [].push.length; return [[].push.length, Object.getOwnPropertyNames([].push)];' + ).value + ).toEqual([0, ['name']]); + expect(maple.run('return [Math.max.name, Number.name, [].push.length];').value).toEqual([ + 'max', + 'Number', + 1 + ]); }); }); @@ -398,6 +422,28 @@ describe('functions', () => { 'TypeError' ); }); + + it('can be deleted, leaving what Function.prototype has, which cannot be written either', () => { + expect( + maple.run('function f(a, b) {} return [delete f.name, f.name, delete f.length, f.length];') + .value + ).toEqual([true, '', true, 0]); + expect( + maple.run( + 'function f() {} delete f.name; return [Object.getOwnPropertyNames(f), "name" in f, Object.hasOwn(f, "name")];' + ).value + ).toEqual([['length'], true, false]); + expect(errorMessage('function f() {} delete f.name; f.name = "g";')).toBe( + "Cannot assign to read-only property 'name' of a Function" + ); + expect(errorMessage('function f() {} Object.freeze(f); delete f.name;')).toBe( + "Cannot delete property 'name' of [function]" + ); + expect( + maple.run('function f() {} delete f.name; Object.freeze(f); return Object.isFrozen(f);') + .value + ).toBe(true); + }); }); describe('this', () => { diff --git a/packages/maple/src/support/noop.ts b/packages/maple/src/support/noop.ts new file mode 100644 index 0000000..b976449 --- /dev/null +++ b/packages/maple/src/support/noop.ts @@ -0,0 +1,5 @@ +/** + * A callback that does nothing and returns `undefined`, for a parameter that must be a function + * where the caller has nothing to do, such as a method lookup for keys that are never methods. + */ +export const NOOP_CALLBACK = (): undefined => undefined; diff --git a/packages/maple/src/values/builtin.ts b/packages/maple/src/values/builtin.ts index e33a6f1..eece98f 100644 --- a/packages/maple/src/values/builtin.ts +++ b/packages/maple/src/values/builtin.ts @@ -1,5 +1,4 @@ import { FUNCTION_PLACEHOLDER, type Primitive, type PrimitiveHint } from './coercion.ts'; -import type { MemberContext } from './members.ts'; import type { CallContext } from './native.ts'; import type { Prototype } from './prototype.ts'; import { @@ -18,7 +17,10 @@ export interface BuiltinOptions { * read off the host's own; `Math` has none. */ length?: number; - /** Static members such as `Math.max`; read through member access, never written. */ + /** + * Static members such as `Math.max` and `Math.PI`. As in JavaScript, a method can be replaced and + * deleted, as in `Math.max = fn`, and a constant can be neither. + */ members?: Record; /** What a plain call does, as in `Error('x')` or `String(1)`. */ call?: BuiltinCall; @@ -31,8 +33,8 @@ export interface BuiltinOptions { } /** - * A standard global such as `Error`, `Array` or `Math`, whose members the script can only read. It - * is shared between runs, so what a script adds to one is kept by the run. + * A standard global such as `Error`, `Array` or `Math`. It is shared between runs, so what a script + * adds to one, replaces on it or deletes from it is kept by the run. */ export class Builtin extends SandboxObject { public readonly name: string; @@ -60,10 +62,7 @@ export class Builtin extends SandboxObject { this.name = options.name; this.length = options.length; const members = Object.entries(options.members ?? {}).map( - ([key, member]): [string, FixedProperty] => [ - key, - { get: () => member, configurable: false } - ] + ([key, member]): [string, FixedProperty] => [key, memberProperty(member)] ); this.fixed = new Map([ ...(options.length === undefined ? [] : FUNCTION_PROPERTIES), @@ -77,12 +76,9 @@ export class Builtin extends SandboxObject { this.tag = this.isCallable ? 'Function' : options.name; } - /** Its members cannot be deleted, so `Math.PI` stays; what the run added to it can. */ - public override deleteProperty(key: string, context: MemberContext): boolean { - if (this.fixed.has(key)) { - return context.fail('TypeError', `Cannot delete property '${key}' of a read-only object`); - } - return super.deleteProperty(key, context); + /** A namespace such as `Math` is a plain object in JavaScript, so it is named `#`. */ + public override describe(): string { + return this.isCallable ? super.describe() : '#'; } /** A namespace converts as in JavaScript, `[object Math]`. */ @@ -90,3 +86,12 @@ export class Builtin extends SandboxObject { return this.isCallable ? FUNCTION_PLACEHOLDER : super.toPrimitive(hint); } } + +/** + * A static member with the attributes JavaScript gives it: a method, such as `Math.max`, is + * writable and configurable, and a constant, such as `Math.PI` or `Number.EPSILON`, is neither. + */ +function memberProperty(member: unknown): FixedProperty { + const isMethod = member instanceof SandboxObject && member.isCallable; + return { get: () => member, writable: isMethod, configurable: isMethod }; +} diff --git a/packages/maple/src/values/coercion.ts b/packages/maple/src/values/coercion.ts index 6fd2864..656e3b4 100644 --- a/packages/maple/src/values/coercion.ts +++ b/packages/maple/src/values/coercion.ts @@ -138,16 +138,14 @@ export function describeValue(value: unknown): string { /** * An object as a failed `delete` names it in JavaScript: `#`, `[object Array]`, `#`, - * and a function or an error by its string. + * and a function or an error by its string (see {@link SandboxObject.describe}). */ export function describeObject(value: object): string { if (Array.isArray(value)) { return ARRAY_PLACEHOLDER; } if (value instanceof SandboxObject) { - return value.tag === 'Function' || value.tag === 'Error' - ? describeValue(value) - : `#<${value.tag}>`; + return value.describe(); } return '#'; } diff --git a/packages/maple/src/values/members.ts b/packages/maple/src/values/members.ts index 227d6e6..e2ae855 100644 --- a/packages/maple/src/values/members.ts +++ b/packages/maple/src/values/members.ts @@ -159,6 +159,14 @@ export function setMember( throw new Error('A value of an unknown kind was written'); } +/** + * `delete target[key]`, which gives `true`, or a TypeError where strict mode has one: + * + * - Deleting from `null` or `undefined`, or + * - Deleting a property that cannot be deleted, such as one of a frozen object. + * + * Deleting from any other primitive, as in `delete 'abc'.x`, does nothing. + */ export function deleteMember(target: unknown, key: string, context: MemberContext): boolean { const fail = context.fail; @@ -183,7 +191,15 @@ export function deleteMember(target: unknown, key: string, context: MemberContex return true; } -/** The `in` operator over own properties, which is all the sandbox has. */ +/** + * `key in target`. Maple walks no prototype chain, so: + * + * - A plain object or an array answers whether it has `key` as its own property; + * - A value Maple made answers itself, and also counts what its kind has without it being own, so + * `'size' in new Map()` is `true`. + * + * As in JavaScript, the right-hand side must be an object: `'a' in 'abc'` is a TypeError. + */ export function hasMember(target: unknown, key: string, context: MemberContext): boolean { const fail = context.fail; const runState = context.runState; @@ -202,7 +218,24 @@ export function hasMember(target: unknown, key: string, context: MemberContext): return Object.hasOwn(target, key); } -/** The own property names `in` answers `true` for, enumerable or not, for `getOwnPropertyNames`. */ +/** + * `Object.hasOwn(target, key)` for an object. It differs from `in` for a value Maple made, whose + * kind can have a property that is not its own: `'size' in m` is `true`, but + * `Object.hasOwn(m, 'size')` is `false` (see {@link SandboxObject.hasOwnProperty}). + */ +export function hasOwnMember(target: object, key: string, runState: RunState): boolean { + return target instanceof SandboxObject + ? target.hasOwnProperty(key, runState) + : Object.hasOwn(target, key); +} + +/** + * `Object.getOwnPropertyNames(target)`: every own property name, enumerable or not. + * + * - An object lists its own names, so `[7]` gives `['0', 'length']`. + * - A string lists its indices and `length`. + * - Any other primitive has none. + */ export function ownPropertyNames(target: unknown, runState: RunState): Array { if (typeof target === 'string') { return [...Object.keys(target), 'length']; diff --git a/packages/maple/src/values/own-properties.ts b/packages/maple/src/values/own-properties.ts index 6661c75..683f492 100644 --- a/packages/maple/src/values/own-properties.ts +++ b/packages/maple/src/values/own-properties.ts @@ -13,10 +13,25 @@ export interface AddedProperty { configurable: boolean; } -/** What `seal` or `freeze` took from a fixed property of this value. */ +/** + * What happened to a fixed property on this one value, where it departs from what its kind gives + * it: + * + * - `seal` or `freeze` took `writable` or `configurable` away; + * - `delete` removed it, as in `delete fn.name`; + * - The script wrote a property that keeps no value of its own, as in `Math.max = fn`. + */ export interface FixedChange { writable?: false; configurable?: false; + /** + * `delete` removed it. A read then finds what the script added under the key afterwards, or + * otherwise what JavaScript inherits from the prototype, such as `''` for a function's + * `name`. + */ + deleted?: true; + /** What the script wrote, for a writable fixed property without a `set`, such as `Math.max`. */ + replaced?: { readonly value: unknown }; } /** diff --git a/packages/maple/src/values/run-state.ts b/packages/maple/src/values/run-state.ts index 2bea426..7567858 100644 --- a/packages/maple/src/values/run-state.ts +++ b/packages/maple/src/values/run-state.ts @@ -18,6 +18,16 @@ export class RunState { */ readonly #templateObjects = new WeakMap>(); + /** + * What this run changed on `value`, or `undefined` while it changed nothing. For reads, which + * leave no entry behind, so reading `Math.max` costs no allocation. + */ + public peekOwnPropertiesOf(value: SandboxObject): OwnProperties | undefined { + // Most runs change no shared value, which spares every read the lookup + return this.#ownProperties.size === 0 ? undefined : this.#ownProperties.get(value); + } + + /** What this run changed on `value`, with an entry made on the first call, for writes. */ public getOwnPropertiesOf(value: SandboxObject): OwnProperties { let properties = this.#ownProperties.get(value); if (properties === undefined) { diff --git a/packages/maple/src/values/sandbox-error.ts b/packages/maple/src/values/sandbox-error.ts index 77e3189..2f3d6d2 100644 --- a/packages/maple/src/values/sandbox-error.ts +++ b/packages/maple/src/values/sandbox-error.ts @@ -1,30 +1,45 @@ import type { Node } from 'acorn'; -import { Conversion, type Primitive, type PrimitiveHint, toString } from './coercion.ts'; +import { NOOP_CALLBACK } from '../support/noop.ts'; +import { + Conversion, + describeValue, + type Primitive, + type PrimitiveHint, + toString +} from './coercion.ts'; import { type FixedProperties, SandboxObject } from './sandbox-object.ts'; -/** `name` and `message` keep any value, as in JavaScript; converting the error reads them. */ +/** + * `name` and `message` keep any value, as in JavaScript; converting the error reads them. Once + * deleted, they read what the error's prototype has: its kind's name, such as `'TypeError'`, and + * an empty message. + */ const ERROR_PROPERTIES: FixedProperties = new Map([ [ 'name', { get: (error) => error.name, - configurable: true, set: (error, value) => { error.name = value; - } + }, + writable: true, + configurable: true, + inherited: { get: (error) => error.kind, writable: true } } ], [ 'message', { get: (error) => error.message, - configurable: true, set: (error, value) => { error.message = value; - } + }, + writable: true, + configurable: true, + inherited: { get: () => '', writable: true } } ], - ['stack', { get: (error) => error.stack, configurable: true }] + ['stack', { get: (error) => error.stack, writable: false, configurable: true }] ]); /** An error object as the script sees it: name, message and a stack of script positions. */ @@ -71,8 +86,10 @@ export class SandboxError extends SandboxObject { conversion: Conversion = new Conversion() ): Primitive { return conversion.convertError(() => { - const name = this.name === undefined ? 'Error' : toString(this.name, conversion); - const message = this.message === undefined ? '' : toString(this.message, conversion); + const nameValue = this.read('name'); + const messageValue = this.read('message'); + const name = nameValue === undefined ? 'Error' : toString(nameValue, conversion); + const message = messageValue === undefined ? '' : toString(messageValue, conversion); if (name === '') { return message; } @@ -83,14 +100,38 @@ export class SandboxError extends SandboxObject { }); } + /** An error names itself by its string, as in `Cannot delete property 'x' of Error: oops`. */ + public override describe(): string { + return describeValue(this); + } + /** The fixed part of the error as the host receives it; the boundary adds `properties`. */ public toInfo(): { name: string; message: string; stack: string } { return { - name: hostText(this.name, 'Error'), - message: hostText(this.message, ''), - stack: this.stack + name: hostText(this.read('name'), 'Error'), + message: hostText(this.read('message'), ''), + stack: hostText(this.read('stack'), '') }; } + + /** + * `name`, `message` or `stack` as a script reads it: what it was given or assigned, or after + * `delete`, what was assigned again or what the prototype has, which for `stack` is nothing. + */ + private read(key: 'name' | 'message' | 'stack'): unknown { + // The common case, a property the script never deleted, read straight from the field + if (this.properties.getFixedChange(key)?.deleted !== true) { + switch (key) { + case 'name': + return this.name; + case 'message': + return this.message; + case 'stack': + return this.stack; + } + } + return this.readProperty(key, this.properties, NOOP_CALLBACK); + } } /** diff --git a/packages/maple/src/values/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index 1ff8f19..16c07b5 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -1,5 +1,11 @@ -import { type Conversion, describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; -import type { MemberContext, MethodLookup } from './members.ts'; +import { + type Conversion, + describeObject, + describeValue, + type Primitive, + type PrimitiveHint +} from './coercion.ts'; +import type { Fail, MemberContext, MethodLookup } from './members.ts'; import { type Attributes, type FixedChange, @@ -8,116 +14,276 @@ import { } from './own-properties.ts'; import type { RunState } from './run-state.ts'; +/* + * The objects Maple makes itself, apart from arrays and plain objects, which stay the host's own: + * + * - errors, + * - functions, + * - globals such as `Math`, and + * - the wrappers around a native Map, Set, RegExp, Date and so on. + * + * A script never reaches a host prototype, so each of these objects answers for its properties + * itself. Reading `m.label` off a Map looks in three places, in this order: + * + * 1. its fixed properties: what every value of its kind has, such as `size`, `lastIndex`, `name`; + * 2. what the script added: `m.label = 'x'` stores `label` per value, in {@link OwnProperties}; + * 3. its type's methods: `m.get`, which a {@link MethodLookup} finds, never stored per value. + * + * Every own property has the attributes JavaScript gives it (writable, enumerable, configurable), + * and assigning, deleting, `Object.seal` and `Object.freeze` follow the specification's rules for + * ordinary objects, so `Object.freeze(m); m.label = 'y'` throws as it does in Node. + */ + /** - * A property every value of a kind has, such as a Map's `size` or a RegExp's `lastIndex`, with the - * attributes JavaScript gives it. It is never enumerable. + * A property every value of a kind has from the moment it is made, with the attributes JavaScript + * gives it, such as a Map's `size`, a RegExp's `lastIndex` or a function's `name`. Its value is not + * stored: {@link FixedProperty.get} reads it off the value each time, so `m.size` is always the + * Map's current size. A fixed property is never enumerable, so `Object.keys` and `for...in` skip + * it. */ export interface FixedProperty { + /** Reads the current value off `target`, as `(wrapper) => wrapper.map.size` does. */ get(target: T): unknown; - /** Absent for a read-only property. */ + /** + * Writes it to `target`, as `r.lastIndex = 0` does. A writable property without one, such as + * `Math.max`, keeps what the script writes in the value's {@link OwnProperties} instead. + */ set?(target: T, value: unknown): void; - /** Its `configurable` attribute in JavaScript, until `seal` or `freeze` turns it off. */ + /** + * Its `writable` and `configurable` attributes as the kind starts it. `seal` and `freeze` can + * turn them off for a single value, which that value's {@link OwnProperties} records, not this + * shared table. + */ + readonly writable: boolean; readonly configurable: boolean; + /** + * `false` for a property JavaScript keeps as an accessor on the prototype instead of on each + * value, such as a Map's `size` or a RegExp's `source`. Every value of the kind still answers to + * it and `in` finds it, but it is not an own property: + * + * Object.hasOwn(m, 'size') → false + * Object.getOwnPropertyNames(m) → [] + * delete m.size → true, and `m.size` is unchanged + * + * Absent for an own property such as a RegExp's `lastIndex`. + */ + readonly own?: false; + /** + * What a read finds once `delete` removed the property, which in JavaScript comes from the + * prototype: `delete fn.name` leaves `''`, as `Function.prototype.name` is. Absent where the + * prototype has nothing, so `delete Math.max` leaves `undefined`. + */ + readonly inherited?: InheritedProperty; } +/** What JavaScript keeps on the prototype under the name of a fixed property. */ +export interface InheritedProperty { + get(target: T): unknown; + /** + * Whether the script can write it. If not, as for a function's `name`, assigning to the deleted + * property fails; if so, as for an error's `message`, the assignment adds an own property. + */ + readonly writable: boolean; +} + +/** The fixed properties of a kind by name, in the order `Object.getOwnPropertyNames` lists them. */ export type FixedProperties = ReadonlyMap>; /** For the kinds that have no fixed properties, such as Dates and host functions. */ export const NO_FIXED_PROPERTIES: FixedProperties = new Map(); -/** What script functions, built-ins and callable globals show, in the order JavaScript lists them. */ +/** + * The fixed properties of every function, in the order JavaScript lists them: + * + * - `length`, the number of parameters it declares: 2 for `function f(a, b) {}`; + * - `name`: `'f'` for the same function. + * + * Script functions, built-in functions and callable globals such as `String` share them. + */ export const FUNCTION_PROPERTIES: FixedProperties<{ readonly length: number | undefined; readonly name: string; }> = new Map([ - ['length', { get: (target) => target.length, configurable: true }], - ['name', { get: (target) => target.name, configurable: true }] + [ + 'length', + { + get: (target) => target.length, + writable: false, + configurable: true, + inherited: { get: () => 0, writable: false } + } + ], + [ + 'name', + { + get: (target) => target.name, + writable: false, + configurable: true, + inherited: { get: () => '', writable: false } + } + ] ]); /** - * What a shared value answers when asked for its own properties without the run: nothing, and it - * takes nothing, so a missed `getOwnProperties` call fails instead of carrying a property over. + * The {@link SandboxObject.properties} of a shared value (see {@link SandboxObject.isShared}). A + * shared value keeps nothing itself, because each run keeps what it adds in its {@link RunState}. + * This stand-in is empty and takes no new properties, so code that reads the field instead of + * asking {@link SandboxObject.getOwnProperties} fails at once, instead of carrying one run's + * property over into the next. */ const SHARED_STAND_IN = new OwnProperties(); SHARED_STAND_IN.preventExtensions(); -/** How far `Object.seal` and `Object.freeze` lock a value down. */ +/** + * How far `Object.seal` and `Object.freeze` lock a value down: + * + * - `sealed`: it takes no new properties, and none of its own can be deleted; + * - `frozen`: sealed, and none of its own can be written either. + */ export type IntegrityLevel = 'sealed' | 'frozen'; /** - * A value Maple made, other than arrays and plain objects, which stay the host's own: errors, - * functions, globals and wrappers. Each answers for its properties itself, so how a Map behaves - * is read off the Map. Fixed properties come first, then what the script added, then the methods - * of the value's type. Every own property has the attributes of JavaScript, and the rules below - * follow the spec's ordinary objects. + * The base of every value Maple makes itself, apart from arrays and plain objects (see the top of + * this file). A subclass names its {@link SandboxObject.tag} and its {@link SandboxObject.fixed} + * properties; reading, writing and deleting properties, `in` and the `Object` statics all go + * through the methods here. */ export abstract class SandboxObject { /** - * Whether every run shares the value, as `Math` and `[].push` are, so what a script adds to it is - * kept by the run instead of the value. + * Whether every run shares this one value, as they share `Math` and `[].push`: built-ins are + * made once when Maple loads, not for each run. A script may still add to one, as in + * `Math.cache = {}`, but the next run must not see that, so the run keeps what it adds (see + * {@link RunState}), not the value. A value a run makes, such as `new Map()`, is not shared. */ public readonly isShared: boolean; - /** The state of the own properties of a value it owns; for a shared one, the stand-in. */ + /** + * What the script added to a value that is not shared, and how it changed the value's fixed + * properties ({@link FixedChange}). A shared value has the empty {@link SHARED_STAND_IN} here; + * {@link SandboxObject.getOwnProperties} works for both. + */ public readonly properties: OwnProperties; - /** The type as `[object Map]` prints it, the name JavaScript keeps as `Symbol.toStringTag`. */ + /** + * The type's name as `Object.prototype.toString` prints it, the `Map` in `[object Map]`. + * JavaScript keeps it as `Symbol.toStringTag`. + */ public abstract readonly tag: string; - /** Whether the script can call it, which is what `typeof` reports as `function`. */ + /** Whether the script can call it, as in `fn()`; `typeof` then reports it as `'function'`. */ public readonly isCallable: boolean = false; - /** Shared by every value of the kind, apart from globals, whose members differ. */ + /** + * The fixed properties of the value's kind (see {@link FixedProperty}). Every value of a kind, + * such as every Map, shares one table; only the globals have one each, as their members differ. + */ protected abstract readonly fixed: FixedProperties; - /** `isShared` for a value every run uses, whose own properties each run keeps for itself. */ + /** `isShared` is `true` for a value every run uses (see {@link SandboxObject.isShared}). */ protected constructor(isShared: boolean) { this.isShared = isShared; this.properties = isShared ? SHARED_STAND_IN : new OwnProperties(); } - /** The state of its own properties, during `runState`'s run if the value is shared. */ + /** + * What the script added to this value, and how it changed the value's fixed properties + * ({@link FixedChange}). For a shared value that is what `runState`'s run did, so two runs never + * see each other's changes. Writes use it, as it makes the run's entry on first use. + */ public getOwnProperties(runState: RunState): OwnProperties { return this.isShared ? runState.getOwnPropertiesOf(this) : this.properties; } /** - * ToPrimitive without hooks, so a script cannot influence a conversion: `[object Map]`. A value - * whose conversion converts others, as an error does, hands them `conversion`. + * What the script changed on this value, or `undefined` while a run changed nothing on a shared + * value. Reads use it, so reading `Math.max` makes no entry in the run. + */ + public peekOwnProperties(runState: RunState): OwnProperties | undefined { + return this.isShared ? runState.peekOwnPropertiesOf(this) : this.properties; + } + + /** + * Converts the value to a primitive, as `String(m)` and `m + ''` do: `'[object Map]'`. JavaScript + * would first call a `toString` or `valueOf` the script defined; Maple never does, so a script + * cannot run code in the middle of a conversion (see `docs/divergences.md`). A value whose + * conversion converts other values, as an error converts its `name` and `message`, hands them + * `conversion`. */ public toPrimitive(_hint: PrimitiveHint, _conversion?: Conversion): Primitive { return `[object ${this.tag}]`; } - /** The attributes of the own property under `key`, or `undefined` if there is none. */ + /** + * How a failed `delete` names the value in JavaScript, as in + * `Cannot delete property 'x' of #`: a function by its string, anything else by its tag. + */ + public describe(): string { + return this.isCallable ? describeValue(this) : `#<${this.tag}>`; + } + + /** + * The attributes (writable, enumerable, configurable) of the own property under `key`, or + * `undefined` when the value has no such own property. A fixed property that is not own, such as + * a Map's `size`, has none, and neither has one that `delete` removed. + */ public getOwnPropertyAttributes(key: string, runState: RunState): Attributes | undefined { - const properties = this.getOwnProperties(runState); + const properties = this.peekOwnProperties(runState); const fixed = this.fixed.get(key); - return fixed ? fixedAttributes(key, fixed, properties) : properties.getAdded(key); + if (fixed !== undefined && fixed.own !== false) { + const change = properties?.getFixedChange(key); + if (change?.deleted !== true) { + return fixedAttributes(fixed, change); + } + } + + return properties?.getAdded(key); } + /** + * Reads `value[key]`, looking in this order: + * + * 1. A fixed property, unless `delete` removed it; + * 2. One the script added; + * 3. What a removed fixed property inherits, such as `''` for a function's `name`; + * 4. A method of the value's type, which `methodLookup` finds. + */ public getProperty(key: string, runState: RunState, methodLookup: MethodLookup): unknown { - const fixed = this.fixed.get(key); - if (fixed) { - return fixed.get(this); - } - const added = this.getOwnProperties(runState).getAdded(key); - return added ? added.value : methodLookup(this, key); + return this.readProperty(key, this.peekOwnProperties(runState), methodLookup); } + /** + * Assigns `value` to the property under `key`, as `obj[key] = value` does in strict mode. It is a + * TypeError to: + * + * - Write a read-only property, such as `m.size = 1` or one that `freeze` locked; + * - Write a property that `delete` removed where what it inherits is read-only, as `delete fn.name; + * fn.name = 'x'` does; + * - Add a property to a value that takes no new ones. + * + * Anything else overwrites the property or adds it. + */ public setProperty(key: string, value: unknown, { fail, runState }: MemberContext): void { - const properties = this.getOwnProperties(runState); const fixed = this.fixed.get(key); - if (fixed) { - if (!fixed.set || !fixedAttributes(key, fixed, properties).writable) { - const article = /^[AEIOU]/.test(this.tag) ? 'an' : 'a'; - return fail( - 'TypeError', - `Cannot assign to read-only property '${key}' of ${article} ${this.tag}` - ); + if (fixed !== undefined) { + const change = this.peekOwnProperties(runState)?.getFixedChange(key); + if (change?.deleted !== true) { + if (!fixedAttributes(fixed, change).writable) { + return this.failReadOnly(key, fail); + } + if (fixed.set) { + return fixed.set(this, value); + } + return this.getOwnProperties(runState).changeFixed(key, { replaced: { value } }); + } + if ( + fixed.inherited?.writable === false && + this.getOwnProperties(runState).getAdded(key) === undefined + ) { + return this.failReadOnly(key, fail); } - return fixed.set(this, value); } + + const properties = this.getOwnProperties(runState); const added = properties.getAdded(key); if (added && !added.writable) { return fail('TypeError', `Cannot assign to read-only property '${key}'`); @@ -128,69 +294,135 @@ export abstract class SandboxObject { if (added) { added.value = value; } else { - // What an assignment creates + // An assignment creates a property that is writable, enumerable and configurable properties.add(key, value, { writable: true, enumerable: true, configurable: true }); } } /** - * A fixed property stays: deleting it changes nothing, unless `seal` or `freeze` locked it, which - * makes the delete fail as in JavaScript. + * `delete value[key]`, which gives `true`: + * + * - A configurable property is removed, whether the kind gives it, as `fn.name`, or the script + * added it; + * - One that is not own, such as `size`, was never there to delete. + * + * As in JavaScript, deleting a property that is not configurable is a TypeError: a RegExp's + * `lastIndex`, `Math.PI`, or anything `seal` or `freeze` locked. */ public deleteProperty(key: string, { fail, runState }: MemberContext): boolean { - const properties = this.getOwnProperties(runState); const fixed = this.fixed.get(key); - - let locked: boolean; - if (fixed) { - locked = fixed.configurable && !fixedAttributes(key, fixed, properties).configurable; - } else { - locked = properties.getAdded(key)?.configurable === false; + if (fixed !== undefined && fixed.own !== false) { + const change = this.peekOwnProperties(runState)?.getFixedChange(key); + if (change?.deleted !== true) { + if (!fixedAttributes(fixed, change).configurable) { + return this.failDelete(key, fail); + } + this.getOwnProperties(runState).changeFixed(key, { deleted: true, replaced: undefined }); + return true; + } } - if (locked) { - return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); + const properties = this.peekOwnProperties(runState); + const added = properties?.getAdded(key); + if (properties === undefined || added === undefined) { + return true; + } + if (!added.configurable) { + return this.failDelete(key, fail); } - properties.deleteAdded(key); return true; } + /** + * What `key in value` answers for a property: + * + * - A fixed one counts even when it is not own, so `'size' in new Map()` is `true`; + * - So does one the script added; + * - And one that `delete` removed still counts when it inherits a value, as `'name' in fn` does. + */ public hasProperty(key: string, runState: RunState): boolean { - return this.fixed.has(key) || this.getOwnProperties(runState).getAdded(key) !== undefined; + const properties = this.peekOwnProperties(runState); + const fixed = this.fixed.get(key); + if (fixed !== undefined && properties?.getFixedChange(key)?.deleted !== true) { + return true; + } + return properties?.getAdded(key) !== undefined || fixed?.inherited !== undefined; + } + + /** + * What `Object.hasOwn(value, key)` answers: like {@link SandboxObject.hasProperty}, except that a + * fixed property that is not own, or was deleted, does not count, so + * `Object.hasOwn(new Map(), 'size')` is `false`. + */ + public hasOwnProperty(key: string, runState: RunState): boolean { + const properties = this.peekOwnProperties(runState); + const fixed = this.fixed.get(key); + if ( + fixed !== undefined && + fixed.own !== false && + properties?.getFixedChange(key)?.deleted !== true + ) { + return true; + } + return properties?.getAdded(key) !== undefined; } /** - * In the order JavaScript lists them: array indices first, then the fixed properties, which - * exist from the start, then the rest of what the script added. + * The own property names, in the order `Object.getOwnPropertyNames` lists them in JavaScript: + * + * 1. Array indices such as `'0'`; + * 2. The own fixed properties, which exist from the moment the value is made, unless deleted; + * 3. The other names the script added, in the order it added them. + * + * After `fn.extra = 1`, a function lists `['length', 'name', 'extra']`. A fixed property deleted + * and then assigned again counts as added, so it moves to the end, as in JavaScript. */ public ownKeys(runState: RunState): Array { - const added = this.getOwnProperties(runState).addedKeys(); - return [ - ...added.filter(isArrayIndex), - ...this.fixed.keys(), - ...added.filter((key) => !isArrayIndex(key)) - ]; + const properties = this.peekOwnProperties(runState); + const added = properties?.addedKeys() ?? []; + const keys = added.filter(isArrayIndex); + for (const [key, fixed] of this.fixed) { + if (fixed.own !== false && properties?.getFixedChange(key)?.deleted !== true) { + keys.push(key); + } + } + for (const key of added) { + if (!isArrayIndex(key)) { + keys.push(key); + } + } + return keys; } - /** `Object.preventExtensions`: no new property from now on, which cannot be undone. */ + /** + * `Object.preventExtensions(value)`: from now on, adding a property is a TypeError. The existing + * ones can still be written and deleted. It cannot be undone. + */ public preventExtensions(runState: RunState): void { this.getOwnProperties(runState).preventExtensions(); } + /** `Object.isExtensible(value)`: whether the value still takes new properties. */ public isExtensible(runState: RunState): boolean { - return this.getOwnProperties(runState).isExtensible; + return this.peekOwnProperties(runState)?.isExtensible ?? true; } - /** `Object.seal` and `Object.freeze`: not extensible, and every own property locked down. */ + /** + * `Object.seal` and `Object.freeze`: the value takes no new properties and none of its own can be + * deleted; `freeze` also makes each of them read-only. + */ public setIntegrityLevel(level: IntegrityLevel, runState: RunState): void { this.lockDown(level, this.getOwnProperties(runState)); } - /** `Object.isSealed` and `Object.isFrozen`: derived from the properties, as JavaScript does. */ + /** + * `Object.isSealed` and `Object.isFrozen`. As in JavaScript, they look at the properties, not at + * whether `seal` or `freeze` was called: a value that takes no new properties and has none that + * can be deleted (or, for frozen, written) counts as sealed (or frozen). + */ public testIntegrityLevel(level: IntegrityLevel, runState: RunState): boolean { - const properties = this.getOwnProperties(runState); - if (properties.isExtensible) { + if (this.isExtensible(runState)) { return false; } return this.ownKeys(runState).every((key) => { @@ -203,17 +435,70 @@ export abstract class SandboxObject { }); } - /** For a value frozen from the start, such as a wrapper that came in with the input. */ + /** Freezes the value as it is made, for a wrapper that came in with the input. */ protected freezeOwnProperties(): void { this.lockDown('frozen', this.properties); } + /** + * {@link SandboxObject.getProperty} with the value's own properties at hand, for an error, which + * converts itself by reading its `name` and `message` the way a script does. + */ + protected readProperty( + key: string, + properties: OwnProperties | undefined, + methodLookup: MethodLookup + ): unknown { + const fixed = this.fixed.get(key); + if (fixed !== undefined) { + const change = properties?.getFixedChange(key); + // The common case, a fixed property the script left as it is + if (change === undefined) { + return fixed.get(this); + } + if (change.deleted !== true) { + return change.replaced ? change.replaced.value : fixed.get(this); + } + } + const added = properties?.getAdded(key); + if (added !== undefined) { + return added.value; + } + return fixed?.inherited ? fixed.inherited.get(this) : methodLookup(this, key); + } + + private failReadOnly(key: string, fail: Fail): never { + const article = /^[AEIOU]/.test(this.tag) ? 'an' : 'a'; + return fail( + 'TypeError', + `Cannot assign to read-only property '${key}' of ${article} ${this.tag}` + ); + } + + private failDelete(key: string, fail: Fail): never { + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); + } + + /** + * What `seal` and `freeze` do to `properties`: + * + * - No new properties; + * - Every own property becomes non-configurable; + * - For `frozen`, every own property becomes read-only too. + * + * The kind's table of fixed properties is shared by every value of the kind, so what changes for + * a fixed property on this one value is recorded in `properties` + * ({@link OwnProperties.changeFixed}). A fixed property that is not own, or was deleted, is left + * alone. + */ private lockDown(level: IntegrityLevel, properties: OwnProperties): void { properties.preventExtensions(); const change: FixedChange = level === 'frozen' ? { writable: false, configurable: false } : { configurable: false }; - for (const key of this.fixed.keys()) { - properties.changeFixed(key, change); + for (const [key, fixed] of this.fixed) { + if (fixed.own !== false && properties.getFixedChange(key)?.deleted !== true) { + properties.changeFixed(key, change); + } } for (const key of properties.addedKeys()) { const added = properties.getAdded(key); @@ -225,15 +510,13 @@ export abstract class SandboxObject { } } -/** A fixed property's attributes: what its kind gives it, less what `seal` or `freeze` took. */ -function fixedAttributes( - key: string, - fixed: FixedProperty, - properties: OwnProperties -): Attributes { - const change = properties.getFixedChange(key); +/** + * A fixed property's attributes on one value: what its kind starts it with, less what `seal` or + * `freeze` took away on this value (`change`). It is never enumerable. + */ +function fixedAttributes(fixed: FixedProperty, change: FixedChange | undefined): Attributes { return { - writable: fixed.set !== undefined && change?.writable !== false, + writable: fixed.writable && change?.writable !== false, enumerable: false, configurable: fixed.configurable && change?.configurable !== false }; diff --git a/packages/maple/src/values/wrappers/date-wrapper.ts b/packages/maple/src/values/wrappers/date-wrapper.ts index 75f39da..95fd41a 100644 --- a/packages/maple/src/values/wrappers/date-wrapper.ts +++ b/packages/maple/src/values/wrappers/date-wrapper.ts @@ -21,6 +21,11 @@ export class DateWrapper extends SandboxObject { } /** The one value that converts differently for a number than for a string. */ + /** JavaScript names a Date as `[object Date]` when a `delete` fails on it. */ + public override describe(): string { + return `[object Date]`; + } + public override toPrimitive(hint: PrimitiveHint): Primitive { return hint === 'number' ? this.date.getTime() : this.date.toString(); } diff --git a/packages/maple/src/values/wrappers/map-wrapper.ts b/packages/maple/src/values/wrappers/map-wrapper.ts index 1196b4b..27aefd5 100644 --- a/packages/maple/src/values/wrappers/map-wrapper.ts +++ b/packages/maple/src/values/wrappers/map-wrapper.ts @@ -1,7 +1,12 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; +/** + * A Map's one fixed property, `size`, its number of entries. JavaScript keeps it as an accessor on + * `Map.prototype`, not on each Map, so its entry says `own: false`. It has no setter, so + * `m.size = 1` is a TypeError. + */ const MAP_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.map.size, configurable: false }] + ['size', { get: (wrapper) => wrapper.map.size, writable: false, configurable: true, own: false }] ]); export class MapWrapper extends SandboxObject { diff --git a/packages/maple/src/values/wrappers/regexp-wrapper.ts b/packages/maple/src/values/wrappers/regexp-wrapper.ts index a81a8db..9db56d3 100644 --- a/packages/maple/src/values/wrappers/regexp-wrapper.ts +++ b/packages/maple/src/values/wrappers/regexp-wrapper.ts @@ -1,28 +1,52 @@ import { type Primitive, toNumber } from '../coercion.ts'; import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; -/** What a regexp shows; `lastIndex` is the one that can be written. */ +/** + * A regexp's fixed properties: + * + * - `lastIndex`, where the next `exec` or `test` with the `g` or `y` flag starts to search. It is the + * one own property, and the only one a script can write. + * - `source`, the pattern's text. + * - `flags`, all flags as a string such as `'gi'`. + * - One per flag, such as `global` for `g`. + * + * JavaScript keeps all but `lastIndex` as accessors on `RegExp.prototype`, so their entries say + * `own: false`, and they have no setter. + */ const REGEXP_PROPERTIES: FixedProperties = new Map([ [ 'lastIndex', { get: (r) => r.regexp.lastIndex, + writable: true, configurable: false, set: (r, value) => { r.regexp.lastIndex = toNumber(value); } } ], - ['source', { get: (r) => r.regexp.source, configurable: false }], - ['flags', { get: (r) => r.regexp.flags, configurable: false }], - ['global', { get: (r) => r.regexp.global, configurable: false }], - ['ignoreCase', { get: (r) => r.regexp.ignoreCase, configurable: false }], - ['multiline', { get: (r) => r.regexp.multiline, configurable: false }], - ['sticky', { get: (r) => r.regexp.sticky, configurable: false }], - ['unicode', { get: (r) => r.regexp.unicode, configurable: false }], - ['dotAll', { get: (r) => r.regexp.dotAll, configurable: false }], - ['hasIndices', { get: (r) => r.regexp.hasIndices, configurable: false }], - ['unicodeSets', { get: (r) => r.regexp.unicodeSets, configurable: false }] + ['source', { get: (r) => r.regexp.source, writable: false, configurable: true, own: false }], + ['flags', { get: (r) => r.regexp.flags, writable: false, configurable: true, own: false }], + ['global', { get: (r) => r.regexp.global, writable: false, configurable: true, own: false }], + [ + 'ignoreCase', + { get: (r) => r.regexp.ignoreCase, writable: false, configurable: true, own: false } + ], + [ + 'multiline', + { get: (r) => r.regexp.multiline, writable: false, configurable: true, own: false } + ], + ['sticky', { get: (r) => r.regexp.sticky, writable: false, configurable: true, own: false }], + ['unicode', { get: (r) => r.regexp.unicode, writable: false, configurable: true, own: false }], + ['dotAll', { get: (r) => r.regexp.dotAll, writable: false, configurable: true, own: false }], + [ + 'hasIndices', + { get: (r) => r.regexp.hasIndices, writable: false, configurable: true, own: false } + ], + [ + 'unicodeSets', + { get: (r) => r.regexp.unicodeSets, writable: false, configurable: true, own: false } + ] ]); export class RegExpWrapper extends SandboxObject { @@ -44,6 +68,11 @@ export class RegExpWrapper extends SandboxObject { } } + /** JavaScript names a RegExp as `[object RegExp]` when a `delete` fails on it. */ + public override describe(): string { + return `[object RegExp]`; + } + public override toPrimitive(): Primitive { return String(this.regexp); } diff --git a/packages/maple/src/values/wrappers/set-wrapper.ts b/packages/maple/src/values/wrappers/set-wrapper.ts index 072d432..28f8828 100644 --- a/packages/maple/src/values/wrappers/set-wrapper.ts +++ b/packages/maple/src/values/wrappers/set-wrapper.ts @@ -1,7 +1,12 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; +/** + * A Set's one fixed property, `size`, its number of values. JavaScript keeps it as an accessor on + * `Set.prototype`, not on each Set, so its entry says `own: false`. It has no setter, so + * `s.size = 1` is a TypeError. + */ const SET_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.set.size, configurable: false }] + ['size', { get: (wrapper) => wrapper.set.size, writable: false, configurable: true, own: false }] ]); export class SetWrapper extends SandboxObject {