From d6236ff4adfe5208c33120fd91265f4dcf316434 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 20:37:50 +0200 Subject: [PATCH 1/8] feat(maple): support call, apply and bind A call through call, apply or a bound function passes its receiver to the target, so a built-in method still checks it and a script function cannot see it. bind returns a built-in function owned by the run. Calls between built-ins now count toward the depth limit, so a loop through apply cannot run past maxDepth. --- packages/maple-parity/README.md | 5 +- .../maple-parity/src/cases/functions.spec.ts | 72 +++++ packages/maple-parity/src/divergences.spec.ts | 70 +++-- packages/maple/docs/builtins.md | 13 + packages/maple/docs/divergences.md | 10 +- packages/maple/docs/language.md | 21 +- packages/maple/docs/limits.md | 8 +- .../maple/src/builtins/array/statics/from.ts | 4 +- .../builtins/array/utils/array-callback.ts | 2 +- .../builtins/function/function-prototype.ts | 22 +- .../src/builtins/function/function.spec.ts | 248 ++++++++++++++++++ .../src/builtins/function/methods/apply.ts | 28 ++ .../src/builtins/function/methods/bind.ts | 21 ++ .../src/builtins/function/methods/call.ts | 7 + .../iterator/utils/iterator-callback.ts | 2 +- .../maple/src/builtins/json/statics/parse.ts | 2 +- .../src/builtins/json/statics/stringify.ts | 2 +- .../src/builtins/map/methods/for-each.ts | 2 +- .../src/builtins/map/statics/group-by.ts | 2 +- .../src/builtins/object/statics/group-by.ts | 2 +- .../src/builtins/set/methods/for-each.ts | 2 +- .../builtins/string/methods/replace-all.ts | 2 +- .../src/builtins/string/methods/replace.ts | 2 +- packages/maple/src/escapes.spec.ts | 58 +++- packages/maple/src/evaluator/evaluator.ts | 50 ++-- packages/maple/src/evaluator/objects.spec.ts | 2 +- packages/maple/src/values/native.ts | 18 +- 27 files changed, 591 insertions(+), 86 deletions(-) create mode 100644 packages/maple/src/builtins/function/function.spec.ts create mode 100644 packages/maple/src/builtins/function/methods/apply.ts create mode 100644 packages/maple/src/builtins/function/methods/bind.ts create mode 100644 packages/maple/src/builtins/function/methods/call.ts diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 9e3b858..5118b37 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -62,6 +62,8 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `new Error('m', { cause: 1 }).cause`: Maple gives `1`, js-interpreter `undefined` | The `cause` option is ES2022; js-interpreter follows ES5 and ignores the second argument. | | `'abc'.split(undefined, 0)`: Maple gives `[]`, js-interpreter `['abc']` | A limit of 0 gives no parts, as in JavaScript (and ES5); js-interpreter checks the separator first. | | An error with `name` set to `''` or `undefined`: Maple prints `oops` and `Error: x` | As in JavaScript (and ES5); js-interpreter joins `name` and `message` with `: ` regardless. | +| `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. | ### Maple departs from JavaScript @@ -70,6 +72,8 @@ On purpose: these are what the sandbox leaves out. | Divergence | Why | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `this`, `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes or receivers. An object's behaviour comes from its type. | +| `new` on a bound function: `new (Error.bind(null, 'x'))()` | Only the listed built-in constructors can be constructed, and a bound function is not one of them. | +| A built-in method called on a receiver of another type: `''.trim.call({})` | A method's behaviour comes from its receiver's type, so it refuses another type with a `TypeError`. | | `Object.create`, `Object.getPrototypeOf`, `isPrototypeOf` | The same object model: there is no prototype chain to create or inspect. | | Getters and setters | They would run script code on a plain property read or write, host conversion included. | | `new String`, `new Number`, `new Boolean`, `Object(primitive)`, `instanceof String` / `Number` / `Boolean` | There are no primitive wrapper objects. They are a well-known trap and would add a value kind. | @@ -82,5 +86,4 @@ Planned; each entry goes once Maple supports it. | Divergence | Why | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `call`, `apply`, `bind` | Every built-in method checks its receiver now, so they can come. Spread arguments and closures cover the uses until then. | | `defineProperty`, `defineProperties`, `getOwnPropertyDescriptor`, `propertyIsEnumerable` | Data descriptors need per-property attributes on Maple's own values first. Accessors stay out, as getters and setters do. | diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 350d14f..67b02d1 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -363,6 +363,78 @@ describe('Function.prototype.valueOf', () => { }); }); +describe('Function.prototype.call', () => { + it('calls a script function with the arguments after the receiver', () => { + expect(parity('function f(a, b) { return a + b; } return f.call(null, 1, 2);')).toBe(3); + }); + + it('passes the receiver to a built-in method', () => { + expect(parity("return ''.trim.call(' b ');")).toBe('b'); + expect(parity('var a = [1]; [].push.call(a, 2, 3); return a;')).toEqual([1, 2, 3]); + }); + + it('calls a global function', () => { + expect(parity('return [String.call(null, 5), Math.max.call(null, 1, 3, 2)];')).toEqual([ + '5', + 3 + ]); + }); + + it('calls call itself', () => { + expect(parity('function f(a) { return a; } return f.call.call(f, null, 1);')).toBe(1); + }); + + it('needs a function as its receiver', () => { + expect(allThrow('var c = Math.max.call; return c();')).toBe('TypeError'); + }); +}); + +// js-interpreter throws for `f.apply(null, null)`, so the case for no arguments is in Maple's own spec +describe('Function.prototype.apply', () => { + it('spreads an array into the arguments', () => { + expect(parity('function f(a, b) { return a + b; } return f.apply(null, [1, 2]);')).toBe(3); + expect(parity('return Math.max.apply(null, [1, 3, 2]);')).toBe(3); + }); + + it('spreads an array-like and arguments', () => { + expect( + parity( + "function f(a, b) { return a + b; } return f.apply(null, { length: 2, 0: 'a', 1: 'b' });" + ) + ).toBe('ab'); + expect( + parity( + 'function f(a, b) { return a + b; } function g() { return f.apply(null, arguments); } return g(1, 2);' + ) + ).toBe(3); + }); + + it('passes the receiver to a built-in method', () => { + expect(parity("return 'a-b'.split.apply('x-y', ['-']);")).toEqual(['x', 'y']); + }); +}); + +// js-interpreter throws for `f.bind()` without a receiver, so every case passes one +describe('Function.prototype.bind', () => { + it('fixes leading arguments', () => { + expect(parity('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe(3); + expect( + parity('function f(a, b, c) { return [a, b, c]; } return f.bind(null, 1).bind(null, 2)(3);') + ).toEqual([1, 2, 3]); + }); + + it('binds the receiver of a built-in method', () => { + expect(parity("var trim = ''.trim.bind(' b '); return trim();")).toBe('b'); + expect(parity('var a = []; var push = [].push.bind(a); push(1); push(2); return a;')).toEqual([ + 1, 2 + ]); + }); + + it('gives a function', () => { + expect(parity('function f() {} return typeof f.bind(null);')).toBe('function'); + }); +}); + describe('method calls', () => { it('read the same function for every value of a type', () => { expect( diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index db502ae..c0ac206 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -183,6 +183,36 @@ describe('Error:cause', () => { }); }); +describe('Function.prototype.bind', () => { + // A bound function's name and length are ES2015; js-interpreter follows ES5 and gives neither + it('names a bound function after its target', () => { + expect(followsJavaScript('function f(a, b, c) {} return f.bind(null, 1).name;')).toEqual({ + maple: 'bound f', + jsInterpreter: 'fBound' + }); + }); + + it('leaves a bound function the rest of its length', () => { + expect(followsJavaScript('function f(a, b, c) {} return f.bind(null, 1).length;')).toEqual({ + maple: 2, + jsInterpreter: 0 + }); + }); +}); + +describe('Function.prototype.apply', () => { + // A primitive is no array-like of arguments; js-interpreter takes a string's characters + it('refuses a primitive as the arguments', () => { + expect(followsJavaScript('function f() {} return f.apply(null, 1);')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: undefined + }); + expect( + followsJavaScript('function f() { return arguments.length; } return f.apply(null, "ab");') + ).toEqual({ maple: MapleErrorCode.RUNTIME_ERROR, jsInterpreter: 2 }); + }); +}); + describe('String.prototype.split', () => { // A limit of 0 gives no parts, separator or not; js-interpreter checks the separator first it('gives no parts for a limit of 0 without a separator', () => { @@ -221,6 +251,19 @@ describe('syntax:NewExpression', () => { MapleErrorCode.DISALLOWED_SYNTAX ); }); + + it('rejects new on a bound function', () => { + expect(rejected("var B = Error.bind(null, 'x'); return new B().message;")).toBe( + MapleErrorCode.DISALLOWED_SYNTAX + ); + }); +}); + +describe('Function.prototype.call', () => { + // A method's behaviour comes from its type, so it refuses a receiver of another type + it('refuses a receiver of another type for a built-in method', () => { + expect(rejected("return ''.trim.call({ a: 1 });")).toBe(MapleErrorCode.RUNTIME_ERROR); + }); }); describe('instance:function.prototype', () => { @@ -375,33 +418,6 @@ describe('constructor', () => { // Not supported yet: each entry goes once Maple supports it. -// Every built-in method checks its receiver now, so these can come; spread arguments and closures -// cover the uses until then. - -describe('Function.prototype.call', () => { - it('has no call', () => { - expect(rejected('function f(a) { return a; } return f.call(null, 1);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - -describe('Function.prototype.apply', () => { - it('has no apply', () => { - expect(rejected('function f(a, b) { return a + b; } return f.apply(null, [1, 2]);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - -describe('Function.prototype.bind', () => { - it('has no bind', () => { - expect(rejected('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - // Data descriptors need per-property attributes on Maple's own values first. Accessors stay out, as // getters and setters do. diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index f0d6b1d..dd331a0 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -137,6 +137,19 @@ Arrays, strings, Maps, Sets and iterators are the iterables, in `for...of`, spre and wherever a built-in takes an iterable. Plain objects are not iterable. An iterator never leaves the sandbox (see [Boundary](boundary.md)). +## Functions + +Every function, script, built-in or host, answers to `call`, `apply`, `bind`, `toString` and +`valueOf`. + +`apply` takes `null` or `undefined` for no arguments, or an array-like: an array, `arguments` or +any object with a `length`. A primitive is a `TypeError`. + +A bound function is named `bound ` followed by the name of the function it calls, and its `length` +is what is left of that function's after the bound arguments. One bound from a host function has no +properties, as host functions have none. A bound function cannot be constructed with `new` or used +on the right-hand side of `instanceof`, and it adds no frame to `stack`. + ## Errors `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError` and `EvalError`, diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index edce11d..7abebed 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -30,14 +30,14 @@ one that does; the linked section has the detail. ## Functions and constructors - `new` works only for the [listed constructors](language.md#constructors); script functions - cannot be constructed, and `instanceof` accepts only those constructors and `Iterator`. + and bound functions cannot be constructed, so `new (Error.bind(null, 'x'))()` is rejected, and + `instanceof` accepts only those constructors and `Iterator`. - There are no primitive wrapper objects: `new String('a')` is rejected and `Object('a')` is a `TypeError`. -- There is no `call`, `apply` or `bind`. - A function converts to `[function]`, not to its source. -- Host functions have no `length` or `name`. -- A method refuses a receiver of another type, so `({ t: 'x'.trim }).t()` is a `TypeError` - ([Methods](language.md#methods)). +- Host functions, and functions bound from them, have no `length` or `name`. +- A method refuses a receiver of another type, so `({ t: 'x'.trim }).t()`, `''.trim.call({})` and + `[].slice.call(arguments)` are `TypeError`s ([Methods](language.md#methods)). ## Conversion diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 9677918..3b25e18 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -59,19 +59,22 @@ name. `typeof` never throws for a name that is not bound, so `typeof window !== `new` is allowed for `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`, `EvalError`, `Date`, `RegExp`, `Map`, `Set`, `Array` and `Object`. Any other `new` -target, including a script function, is `DISALLOWED_SYNTAX`. There are no primitive wrapper -objects: `String`, `Number` and `Boolean` are conversion functions only, so `new String('a')` is -rejected, and `Object('a')` is a `TypeError`. +target, including a script function or a bound function, is `DISALLOWED_SYNTAX`. There are no +primitive wrapper objects: `String`, `Number` and `Boolean` are conversion functions only, so +`new String('a')` is rejected, and `Object('a')` is a `TypeError`. `instanceof` accepts those thirteen plus `Iterator` on the right-hand side. Any other right-hand -side, a script function or `String` included, throws a `TypeError`. +side, a script function, a bound function or `String` included, throws a `TypeError`. ## Functions Script functions and built-in functions have JavaScript's read-only `length` and `name`, names -inferred from bindings included. Host functions have no properties at all. Every function answers -to `toString`, which gives `[function]`, and `valueOf`. There is no `call`, `apply` or `bind`, since -there is no `this` for them to set. +inferred from bindings included. 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'`. A script +function never sees it, since there is no `this`, which is how JavaScript behaves for a strict +function that does not use `this`. Inside a function that is not an arrow, `arguments` is the strict-mode arguments object. @@ -106,7 +109,9 @@ A method such as `s.trim` is resolved by the receiver's type, never through a ho method is one function shared by every value of the type, so `'x'.trim === 'y'.trim`. The receiver comes from the call as in JavaScript, so `const p = a.push; p(1)` has none and is a `TypeError`. Unlike JavaScript, a receiver of another type is refused too: `({ t: 'x'.trim }).t()` is a -`TypeError`. Own data properties win, so `obj.toString()` calls an own `toString` key. +`TypeError`, and so is `''.trim.call({})`. Array methods take arrays only, so +`[].slice.call(arguments)` is a `TypeError` as well. Own data properties win, so `obj.toString()` +calls an own `toString` key. Plain objects have four methods: `hasOwnProperty`, `toString`, `toLocaleString` and `valueOf`. An object without a prototype, such as `Object.groupBy` returns or a match gives as `groups`, has none diff --git a/packages/maple/docs/limits.md b/packages/maple/docs/limits.md index 5e2d4da..357ff14 100644 --- a/packages/maple/docs/limits.md +++ b/packages/maple/docs/limits.md @@ -6,9 +6,11 @@ included. Exceeding either throws `LIMIT_EXCEEDED`. - **Operations** (`maxOps`). A run is charged for each statement, each evaluated expression, each loop iteration and each callback a built-in or an intrinsic invokes. Copying or growing an array or object element by element, as spread, rest, `for...in`, `Array.from`, `flat` or a larger - `length` do, costs one operation per element. -- **Depth** (`maxDepth`). Each closure call, each callback invoked by a built-in and each nested - `sandbox.run` adds one level. + `length` do, costs one operation per element. So does each argument `apply` takes from its + array-like, before any is read, and each bound argument a bound function passes on a call. +- **Depth** (`maxDepth`). Each closure call and each nested `sandbox.run` adds one level. So does + each call a built-in or an intrinsic makes to a function that is not a closure, as `apply` or a + bound function can, so built-ins calling one another stay under the limit too. A limit error aborts the run outright: it skips the script's `catch` and `finally` blocks, and the script executes nothing after it. diff --git a/packages/maple/src/builtins/array/statics/from.ts b/packages/maple/src/builtins/array/statics/from.ts index 1f8a084..951950f 100644 --- a/packages/maple/src/builtins/array/statics/from.ts +++ b/packages/maple/src/builtins/array/statics/from.ts @@ -13,7 +13,9 @@ export const ARRAY_STATIC_FROM: NativeFunction = new NativeFunction( } const items: Array = []; const add = (item: unknown): void => { - items.push(mapper === undefined ? item : context.call(mapper, [item, items.length])); + items.push( + mapper === undefined ? item : context.call(undefined, mapper, [item, items.length]) + ); }; const iterator = iterate(source); if (iterator) { diff --git a/packages/maple/src/builtins/array/utils/array-callback.ts b/packages/maple/src/builtins/array/utils/array-callback.ts index 05f1bce..ea880c3 100644 --- a/packages/maple/src/builtins/array/utils/array-callback.ts +++ b/packages/maple/src/builtins/array/utils/array-callback.ts @@ -8,5 +8,5 @@ export function arrayCallback(fn: unknown, context: CallContext): ArrayCallback if (!isCallable(fn)) { context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(fn, args); + return (...args) => context.call(undefined, fn, args); } diff --git a/packages/maple/src/builtins/function/function-prototype.ts b/packages/maple/src/builtins/function/function-prototype.ts index c0a18fe..ea929cd 100644 --- a/packages/maple/src/builtins/function/function-prototype.ts +++ b/packages/maple/src/builtins/function/function-prototype.ts @@ -1,16 +1,28 @@ -import { isCallable } from '../../values/native.ts'; +import type { Builtin } from '../../values/builtin.ts'; +import type { Closure } from '../../values/closure.ts'; +import { isCallable, type NativeFunction } from '../../values/native.ts'; import { Prototype } from '../../values/prototype.ts'; +import { FUNCTION_METHOD_APPLY } from './methods/apply.ts'; +import { FUNCTION_METHOD_BIND } from './methods/bind.ts'; +import { FUNCTION_METHOD_CALL } from './methods/call.ts'; import { FUNCTION_METHOD_TO_STRING } from './methods/to-string.ts'; import { FUNCTION_METHOD_VALUE_OF } from './methods/value-of.ts'; +/** What a script can call: a script function, a built-in or host function, or a callable global. */ +export type SandboxFunction = Closure | NativeFunction | Builtin; + /** - * The methods every function answers to, closures, built-ins and host functions alike. `call`, - * `apply` and `bind`, which would let a script pick the receiver, are left out. + * The methods every function answers to, closures, built-ins and host functions alike. The + * receiver `call`, `apply` and `bind` pass on reaches a built-in method, which checks it, and is + * invisible to a script function, which has no `this`. */ -export const FUNCTION_PROTOTYPE: Prototype = new Prototype( +export const FUNCTION_PROTOTYPE: Prototype = new Prototype( 'Function', - (value): value is unknown => isCallable(value), + (value): value is SandboxFunction => isCallable(value), { + apply: FUNCTION_METHOD_APPLY, + bind: FUNCTION_METHOD_BIND, + call: FUNCTION_METHOD_CALL, toString: FUNCTION_METHOD_TO_STRING, valueOf: FUNCTION_METHOD_VALUE_OF } diff --git a/packages/maple/src/builtins/function/function.spec.ts b/packages/maple/src/builtins/function/function.spec.ts new file mode 100644 index 0000000..a8d3828 --- /dev/null +++ b/packages/maple/src/builtins/function/function.spec.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { intrinsic } from '../../boundary/host.ts'; +import { Maple } from '../../maple.ts'; +import { runAndCatch } from '../../spec-helpers.ts'; + +const maple = new Maple({ + globals: { + sum: (...values: Array): number => values.reduce((a, b) => a + b, 0), + count: intrinsic((args) => args.length) + } +}); + +function run(code: string, data?: unknown): unknown { + return maple.run(code, data).value; +} + +function sandboxError(code: string): { name?: string; message?: string } | undefined { + return runAndCatch(maple, code).sandboxError; +} + +describe('Function.prototype.call', () => { + it('calls script functions with the arguments after the receiver', () => { + expect(run('function f(a, b) { return a + b; } return f.call(null, 1, 2);')).toBe(3); + expect(run('return ((a) => a * 2).call(undefined, 4);')).toBe(8); + expect(run('function f() { return arguments.length; } return f.call();')).toBe(0); + expect(run('return (function () { return 1; }).call({ a: 1 });')).toBe(1); + }); + + it('passes the receiver to built-in methods', () => { + expect(run('return "".trim.call(" b ");')).toBe('b'); + expect(run('const a = [1]; [].push.call(a, 2, 3); return a;')).toEqual([1, 2, 3]); + expect(run('return Math.max.call(null, 1, 3, 2);')).toBe(3); + expect(run('return [String.call(null, 5), Error.call(null, "m").message];')).toEqual([ + '5', + 'm' + ]); + }); + + it('still checks the receiver of a built-in method', () => { + expect(sandboxError('"".trim.call({});')).toMatchObject({ + name: 'TypeError', + message: 'String.prototype.trim called on incompatible receiver [object Object]' + }); + expect(sandboxError('[].push.call("x", 1);')?.name).toBe('TypeError'); + expect(sandboxError('Map.call();')).toMatchObject({ + name: 'TypeError', + message: "Constructor Map requires 'new'" + }); + }); + + it('calls host functions and intrinsics', () => { + expect(run('return sum.call(null, 1, 2);')).toBe(3); + expect(run('return count.call(null, 1, 2, 3);')).toBe(3); + }); + + it('needs a function as its receiver', () => { + expect(sandboxError('const c = (() => 1).call; c();')).toMatchObject({ + name: 'TypeError', + message: 'Function.prototype.call called on incompatible receiver undefined' + }); + expect(sandboxError('(() => 1).call.call(1);')?.name).toBe('TypeError'); + expect(run('function f(a) { return a; } return f.call.call(f, null, 1);')).toBe(1); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.call.length, Math.max.call.name];')).toEqual([1, 'call']); + }); +}); + +describe('Function.prototype.apply', () => { + const collect = 'function f() { return [...arguments]; }'; + + it('spreads an array-like into the arguments', () => { + expect(run(`${collect} return f.apply(null, [1, 2]);`)).toEqual([1, 2]); + expect(run(`${collect} return f.apply(null, [1, , 3]);`)).toEqual([1, undefined, 3]); + expect(run(`${collect} return f.apply(null, { length: 2, 0: "a", 1: "b" });`)).toEqual([ + 'a', + 'b' + ]); + expect( + run(`${collect} function g() { return f.apply(null, arguments); } return g(1, 2);`) + ).toEqual([1, 2]); + expect(run(`${collect} return f.apply(null, (a, b) => 1);`)).toEqual([undefined, undefined]); + expect(run(`${collect} return f.apply(null, new Map());`)).toEqual([]); + }); + + it('takes length as JavaScript converts it', () => { + expect(run(`${collect} return f.apply(null, { length: -1 });`)).toEqual([]); + expect(run(`${collect} return f.apply(null, { length: 2.7, 0: 1 });`)).toEqual([1, undefined]); + expect(run(`${collect} return f.apply(null, { length: "1", 0: 1 });`)).toEqual([1]); + }); + + it('calls with no arguments for null and undefined', () => { + expect( + run(`${collect} return [f.apply(), f.apply(null, null), f.apply(null, undefined)];`) + ).toEqual([[], [], []]); + }); + + it('refuses a primitive as the arguments', () => { + expect(sandboxError('(() => 1).apply(null, 1);')).toMatchObject({ + name: 'TypeError', + message: 'CreateListFromArrayLike called on non-object' + }); + expect(sandboxError('(() => 1).apply(null, "ab");')?.name).toBe('TypeError'); + }); + + it('passes the receiver to built-in methods and calls everything else', () => { + expect(run('return Math.max.apply(null, [1, 3, 2]);')).toBe(3); + expect(run('return "a-b".split.apply("x-y", ["-"]);')).toEqual(['x', 'y']); + expect(sandboxError('[].push.apply({}, [1]);')?.name).toBe('TypeError'); + expect(run('return [sum.apply(null, [1, 2]), count.apply(null, [1, 2])];')).toEqual([3, 2]); + }); + + it('charges the arguments before it builds them', () => { + const bounded = new Maple({ maxOps: 1000 }); + + expect(runAndCatch(bounded, '(() => 1).apply(null, { length: 1e9 });').code).toBe( + 'LIMIT_EXCEEDED' + ); + const ops = (code: string): number => bounded.run(code).stats.ops; + expect( + ops('(() => 1).apply(null, { length: 100 });') - ops('(() => 1).apply(null, { length: 0 });') + ).toBe(100); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.apply.length, Math.max.apply.name];')).toEqual([2, 'apply']); + }); +}); + +describe('Function.prototype.bind', () => { + it('fixes leading arguments', () => { + expect(run('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe(3); + expect(run('function f(a, b) { return [a, b]; } return f.bind()(1, 2);')).toEqual([1, 2]); + expect(run('function f(...a) { return a; } return f.bind(null, 1).bind(null, 2)(3);')).toEqual([ + 1, 2, 3 + ]); + expect( + run('const atLeast2 = Math.max.bind(null, 2); return [atLeast2(1), atLeast2(5)];') + ).toEqual([2, 5]); + }); + + it('names a bound function after its target and leaves the rest of its length', () => { + expect( + run('function f(a, b, c) {} const b = f.bind(null, 1); return [b.name, b.length];') + ).toEqual(['bound f', 2]); + expect(run('return [(() => 1).bind().name, (() => 1).bind(null, 1, 2).length];')).toEqual([ + 'bound ', + 0 + ]); + expect(run('function f(a, b) {} return f.bind().bind().name;')).toBe('bound bound f'); + expect(run('return [Math.max.bind().name, Math.max.bind().length];')).toEqual(['bound max', 2]); + expect(run('return [String.bind().name, "".trim.bind().name];')).toEqual([ + 'bound String', + 'bound trim' + ]); + expect(run('return Object.getOwnPropertyNames((() => 1).bind());')).toEqual(['length', 'name']); + }); + + it('shows no properties for a bound host function', () => { + expect(run('const b = sum.bind(null, 1); return [b.name, b.length, Object.keys(b)];')).toEqual([ + undefined, + undefined, + [] + ]); + expect(run('return [sum.bind(null, 1)(2), count.bind(null, 1)(2, 3)];')).toEqual([3, 3]); + }); + + it('passes the bound receiver to a built-in method, which checks it', () => { + expect(run('return "".trim.bind(" b ")();')).toBe('b'); + expect(run('const a = []; const push = [].push.bind(a); push(1); push(2); return a;')).toEqual([ + 1, 2 + ]); + expect(sandboxError('[].push.bind({})(1);')).toMatchObject({ + name: 'TypeError', + message: 'Array.prototype.push called on incompatible receiver [object Object]' + }); + }); + + it('is a function that takes properties and freezes', () => { + expect(run('return typeof (() => 1).bind();')).toBe('function'); + expect(run('return String((() => 1).bind());')).toBe('[function]'); + expect( + run( + 'const b = (() => 1).bind(); b.x = 1; Object.freeze(b); return [b.x, Object.isFrozen(b)];' + ) + ).toEqual([1, true]); + expect(sandboxError('const b = (() => 1).bind(); Object.freeze(b); b.x = 1;')?.name).toBe( + 'TypeError' + ); + expect(sandboxError('(() => 1).bind().name = "x";')?.name).toBe('TypeError'); + }); + + it('cannot be constructed or used with instanceof', () => { + expect(runAndCatch(maple, 'const B = Error.bind(null, "x"); new B();').code).toBe( + 'DISALLOWED_SYNTAX' + ); + expect(sandboxError('({}) instanceof (() => 1).bind();')).toMatchObject({ + name: 'TypeError', + message: "Right-hand side of 'instanceof' is not a constructor" + }); + }); + + it('needs a function as its receiver', () => { + expect(sandboxError('const b = (() => 1).bind; b();')).toMatchObject({ + name: 'TypeError', + message: 'Function.prototype.bind called on incompatible receiver undefined' + }); + }); + + it('keeps the name of the target in the stack', () => { + expect( + run('function f() { return new Error("e").stack; } return f.bind()().split("\\n")[1];') + ).toMatch(/^ {4}at f /); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.bind.length, Math.max.bind.name];')).toEqual([1, 'bind']); + }); +}); + +describe('calls through call, apply and bind', () => { + it('count toward maxDepth like the calls they make', () => { + const shallow = new Maple({ maxDepth: 10 }); + + expect( + shallow.run('function f(n) { return n && f.call(null, n - 1); } return f(9);').stats.maxDepth + ).toBe(10); + expect(runAndCatch(shallow, 'function f(n) { return f.apply(null, [n]); } f(1);').code).toBe( + 'LIMIT_EXCEEDED' + ); + expect(runAndCatch(shallow, 'function f() { return g(); } const g = f.bind(); f();').code).toBe( + 'LIMIT_EXCEEDED' + ); + }); + + it('count a level for each built-in that one built-in calls', () => { + const shallow = new Maple({ maxDepth: 10 }); + + expect(shallow.run('return Math.max.call(null, 1);').stats.maxDepth).toBe(1); + expect( + runAndCatch( + shallow, + 'const ap = (() => 1).apply; const a = [ap, null]; a[1] = a; ap.apply(ap, a);' + ).code + ).toBe('LIMIT_EXCEEDED'); + }); +}); diff --git a/packages/maple/src/builtins/function/methods/apply.ts b/packages/maple/src/builtins/function/methods/apply.ts new file mode 100644 index 0000000..2060f5b --- /dev/null +++ b/packages/maple/src/builtins/function/methods/apply.ts @@ -0,0 +1,28 @@ +import { toLength } from '../../../values/coercion.ts'; +import { method, type Method } from '../../../values/native.ts'; +import { getMember } from '../../method-dispatch.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +export const FUNCTION_METHOD_APPLY: Method = method( + Function.prototype.apply.length, + (fn, [thisArg, argArray], context) => { + const list: Array = []; + + if (argArray === null || argArray === undefined) { + return context.call(thisArg, fn, list); + } + + if (typeof argArray !== 'object') { + return context.fail('TypeError', 'CreateListFromArrayLike called on non-object'); + } + + const length = toLength(getMember(argArray, 'length', context)); + // Charged before the list is built, so a huge `length` is stopped before anything is allocated + context.charge(length); + for (let index = 0; index < length; index++) { + list.push(getMember(argArray, String(index), context)); + } + + return context.call(thisArg, fn, list); + } +); diff --git a/packages/maple/src/builtins/function/methods/bind.ts b/packages/maple/src/builtins/function/methods/bind.ts new file mode 100644 index 0000000..a13b909 --- /dev/null +++ b/packages/maple/src/builtins/function/methods/bind.ts @@ -0,0 +1,21 @@ +import { method, type Method, NativeFunction } from '../../../values/native.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +/** + * A bound function is a built-in of the run that made it. A host function bound keeps showing no + * properties, as host functions do. + */ +export const FUNCTION_METHOD_BIND: Method = method( + Function.prototype.bind.length, + (fn, [thisArg, ...boundArgs]) => + new NativeFunction( + `bound ${fn.name}`, + fn.length === undefined ? undefined : Math.max(0, fn.length - boundArgs.length), + (_, args, context) => { + // Copying the bound arguments into each call costs one operation apiece + context.charge(boundArgs.length); + return context.call(thisArg, fn, [...boundArgs, ...args]); + }, + false + ) +); diff --git a/packages/maple/src/builtins/function/methods/call.ts b/packages/maple/src/builtins/function/methods/call.ts new file mode 100644 index 0000000..0b5ad1b --- /dev/null +++ b/packages/maple/src/builtins/function/methods/call.ts @@ -0,0 +1,7 @@ +import { method, type Method } from '../../../values/native.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +export const FUNCTION_METHOD_CALL: Method = method( + Function.prototype.call.length, + (fn, [thisArg, ...args], context) => context.call(thisArg, fn, args) +); diff --git a/packages/maple/src/builtins/iterator/utils/iterator-callback.ts b/packages/maple/src/builtins/iterator/utils/iterator-callback.ts index 4a855e4..a6e31cb 100644 --- a/packages/maple/src/builtins/iterator/utils/iterator-callback.ts +++ b/packages/maple/src/builtins/iterator/utils/iterator-callback.ts @@ -18,5 +18,5 @@ export function iteratorCallback( closeIterator(wrapper); return context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(fn, args); + return (...args) => context.call(undefined, fn, args); } diff --git a/packages/maple/src/builtins/json/statics/parse.ts b/packages/maple/src/builtins/json/statics/parse.ts index 5c05d18..b164aa5 100644 --- a/packages/maple/src/builtins/json/statics/parse.ts +++ b/packages/maple/src/builtins/json/statics/parse.ts @@ -61,5 +61,5 @@ function revive( } } } - return context.call(reviver, [key, value, holder]); + return context.call(undefined, reviver, [key, value, holder]); } diff --git a/packages/maple/src/builtins/json/statics/stringify.ts b/packages/maple/src/builtins/json/statics/stringify.ts index f85992f..3250f8f 100644 --- a/packages/maple/src/builtins/json/statics/stringify.ts +++ b/packages/maple/src/builtins/json/statics/stringify.ts @@ -75,7 +75,7 @@ function replacerOf( context: CallContext ): ((key: string, current: unknown) => unknown) | undefined { return isCallable(replacer) - ? (key, current) => context.call(replacer, [key, current]) + ? (key, current) => context.call(undefined, replacer, [key, current]) : undefined; } diff --git a/packages/maple/src/builtins/map/methods/for-each.ts b/packages/maple/src/builtins/map/methods/for-each.ts index 1ca2dba..5e17c66 100644 --- a/packages/maple/src/builtins/map/methods/for-each.ts +++ b/packages/maple/src/builtins/map/methods/for-each.ts @@ -10,7 +10,7 @@ export const MAP_METHOD_FOR_EACH: Method = method( } for (const [key, value] of m.map) { - context.call(fn, [value, key, m]); + context.call(undefined, fn, [value, key, m]); } return undefined; diff --git a/packages/maple/src/builtins/map/statics/group-by.ts b/packages/maple/src/builtins/map/statics/group-by.ts index 7e768fd..2f6d4f2 100644 --- a/packages/maple/src/builtins/map/statics/group-by.ts +++ b/packages/maple/src/builtins/map/statics/group-by.ts @@ -16,7 +16,7 @@ export const MAP_STATIC_GROUP_BY: NativeFunction = new NativeFunction( return context.fail('TypeError', `${describeValue(callback)} is not a function`); } return new MapWrapper( - Map.groupBy(iterable, (item, index) => context.call(callback, [item, index])) + Map.groupBy(iterable, (item, index) => context.call(undefined, callback, [item, index])) ); } ); diff --git a/packages/maple/src/builtins/object/statics/group-by.ts b/packages/maple/src/builtins/object/statics/group-by.ts index 9a2907f..7df3054 100644 --- a/packages/maple/src/builtins/object/statics/group-by.ts +++ b/packages/maple/src/builtins/object/statics/group-by.ts @@ -18,7 +18,7 @@ export const OBJECT_STATIC_GROUP_BY: NativeFunction = new NativeFunction( // Converting the key here keeps the host from running its own conversion on a script object return Object.groupBy(iterable, (item, index) => - toPropertyKey(context.call(callback, [item, index])) + toPropertyKey(context.call(undefined, callback, [item, index])) ); } ); diff --git a/packages/maple/src/builtins/set/methods/for-each.ts b/packages/maple/src/builtins/set/methods/for-each.ts index 2634933..8328349 100644 --- a/packages/maple/src/builtins/set/methods/for-each.ts +++ b/packages/maple/src/builtins/set/methods/for-each.ts @@ -10,7 +10,7 @@ export const SET_METHOD_FOR_EACH: Method = method( } for (const value of s.set) { - context.call(fn, [value, value, s]); + context.call(undefined, fn, [value, value, s]); } return undefined; diff --git a/packages/maple/src/builtins/string/methods/replace-all.ts b/packages/maple/src/builtins/string/methods/replace-all.ts index e531da1..b5d72eb 100644 --- a/packages/maple/src/builtins/string/methods/replace-all.ts +++ b/packages/maple/src/builtins/string/methods/replace-all.ts @@ -16,7 +16,7 @@ export const STRING_METHOD_REPLACE_ALL: Method = method( return s.replaceAll(search, toString(replacement)); } return s.replaceAll(search, (...hostArgs: Array): string => - toString(context.call(replacement, hostArgs)) + toString(context.call(undefined, replacement, hostArgs)) ); } ); diff --git a/packages/maple/src/builtins/string/methods/replace.ts b/packages/maple/src/builtins/string/methods/replace.ts index be70c1e..61d0abc 100644 --- a/packages/maple/src/builtins/string/methods/replace.ts +++ b/packages/maple/src/builtins/string/methods/replace.ts @@ -13,7 +13,7 @@ export const STRING_METHOD_REPLACE: Method = method( return s.replace(search, toString(replacement)); } return s.replace(search, (...hostArgs: Array): string => - toString(context.call(replacement, hostArgs)) + toString(context.call(undefined, replacement, hostArgs)) ); } ); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 2fb5ac7..811a5ee 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -52,6 +52,12 @@ describe('escape attempts', () => { expect(errorName('host.constructor;')).toBe('TypeError'); expect(errorName('[].map.constructor;')).toBe('TypeError'); expect(errorName('Object.keys.constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).call.constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).bind().constructor;')).toBe('TypeError'); + expect(errorName('host.bind().constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).call.call((() => {}).bind, null).constructor;')).toBe( + 'TypeError' + ); }); it('through __proto__ and prototype, static or computed', () => { @@ -64,6 +70,9 @@ describe('escape attempts', () => { expect(errorName('({}).__proto__ = { polluted: true };')).toBe('TypeError'); expect(errorName('({})["constructor"] = 1;')).toBe('TypeError'); expect(errorName('delete ({}).__proto__;')).toBe('TypeError'); + expect(errorName('(() => 1).bind().prototype;')).toBe('TypeError'); + expect(errorName('(() => 1).bind().__proto__;')).toBe('TypeError'); + expect(errorName('(() => 1).apply.__proto__;')).toBe('TypeError'); }); it('through the host-only globals', () => { @@ -137,6 +146,8 @@ describe('escape attempts', () => { expect(errorCode('new Array(1e9);')).toBe('LIMIT_EXCEEDED'); expect(errorCode('new host();')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('new (Math.max)();')).toBe('DISALLOWED_SYNTAX'); + expect(errorCode('const B = Error.bind(null, "x"); new B();')).toBe('DISALLOWED_SYNTAX'); + expect(errorCode('const H = host.bind(); new H();')).toBe('DISALLOWED_SYNTAX'); }); }); @@ -315,20 +326,44 @@ describe('escape attempts', () => { h: undefined }); expect(run('return [host, [].values()];')).toEqual([undefined, undefined]); + expect(run('return [(() => 1).bind(), host.bind(), Math.max.call];')).toEqual([ + undefined, + undefined, + undefined + ]); const received: Array = []; const spy = new Maple({ globals: { host: (value: unknown): number => received.push(value) } }); - spy.run('host(() => 1); host({ deep: { fn: () => 1 } }); host(host); host(Math.max);'); + spy.run( + 'host(() => 1); host({ deep: { fn: () => 1 } }); host(host); host(Math.max); host(host.bind());' + ); expect(received).toStrictEqual([ undefined, { deep: { fn: undefined } }, undefined, + undefined, undefined ]); }); + it('nor into a host function called through call, apply or bind', () => { + const received: Array = []; + const spy = new Maple({ + globals: { host: (value: unknown): number => received.push(value) } + }); + spy.run( + 'const o = { fn: () => 1, n: 1 }; host.call(null, o); host.apply(null, [o]); host.bind(null, o)();' + ); + expect(received).toStrictEqual([ + { fn: undefined, n: 1 }, + { fn: undefined, n: 1 }, + { fn: undefined, n: 1 } + ]); + expect(new Set(received).size).toBe(3); + }); + it('cannot be inspected', () => { expect(run('return [(() => 1).length, (function f(a) {}).name];')).toEqual([0, 'f']); expect(run('return host.name;')).toBeUndefined(); @@ -337,7 +372,7 @@ describe('escape attempts', () => { '[function]', '[function]' ]); - expect(run('return Math.max.call;')).toBeUndefined(); + expect(run('return [host.bind().name, host.bind().length];')).toEqual([undefined, undefined]); expect(run('return Object.keys(() => 1);')).toEqual([]); expect(run('return Object.keys(host);')).toEqual([]); expect(run('return Object.keys(Math);')).toEqual([]); @@ -507,6 +542,19 @@ describe('escape attempts', () => { expect(runAndCatch(bounded, 'exec("while (true) {}"); return 1;').code).toBe( 'LIMIT_EXCEEDED' ); + expect( + runAndCatch(bounded, 'function f() { try { f.call(); } catch {} } f.apply();').code + ).toBe('LIMIT_EXCEEDED'); + expect( + runAndCatch( + bounded, + 'const ap = (() => 1).apply; const a = [ap, null]; a[1] = a; ap.apply(ap, a);' + ).code + ).toBe('LIMIT_EXCEEDED'); + expect( + runAndCatch(bounded, 'const c = (() => 1).call; let b = c; while (true) { b = b.bind(); }') + .code + ).toBe('LIMIT_EXCEEDED'); }); it('by growing an array or copying a large value in one operation', () => { @@ -527,6 +575,12 @@ describe('escape attempts', () => { expect(runAndCatch(bounded, `const [...rest] = ${huge};`).code).toBe('LIMIT_EXCEEDED'); expect(runAndCatch(bounded, `const { ...rest } = ${huge};`).code).toBe('LIMIT_EXCEEDED'); expect(runAndCatch(bounded, `for (const k in ${huge}) {}`).code).toBe('LIMIT_EXCEEDED'); + expect(runAndCatch(bounded, '(() => 1).apply(null, { length: 4294967295 });').code).toBe( + 'LIMIT_EXCEEDED' + ); + expect(runAndCatch(bounded, `Math.max.apply(null, [...${huge}]);`).code).toBe( + 'LIMIT_EXCEEDED' + ); }); it('bound recursion through nested runs even when the intrinsic swallows the error', () => { diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 58f055e..5697fa4 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -1064,10 +1064,7 @@ export class Evaluator { private callContext(node: Node): CallContext { return { ...this.memberContext(node), - call: (callee, args) => { - this.tick(node); - return this.invoke(undefined, callee, args, node, 'callback'); - }, + call: (receiver, callee, args) => this.call(receiver, callee, args, node), stackTrace: () => this.stackTrace(node), invokeHost: (fn, args) => this.invokeHost(fn, args, node), invokeIntrinsic: (fn, args) => this.invokeIntrinsic(fn, args, node) @@ -1120,11 +1117,7 @@ export class Evaluator { handle: { toHost: (value) => guard(() => this.toHostArgument(value, node)), fromHost: (value) => guard(() => this.fromHostResult(value, node, 'intrinsic')), - call: (callee, args) => - guard(() => { - this.tick(node); - return this.invoke(undefined, callee, args, node, 'callback'); - }), + call: (callee, args) => guard(() => this.call(undefined, callee, args, node)), run: (code, data, options = {}) => guard(() => this.runNested(code, data, options, node)) }, revoke: () => { @@ -1133,6 +1126,28 @@ export class Evaluator { }; } + /** + * Calls a function for a built-in or an intrinsic. A closure counts its own level; any other + * callee counts one here, so built-ins calling each other, as `apply` can, stay under `maxDepth`. + */ + private call( + receiver: unknown, + callee: unknown, + args: ReadonlyArray, + node: Node + ): unknown { + this.tick(node); + if (callee instanceof Closure) { + return this.callClosure(callee, args, node); + } + this.enterCall(node); + try { + return this.invoke(receiver, callee, args, node, 'callback'); + } finally { + this.#budget.leave(); + } + } + /** Calls any callable sandbox value; `description` names it in the error when it is not one. */ private invoke( receiver: unknown, @@ -1159,13 +1174,7 @@ export class Evaluator { * returns. */ private runNested(code: string, data: unknown, options: RunOptions, node: Node): MapleRunResult { - if (!this.#budget.enter()) { - this.fail( - MapleErrorCode.LIMIT_EXCEEDED, - `Call depth limit of ${this.#budget.maxDepth} exceeded`, - node - ); - } + this.enterCall(node); this.#frames.push({ name: '', calledFrom: node }); const outer = this.#runState; this.#runState = new RunState(); @@ -1285,8 +1294,8 @@ export class Evaluator { return args; } - /** `node` is where the call happened, which is what a depth error points at. */ - private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + /** Enters one call level, or ends the run once the depth limit is reached. */ + private enterCall(node: Node): void { if (!this.#budget.enter()) { this.fail( MapleErrorCode.LIMIT_EXCEEDED, @@ -1294,6 +1303,11 @@ export class Evaluator { node ); } + } + + /** `node` is where the call happened, which is what a depth error points at. */ + private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + this.enterCall(node); const { node: fn } = closure; this.#frames.push({ name: closure.name || '', calledFrom: node }); try { diff --git a/packages/maple/src/evaluator/objects.spec.ts b/packages/maple/src/evaluator/objects.spec.ts index bdcada7..44fa536 100644 --- a/packages/maple/src/evaluator/objects.spec.ts +++ b/packages/maple/src/evaluator/objects.spec.ts @@ -228,7 +228,7 @@ describe('delete and in', () => { expect(run('return "map" in list;', data)).toBe(true); expect(run('return "x" in (() => 1);')).toBe(false); expect(run('return "toString" in (() => 1);')).toBe(true); - expect(run('return "call" in (() => 1);')).toBe(false); + expect(run('return "call" in (() => 1);')).toBe(true); expect(run('return "__proto__" in obj;', data)).toBe(false); expect(run('return "__proto__" in own;', '{"own": {"__proto__": 1}}')).toBe(true); expect(sandboxError('return "a" in 5;')).toMatchObject({ diff --git a/packages/maple/src/values/native.ts b/packages/maple/src/values/native.ts index 946e622..46636d4 100644 --- a/packages/maple/src/values/native.ts +++ b/packages/maple/src/values/native.ts @@ -13,8 +13,11 @@ import { * covers work proportional to the size of a value, such as copied elements. */ export interface CallContext extends MemberContext { - /** Calls a script function under the budget, for callbacks such as a replace function. */ - call(callee: unknown, args: ReadonlyArray): unknown; + /** + * Calls a function under the budget, for callbacks such as a replace function. `receiver` is + * `undefined` for a callback; `call`, `apply` and bound functions pass theirs. + */ + call(receiver: unknown, callee: unknown, args: ReadonlyArray): unknown; /** The script call stack at this point, formatted for `Error.stack`. */ stackTrace(): string; /** Calls a host function: arguments copied out, result copied in, exceptions made catchable. */ @@ -38,7 +41,7 @@ export type NativeImplementation = ( * function from `globals`. A built-in shows its `name` and `length` to the script; a host * function has an `undefined` length and shows no properties at all, since its name and arity * are the host's business. Built-in and host functions are shared between runs, so what a script - * adds to one is kept by the run. + * adds to one is kept by the run; a bound function belongs to the run that made it. */ export class NativeFunction extends SandboxObject { public readonly name: string; @@ -54,8 +57,13 @@ export class NativeFunction extends SandboxObject { protected readonly fixed: FixedProperties; - constructor(name: string, length: number | undefined, invoke: NativeImplementation) { - super(true); + constructor( + name: string, + length: number | undefined, + invoke: NativeImplementation, + isShared: boolean = true + ) { + super(isShared); this.name = name; this.length = length; this.invoke = invoke; From d67a86455f0b9a07d352eca4c36d31aad67caa92 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:09:45 +0200 Subject: [PATCH 2/8] fix(maple): refuse deleting a frozen property of a function or error Deleting a property that freeze locked on a function, an error or another value of Maple's own did nothing and returned true; it is now a TypeError, as in JavaScript. A failed delete names the object as JavaScript does, and shrinking the length of an array that is no longer extensible fails as a script error instead of ending the run. --- .../src/builtins/array/methods/copy-within.ts | 2 +- .../maple/src/builtins/array/methods/fill.ts | 2 +- .../maple/src/builtins/array/methods/pop.ts | 2 +- .../maple/src/builtins/array/methods/push.ts | 2 +- .../src/builtins/array/methods/reverse.ts | 2 +- .../maple/src/builtins/array/methods/shift.ts | 2 +- .../maple/src/builtins/array/methods/sort.ts | 2 +- .../src/builtins/array/methods/splice.ts | 2 +- .../src/builtins/array/methods/unshift.ts | 2 +- .../maple/src/builtins/object/object.spec.ts | 31 ++++++++++++++++++- packages/maple/src/evaluator/objects.spec.ts | 2 +- packages/maple/src/spec-helpers.ts | 4 +++ packages/maple/src/values/coercion.ts | 16 ++++++++++ .../guard-integrity-errors.ts | 2 +- packages/maple/src/values/members.ts | 10 ++++-- packages/maple/src/values/sandbox-object.ts | 24 ++++++++++---- 16 files changed, 86 insertions(+), 21 deletions(-) rename packages/maple/src/{builtins/array/utils => values}/guard-integrity-errors.ts (91%) diff --git a/packages/maple/src/builtins/array/methods/copy-within.ts b/packages/maple/src/builtins/array/methods/copy-within.ts index efe2a38..76f827e 100644 --- a/packages/maple/src/builtins/array/methods/copy-within.ts +++ b/packages/maple/src/builtins/array/methods/copy-within.ts @@ -1,7 +1,7 @@ import { optionalNumberArgument } from '../../../values/arguments.ts'; import { toNumber } from '../../../values/coercion.ts'; +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_COPY_WITHIN: Method> = method( Array.prototype.copyWithin.length, diff --git a/packages/maple/src/builtins/array/methods/fill.ts b/packages/maple/src/builtins/array/methods/fill.ts index 7b29bf5..6763801 100644 --- a/packages/maple/src/builtins/array/methods/fill.ts +++ b/packages/maple/src/builtins/array/methods/fill.ts @@ -1,6 +1,6 @@ import { optionalNumberArgument } from '../../../values/arguments.ts'; +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_FILL: Method> = method( Array.prototype.fill.length, diff --git a/packages/maple/src/builtins/array/methods/pop.ts b/packages/maple/src/builtins/array/methods/pop.ts index 77202b5..2b7c087 100644 --- a/packages/maple/src/builtins/array/methods/pop.ts +++ b/packages/maple/src/builtins/array/methods/pop.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_POP: Method> = method( Array.prototype.pop.length, diff --git a/packages/maple/src/builtins/array/methods/push.ts b/packages/maple/src/builtins/array/methods/push.ts index 40bf3ec..c8d3886 100644 --- a/packages/maple/src/builtins/array/methods/push.ts +++ b/packages/maple/src/builtins/array/methods/push.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { guardHostErrors, method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_PUSH: Method> = method( Array.prototype.push.length, diff --git a/packages/maple/src/builtins/array/methods/reverse.ts b/packages/maple/src/builtins/array/methods/reverse.ts index 5be7029..13272a3 100644 --- a/packages/maple/src/builtins/array/methods/reverse.ts +++ b/packages/maple/src/builtins/array/methods/reverse.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_REVERSE: Method> = method( Array.prototype.reverse.length, diff --git a/packages/maple/src/builtins/array/methods/shift.ts b/packages/maple/src/builtins/array/methods/shift.ts index 6026141..62913a8 100644 --- a/packages/maple/src/builtins/array/methods/shift.ts +++ b/packages/maple/src/builtins/array/methods/shift.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_SHIFT: Method> = method( Array.prototype.shift.length, diff --git a/packages/maple/src/builtins/array/methods/sort.ts b/packages/maple/src/builtins/array/methods/sort.ts index 63f950a..a0605c1 100644 --- a/packages/maple/src/builtins/array/methods/sort.ts +++ b/packages/maple/src/builtins/array/methods/sort.ts @@ -1,7 +1,7 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; import { comparator } from '../utils/comparator.ts'; import { presentEntries } from '../utils/entries.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_SORT: Method> = method( Array.prototype.sort.length, diff --git a/packages/maple/src/builtins/array/methods/splice.ts b/packages/maple/src/builtins/array/methods/splice.ts index df229e0..bb01f4a 100644 --- a/packages/maple/src/builtins/array/methods/splice.ts +++ b/packages/maple/src/builtins/array/methods/splice.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; import { splice } from '../utils/splice.ts'; export const ARRAY_METHOD_SPLICE: Method> = method( diff --git a/packages/maple/src/builtins/array/methods/unshift.ts b/packages/maple/src/builtins/array/methods/unshift.ts index c9ae42a..2bdb6cc 100644 --- a/packages/maple/src/builtins/array/methods/unshift.ts +++ b/packages/maple/src/builtins/array/methods/unshift.ts @@ -1,5 +1,5 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { guardHostErrors, method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_UNSHIFT: Method> = method( Array.prototype.unshift.length, diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 91e10c8..d7a03db 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Maple } from '../../maple.ts'; -import { runAndCatch } from '../../spec-helpers.ts'; +import { errorMessage, runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); @@ -268,6 +268,35 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { a: [1, 2] }); }); + + it('refuses deleting what freeze locked, naming the object as JavaScript does', () => { + expect(errorMessage(maple, 'delete Object.freeze({ a: 1 }).a;')).toBe( + "Cannot delete property 'a' of #" + ); + expect(errorMessage(maple, 'delete Object.freeze([1])[0];')).toBe( + "Cannot delete property '0' of [object Array]" + ); + expect(errorMessage(maple, 'const f = Object.freeze(() => 1); delete f.name;')).toBe( + "Cannot delete property 'name' of [function]" + ); + expect(errorMessage(maple, 'delete Object.freeze(new Error("m")).message;')).toBe( + "Cannot delete property 'message' of Error: m" + ); + 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, 'function f() { Object.freeze(arguments); delete arguments[0]; } f(1);') + ).toBe("Cannot delete property '0' of #"); + }); + + 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); + expect(run('return [delete Object.freeze(new Map()).size, delete /a/.source];')).toEqual([ + true, + true + ]); + }); }); describe('Object.preventExtensions and isExtensible', () => { diff --git a/packages/maple/src/evaluator/objects.spec.ts b/packages/maple/src/evaluator/objects.spec.ts index bdcada7..8b7d902 100644 --- a/packages/maple/src/evaluator/objects.spec.ts +++ b/packages/maple/src/evaluator/objects.spec.ts @@ -212,7 +212,7 @@ describe('delete and in', () => { it('rejects deleting from input, nullish values and forbidden keys', () => { expect(sandboxError('delete obj.a;', { obj: { a: 1 } })).toMatchObject({ name: 'TypeError', - message: "Cannot delete property 'a' of a read-only object" + message: "Cannot delete property 'a' of #" }); expect(sandboxError('delete undefined.a;')?.name).toBe('TypeError'); expect(sandboxError('const o = {}; delete o.__proto__;')?.name).toBe('TypeError'); diff --git a/packages/maple/src/spec-helpers.ts b/packages/maple/src/spec-helpers.ts index 22cd3d5..6feba85 100644 --- a/packages/maple/src/spec-helpers.ts +++ b/packages/maple/src/spec-helpers.ts @@ -21,3 +21,7 @@ export function runAndCatch(maple: Maple, code: string, data?: unknown): MapleEr } throw new Error('expected run to throw'); } + +export function errorMessage(maple: Maple, code: string): string | undefined { + return runAndCatch(maple, code).sandboxError?.message; +} diff --git a/packages/maple/src/values/coercion.ts b/packages/maple/src/values/coercion.ts index e386119..d0ef70d 100644 --- a/packages/maple/src/values/coercion.ts +++ b/packages/maple/src/values/coercion.ts @@ -86,6 +86,22 @@ 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. + */ +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 '#'; +} + export function toNumber(value: unknown): number { return Number(toPrimitive(value, 'number')); } diff --git a/packages/maple/src/builtins/array/utils/guard-integrity-errors.ts b/packages/maple/src/values/guard-integrity-errors.ts similarity index 91% rename from packages/maple/src/builtins/array/utils/guard-integrity-errors.ts rename to packages/maple/src/values/guard-integrity-errors.ts index d1fd000..4ce68a9 100644 --- a/packages/maple/src/builtins/array/utils/guard-integrity-errors.ts +++ b/packages/maple/src/values/guard-integrity-errors.ts @@ -1,4 +1,4 @@ -import type { Fail } from '../../../values/members.ts'; +import type { Fail } from './members.ts'; /** * Runs a host write on an array that is no longer extensible, where the host refuses exactly what diff --git a/packages/maple/src/values/members.ts b/packages/maple/src/values/members.ts index 95e754f..227d6e6 100644 --- a/packages/maple/src/values/members.ts +++ b/packages/maple/src/values/members.ts @@ -1,7 +1,8 @@ import { ARRAY_MAX_LENGTH } from '../builtins/array/array.ts'; import { isPlainObject } from '../support/is-plain-object.ts'; import { isArgumentsObject } from './arguments-object.ts'; -import { toNumber, toString, typeOf } from './coercion.ts'; +import { describeObject, toNumber, toString, typeOf } from './coercion.ts'; +import { guardIntegrityErrors } from './guard-integrity-errors.ts'; import type { OwnProperties } from './own-properties.ts'; import type { RunState } from './run-state.ts'; import { SandboxObject } from './sandbox-object.ts'; @@ -120,7 +121,10 @@ export function setMember( return fail('RangeError', 'Invalid array length'); } charge(Math.max(0, length - target.length)); - target.length = length; + // Shrinking a sealed array deletes elements it may not delete + guardIntegrityErrors(fail, target, () => { + target.length = length; + }); return; } const index = toArrayIndex(key); @@ -174,7 +178,7 @@ export function deleteMember(target: unknown, key: string, context: MemberContex } if (!Reflect.deleteProperty(target, key)) { - return fail('TypeError', `Cannot delete property '${key}' of a read-only object`); + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(target)}`); } return true; } diff --git a/packages/maple/src/values/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index d90d99f..2a3dd22 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -1,4 +1,4 @@ -import type { Primitive, PrimitiveHint } from './coercion.ts'; +import { describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; import type { MemberContext, MethodLookup } from './members.ts'; import { type Attributes, @@ -16,7 +16,7 @@ export interface FixedProperty { get(target: T): unknown; /** Absent for a read-only property. */ set?(target: T, value: unknown): void; - /** Whether `delete` may remove it and `seal` has something to take. */ + /** Its `configurable` attribute in JavaScript, until `seal` or `freeze` turns it off. */ readonly configurable: boolean; } @@ -130,13 +130,25 @@ export abstract class SandboxObject { } } - /** A fixed property stays: deleting it changes nothing. */ + /** + * A fixed property stays: deleting it changes nothing, unless `seal` or `freeze` locked it, which + * makes the delete fail as in JavaScript. + */ public deleteProperty(key: string, { fail, runState }: MemberContext): boolean { const properties = this.getOwnProperties(runState); - const added = this.fixed.has(key) ? undefined : properties.getAdded(key); - if (added && !added.configurable) { - return fail('TypeError', `Cannot delete property '${key}' of a read-only object`); + 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 (locked) { + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); } + properties.deleteAdded(key); return true; } From 05ac326f80a42b4052e755a6adbd17d5e4fa6f19 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:09:45 +0200 Subject: [PATCH 3/8] feat(maple): add Object.seal and Object.isSealed --- packages/maple/docs/builtins.md | 14 +-- packages/maple/docs/divergences.md | 4 +- .../src/builtins/object/object-statics.ts | 4 + .../maple/src/builtins/object/object.spec.ts | 96 +++++++++++++++++++ .../src/builtins/object/statics/is-sealed.ts | 18 ++++ .../maple/src/builtins/object/statics/seal.ts | 20 ++++ 6 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 packages/maple/src/builtins/object/statics/is-sealed.ts create mode 100644 packages/maple/src/builtins/object/statics/seal.ts diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index f0d6b1d..8520b02 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -56,12 +56,14 @@ Boolean instance: `toString`, `valueOf`. Static: `Object.keys`, `Object.values`, `Object.entries`, `Object.fromEntries`, `Object.assign`, `Object.hasOwn`, `Object.groupBy`, `Object.is`, `Object.getOwnPropertyNames`, `Object.freeze`, -`Object.isFrozen`, `Object.preventExtensions`, `Object.isExtensible`. - -- The descriptor and prototype functions (`defineProperty`, `getPrototypeOf`, `create`, …) and - `seal` are not available. -- `freeze`, `preventExtensions` and their checks work on Maple's own values (functions, errors, - wrappers) as well. For a built-in function or a namespace, they last for the current run only. +`Object.isFrozen`, `Object.seal`, `Object.isSealed`, `Object.preventExtensions`, +`Object.isExtensible`. + +- The descriptor and prototype functions (`defineProperty`, `getPrototypeOf`, `create`, …) are not + available. +- `freeze`, `seal`, `preventExtensions` and their checks work on Maple's own values (functions, + errors, wrappers) as well. For a built-in function or a namespace, they last for the current run + only. - `Object.groupBy` converts each key hook-free, so a script object always groups as `[object Object]`. diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index edce11d..209d8e4 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -23,8 +23,8 @@ one that does; the linked section has the detail. - `in` sees own properties and the methods of the value's type, so `'valueOf' in []` is `false` ([Methods](language.md#methods)). - Plain objects have only `hasOwnProperty`, `toString`, `toLocaleString` and `valueOf`. -- `defineProperty`, `getPrototypeOf`, `create`, `seal` and the other descriptor and prototype - functions are not available ([Object](builtins.md#object)). +- `defineProperty`, `getPrototypeOf`, `create` and the other descriptor and prototype functions + are not available ([Object](builtins.md#object)). - Input data is frozen unless the host passes `freeze: false` ([Boundary](boundary.md#into-the-run)). ## Functions and constructors diff --git a/packages/maple/src/builtins/object/object-statics.ts b/packages/maple/src/builtins/object/object-statics.ts index 1f77994..e3a454a 100644 --- a/packages/maple/src/builtins/object/object-statics.ts +++ b/packages/maple/src/builtins/object/object-statics.ts @@ -8,9 +8,11 @@ import { OBJECT_STATIC_GROUP_BY } from './statics/group-by.ts'; import { OBJECT_STATIC_HAS_OWN } from './statics/has-own.ts'; import { OBJECT_STATIC_IS_EXTENSIBLE } from './statics/is-extensible.ts'; import { OBJECT_STATIC_IS_FROZEN } from './statics/is-frozen.ts'; +import { OBJECT_STATIC_IS_SEALED } from './statics/is-sealed.ts'; import { OBJECT_STATIC_IS } from './statics/is.ts'; import { OBJECT_STATIC_KEYS } from './statics/keys.ts'; import { OBJECT_STATIC_PREVENT_EXTENSIONS } from './statics/prevent-extensions.ts'; +import { OBJECT_STATIC_SEAL } from './statics/seal.ts'; import { OBJECT_STATIC_VALUES } from './statics/values.ts'; /** The `Object.*` functions; all of them see own enumerable properties and nothing else. */ @@ -25,6 +27,8 @@ export const OBJECT_STATICS: Readonly> = { getOwnPropertyNames: OBJECT_STATIC_GET_OWN_PROPERTY_NAMES, freeze: OBJECT_STATIC_FREEZE, isFrozen: OBJECT_STATIC_IS_FROZEN, + seal: OBJECT_STATIC_SEAL, + isSealed: OBJECT_STATIC_IS_SEALED, preventExtensions: OBJECT_STATIC_PREVENT_EXTENSIONS, isExtensible: OBJECT_STATIC_IS_EXTENSIBLE, groupBy: OBJECT_STATIC_GROUP_BY diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index d7a03db..7dbb95d 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -299,6 +299,102 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { }); }); +describe('Object.seal and isSealed', () => { + it('keep existing properties writable but take no new one and delete none', () => { + expect(run('const o = Object.seal({ a: 1 }); o.a = 2; return o;')).toEqual({ a: 2 }); + expect(errorMessage(maple, 'Object.seal({ a: 1 }).b = 2;')).toBe( + 'Cannot add property b, object is not extensible' + ); + expect(errorMessage(maple, 'delete Object.seal({ a: 1 }).a;')).toBe( + "Cannot delete property 'a' of #" + ); + expect(run('return Object.assign(Object.seal({ a: 1 }), { a: 2 });')).toEqual({ a: 2 }); + expect( + runAndCatch(maple, 'Object.assign(Object.seal({ a: 1 }), { b: 2 });').sandboxError?.name + ).toBe('TypeError'); + }); + + it('let the array methods reorder and overwrite but neither add nor remove', () => { + const sealed = 'const a = Object.seal([1, 2, 3]);'; + + expect( + run( + 'const a = Object.seal([3, 1, 2]); a[0] = 9; a.sort(); a.reverse(); a.copyWithin(0, 2); return a;' + ) + ).toEqual([1, 2, 1]); + expect(run('const a = Object.seal([1, 2]); a.fill(0); a.length = 4; return a.length;')).toBe(4); + expect(errorMessage(maple, `${sealed} a.push(4);`)).toBe( + 'Cannot add property 3, object is not extensible' + ); + expect(errorMessage(maple, `${sealed} a.pop();`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.shift();`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.splice(0, 1);`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.length = 0;`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} delete a[0];`)).toBe( + "Cannot delete property '0' of [object Array]" + ); + }); + + it('seal Maple own values, which keep working', () => { + expect( + run( + 'const m = Object.seal(new Map()); m.set(1, 2); const r = Object.seal(/a/g); r.lastIndex = 3; const d = Object.seal(new Date(0)); d.setTime(5); return [m.size, r.lastIndex, d.getTime()];' + ) + ).toEqual([1, 3, 5]); + expect( + run( + 'const e = Object.seal(new Error("m")); e.message = "x"; return [e.message, Object.isSealed(e)];' + ) + ).toEqual(['x', true]); + expect(errorMessage(maple, 'delete Object.seal(new Error("m")).message;')).toBe( + "Cannot delete property 'message' of Error: m" + ); + expect(errorMessage(maple, 'Object.seal(() => 1).x = 1;')).toBe( + 'Cannot add property x, object is not extensible' + ); + expect( + run( + 'function f() { Object.seal(arguments); arguments[0] = 5; return arguments[0]; } return f(1);' + ) + ).toBe(5); + }); + + it('report it, true for primitives and for anything frozen', () => { + expect( + run( + 'return [Object.isSealed(1), Object.isSealed(null), Object.isSealed({}), Object.isSealed(Object.preventExtensions({})), Object.isSealed(Object.preventExtensions({ a: 1 })), Object.isSealed(Object.freeze({ a: 1 })), Object.isSealed(obj)];', + { obj: { a: 1 } } + ) + ).toEqual([true, true, false, true, false, true, true]); + expect( + run('return [Object.isFrozen(Object.seal({ a: 1 })), Object.isFrozen(Object.seal({}))];') + ).toEqual([false, true]); + expect( + run('const f = Object.seal(() => 1); return [Object.isSealed(f), Object.isFrozen(f)];') + ).toEqual([true, true]); + expect(run('return [Object.seal(1), Object.seal(null), Object.seal(undefined)];')).toEqual([ + 1, + null, + undefined + ]); + expect(run('const o = {}; return Object.seal(o) === o;')).toBe(true); + expect(run('return [Object.seal.length, Object.isSealed.length];')).toEqual([1, 1]); + }); + + it('keep a built-in sealed only for the run that sealed it', () => { + 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]); + }); +}); + describe('Object.preventExtensions and isExtensible', () => { it('take no new property, with the message JavaScript gives', () => { for (const value of [ diff --git a/packages/maple/src/builtins/object/statics/is-sealed.ts b/packages/maple/src/builtins/object/statics/is-sealed.ts new file mode 100644 index 0000000..c389595 --- /dev/null +++ b/packages/maple/src/builtins/object/statics/is-sealed.ts @@ -0,0 +1,18 @@ +import { isPlainObject } from '../../../support/is-plain-object.ts'; +import { NativeFunction } from '../../../values/native.ts'; +import { SandboxObject } from '../../../values/sandbox-object.ts'; + +/** + * Derived from the properties, as JavaScript does: a primitive counts as sealed, and so does + * anything frozen. + */ +export const OBJECT_STATIC_IS_SEALED: NativeFunction = new NativeFunction( + 'isSealed', + Object.isSealed.length, + (_, [target], { runState }) => { + if (Array.isArray(target) || isPlainObject(target)) { + return Object.isSealed(target); + } + return target instanceof SandboxObject ? target.testIntegrityLevel('sealed', runState) : true; + } +); diff --git a/packages/maple/src/builtins/object/statics/seal.ts b/packages/maple/src/builtins/object/statics/seal.ts new file mode 100644 index 0000000..996de8d --- /dev/null +++ b/packages/maple/src/builtins/object/statics/seal.ts @@ -0,0 +1,20 @@ +import { isPlainObject } from '../../../support/is-plain-object.ts'; +import { NativeFunction } from '../../../values/native.ts'; +import { SandboxObject } from '../../../values/sandbox-object.ts'; + +/** + * Sealing reaches what the script can write to: objects, arrays and Maple's own values. Everything + * else is returned as it is, as JavaScript does for primitives. + */ +export const OBJECT_STATIC_SEAL: NativeFunction = new NativeFunction( + 'seal', + Object.seal.length, + (_, [target], { runState }) => { + if (Array.isArray(target) || isPlainObject(target)) { + Object.seal(target); + } else if (target instanceof SandboxObject) { + target.setIntegrityLevel('sealed', runState); + } + return target; + } +); From e3bec2d8cb000f946583560a625c85ddd6bac53d Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:21:59 +0200 Subject: [PATCH 4/8] fix(maple): accept any function as an entry in Object.fromEntries A closure was refused as an entry while a built-in function was read like any other object. Every function is an object in JavaScript, so each is now read for its 0 and 1 the same way. --- packages/maple/src/builtins/object/object.spec.ts | 8 ++++++++ .../maple/src/builtins/object/statics/from-entries.ts | 3 +-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 91e10c8..ca63226 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -35,6 +35,14 @@ describe('Object statics', () => { expect(run('return Object.fromEntries([[1, "n"]]);')).toEqual({ 1: 'n' }); expect(run('const o = Object.fromEntries([]); o.x = 1; return o;')).toEqual({ x: 1 }); expect(run('try { Object.fromEntries([1]); } catch (e) { return e.name; }')).toBe('TypeError'); + expect( + run( + 'const f = () => 1; f[0] = "k"; f[1] = "v"; return Object.entries(Object.fromEntries([f, Math.max]));' + ) + ).toEqual([ + ['k', 'v'], + ['undefined', undefined] + ]); expect(run('try { Object.fromEntries(5); } catch (e) { return e.message; }')).toBe( '5 is not iterable' ); diff --git a/packages/maple/src/builtins/object/statics/from-entries.ts b/packages/maple/src/builtins/object/statics/from-entries.ts index d17d7ec..108986f 100644 --- a/packages/maple/src/builtins/object/statics/from-entries.ts +++ b/packages/maple/src/builtins/object/statics/from-entries.ts @@ -1,5 +1,4 @@ import { defineOwn } from '../../../support/own-property.ts'; -import { Closure } from '../../../values/closure.ts'; import { describeValue } from '../../../values/coercion.ts'; import { iterate } from '../../../values/iteration.ts'; import { toPropertyKey } from '../../../values/members.ts'; @@ -20,7 +19,7 @@ export const OBJECT_STATIC_FROM_ENTRIES: NativeFunction = new NativeFunction( const result: Record = {}; for (let step = iterator.next(); !step.done; step = iterator.next()) { const entry: unknown = step.value; - if (typeof entry !== 'object' || entry === null || entry instanceof Closure) { + if (typeof entry !== 'object' || entry === null) { return fail('TypeError', `Iterator value ${describeValue(entry)} is not an entry object`); } defineOwn( From 918c22762932a0cf4ccd9c80425fdc5dee9707d1 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:28:57 +0200 Subject: [PATCH 5/8] ci(*): merge each release back into increment --- .github/workflows/push.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a9e24b4..6650588 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -77,3 +77,27 @@ jobs: conventional-changelog-conventionalcommits@9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Brings the release commit and tag back to increment, where pull requests branch off, so their + # changelog preview starts from the latest release + sync: + name: Sync increment + needs: [release] + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: write # push to increment + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: increment + + # Fast-forwards when increment has nothing new; otherwise the merge commit is not a Conventional + # Commit, so it stays out of the next changelog. A conflict fails the job and leaves increment + # as it was. + - run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git merge --no-edit origin/master + git push origin increment From ba3c461e72f5edfea95c0c8a5a2904e3aec3d9cc Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:28:58 +0200 Subject: [PATCH 6/8] docs(*): describe the increment branch flow --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af1eae2..2d28e96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,11 @@ It runs `test` on Node 22, 24 and 26: the minimum from the library's `engines` f Commits must follow [Conventional Commits](https://www.conventionalcommits.org), which commitlint enforces in the `commit-msg` hook. Every commit needs a scope: the package it changes, such as `maple`, `playground` or `parity`, or `*` when it spans the repository. + +Pull requests target `increment`, the default branch, and are merged with a merge commit or rebased, never squashed, so their commits reach `master` as they are. +A release is a merge of `increment` into `master`, with a merge commit as well. +After it, the `sync` job in `.github/workflows/push.yml` merges `master` back into `increment`, so the release commit and its tag are there for the next pull requests. + On every push to `master`, [semantic-release](https://semantic-release.gitbook.io) derives the version and changelog of `@netwin/maple` from them, so never edit its `version` or `CHANGELOG.md` by hand. Tags are plain versions (`1.2.3`). While Maple is on 0.x, breaking changes bump the minor version: From 8b45f9d19c5ef4544e1accf204cef95a556068e2 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 21:52:03 +0200 Subject: [PATCH 7/8] fix(maple): make converting a cyclic error a catchable RangeError An error whose name or message led back to itself was converted until the host ran out of stack, ending the run past the script's catch and finally. Converting errors inside one another now stops at the nesting limit with JavaScript's RangeError, and join cuts a cycle through an array across nested conversions, as JavaScript does. --- packages/maple-parity/README.md | 2 + packages/maple-parity/src/divergences.spec.ts | 16 +++ packages/maple/docs/limits.md | 10 +- .../maple/src/builtins/array/array.spec.ts | 7 ++ .../maple/src/builtins/object/object.spec.ts | 28 ++++++ .../src/builtins/string/methods/concat.ts | 2 +- packages/maple/src/errors.spec.ts | 69 +++++++++++++ packages/maple/src/values/arguments.ts | 2 +- packages/maple/src/values/coercion.spec.ts | 26 +++++ packages/maple/src/values/coercion.ts | 98 +++++++++++++------ packages/maple/src/values/sandbox-error.ts | 30 +++--- packages/maple/src/values/sandbox-object.ts | 9 +- 12 files changed, 251 insertions(+), 48 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 5118b37..a69af50 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -62,6 +62,8 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `new Error('m', { cause: 1 }).cause`: Maple gives `1`, js-interpreter `undefined` | The `cause` option is ES2022; js-interpreter follows ES5 and ignores the second argument. | | `'abc'.split(undefined, 0)`: Maple gives `[]`, js-interpreter `['abc']` | A limit of 0 gives no parts, as in JavaScript (and ES5); js-interpreter checks the separator first. | | An error with `name` set to `''` or `undefined`: Maple prints `oops` and `Error: x` | As in JavaScript (and ES5); js-interpreter joins `name` and `message` with `: ` regardless. | +| An error whose `name` is itself: Maple throws a `RangeError` | Nothing cuts that cycle in JavaScript, so it runs out of stack; js-interpreter stops quietly. | +| An error named by an array that holds it: Maple gives `m: m` | `join` cuts the cycle at the array, as in JavaScript; js-interpreter gives `...: m`. | | `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. | diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index c0ac206..8b06e56 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -172,6 +172,22 @@ describe('Error.prototype.toString', () => { ) ).toEqual({ maple: ['oops', 'Error: x'], jsInterpreter: [': oops', 'undefined: x'] }); }); + + it('throws a RangeError for an error that names itself', () => { + expect( + followsJavaScript( + "var e = new Error('m'); e.name = e; try { String(e); } catch (t) { return t.name; }" + ) + ).toEqual({ maple: 'RangeError', jsInterpreter: undefined }); + }); + + // join cuts a cycle through an array, nested conversions included; js-interpreter cuts it as '...' + it('converts an error named by an array that holds it', () => { + expect(followsJavaScript("var e = new Error('m'); e.name = [e]; return String(e);")).toEqual({ + maple: 'm: m', + jsInterpreter: '...: m' + }); + }); }); describe('Error:cause', () => { diff --git a/packages/maple/docs/limits.md b/packages/maple/docs/limits.md index 357ff14..1cff022 100644 --- a/packages/maple/docs/limits.md +++ b/packages/maple/docs/limits.md @@ -24,8 +24,14 @@ responsibility (see [Security](security.md#out-of-scope)). Wherever Maple walks a value recursively, it refuses one nested deeper than 1000 levels instead of overflowing the host stack: when copying values across the [boundary](boundary.md), in -`JSON.stringify` and in `JSON.parse` with a reviver, and when converting a nested array to a string -or flattening it. Inside the script this is a catchable `RangeError`. +`JSON.stringify` and in `JSON.parse` with a reviver, when converting a nested array to a string or +flattening it, and when converting an error whose `name` or `message` holds another error. Inside +the script this is a catchable `RangeError`. + +Converting an array cuts a cycle back to it with an empty string, as JavaScript does, even where +the cycle runs through an error's `name` or `message`. Nothing cuts a cycle from one error to +another, such as an error whose `name` is the error itself, so converting it fails with +JavaScript's `RangeError: Maximum call stack size exceeded`. The host itself running out of stack is not a script error: the run ends with `INTERNAL_ERROR`, and no `catch` sees it. diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index 30661b0..26a8391 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -269,6 +269,13 @@ describe('array methods', () => { expect(run('return [1, 2].join();')).toBe('1,2'); expect(run('return [{ toString: "x" }, () => 1].join();')).toBe('[object Object],[function]'); expect(run('return [1, [2]].toString();')).toBe('1,2'); + expect(run('return [1, , 3].join();')).toBe('1,,3'); + }); + + it('join an array that has its own map, which the conversion does not use', () => { + expect( + run('const a = [1, 2]; a.map = 1; return [a.join("-"), String(a), [a].toString(), `${a}`];') + ).toEqual(['1-2', '1,2', '1,2', '1,2']); }); it('mutate script arrays', () => { diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 2a6ffec..193b44b 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -298,6 +298,26 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { ).toBe("Cannot delete property '0' of #"); }); + it('refuses deleting from a frozen error that does not convert, as a script error', () => { + expect( + run(` + const e = new Error("m"); + e.name = e; + e.x = 1; + Object.freeze(e); + const ran = []; + try { + delete e.x; + } catch (t) { + ran.push(t.message); + } finally { + ran.push("finally"); + } + return ran; + `) + ).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); expect(run('return [delete Object.freeze(new Map()).size, delete /a/.source];')).toEqual([ @@ -351,6 +371,14 @@ describe('Object.seal and isSealed', () => { ); }); + it('refuse deleting from a sealed error that does not convert, as a script error', () => { + expect( + run( + 'const e = new Error("m"); e.name = e; e.x = 1; Object.seal(e); const ran = []; try { delete e.x; } catch (t) { ran.push(t.message); } finally { ran.push("finally"); } return ran;' + ) + ).toEqual(["Cannot delete property 'x' of [object Error]", 'finally']); + }); + it('seal Maple own values, which keep working', () => { expect( run( diff --git a/packages/maple/src/builtins/string/methods/concat.ts b/packages/maple/src/builtins/string/methods/concat.ts index 368b5c0..470f64b 100644 --- a/packages/maple/src/builtins/string/methods/concat.ts +++ b/packages/maple/src/builtins/string/methods/concat.ts @@ -3,5 +3,5 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const STRING_METHOD_CONCAT: Method = method( String.prototype.concat.length, - (s, args, { fail }) => guardHostErrors(fail, () => s.concat(...args.map(toString))) + (s, args, { fail }) => guardHostErrors(fail, () => s.concat(...args.map((arg) => toString(arg)))) ); diff --git a/packages/maple/src/errors.spec.ts b/packages/maple/src/errors.spec.ts index c8986b0..74c40a1 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -8,6 +8,11 @@ function run(code: string, data?: unknown): unknown { return maple.run(code, data).value; } +/** Script that leaves `e` as the last of `length` errors, each named by the one before. */ +function errorChain(length: number): string { + return `let e = new Error("m"); for (let i = 1; i < ${length}; i++) { const n = new Error("m"); n.name = e; e = n; }`; +} + describe('errors', () => { describe('throw', () => { it('surfaces an uncaught error object as RUNTIME_ERROR', () => { @@ -282,6 +287,70 @@ describe('errors', () => { }); }); +describe('an error whose name or message leads back to it', () => { + it('fails to convert with the RangeError JavaScript gives, which the script can catch', () => { + const caught = (setup: string, conversion: string): unknown => + run( + `const e = new Error("m"); ${setup} try { ${conversion}; } catch (t) { return [t.name, t.message]; }` + ); + const rangeError = ['RangeError', 'Maximum call stack size exceeded']; + + expect(caught('e.name = e;', 'String(e)')).toEqual(rangeError); + expect(caught('e.message = e;', '"" + e')).toEqual(rangeError); + expect(caught('e.name = e;', '`${e}`')).toEqual(rangeError); + expect(caught('e.name = e;', 'e.toString()')).toEqual(rangeError); + expect(caught('e.name = e;', 'new Error(e)')).toEqual(rangeError); + expect(caught('const f = new Error("f"); e.name = f; f.name = e;', 'String(e)')).toEqual( + rangeError + ); + expect( + run( + 'const e = new Error("m"); e.name = e; try { String(e); } catch {} e.name = "X"; return String(e);' + ) + ).toBe('X: m'); + }); + + it('converts through an array, which cuts the cycle as JavaScript does', () => { + expect(run('const e = new Error("m"); e.name = [e]; return String(e);')).toBe('m: m'); + expect(run('const e = new Error("m"); e.message = [e, 1]; return String(e);')).toBe( + 'Error: Error,1' + ); + expect( + run( + 'const l = [1]; const e = new Error("m"); e.name = l; l.push(e); return [String(l), String(e)];' + ) + ).toEqual(['1,m', '1,m: m']); + }); + + it('converts a chain of errors up to the nesting limit', () => { + expect(run(`${errorChain(1000)} return String(e);`)).toBe(`Error: m${': m'.repeat(999)}`); + expect(run(`${errorChain(1001)} try { String(e); } catch (t) { return t.name; }`)).toBe( + 'RangeError' + ); + }); + + it('is named by its tag in the messages of other errors', () => { + expect( + run( + 'const e = new Error("m"); e.name = e; try { Array.from([1], e); } catch (t) { return t.message; }' + ) + ).toBe('[object Error] is not a function'); + }); + + it('still reaches the host, thrown or returned', () => { + expect( + runAndCatch(maple, 'const e = new Error("m"); e.name = e; throw e;').sandboxError + ).toMatchObject({ + name: 'Error', + message: 'm' + }); + expect(run('const e = new Error("m"); e.name = e; return e;')).toMatchObject({ + name: 'Error', + message: 'm' + }); + }); +}); + describe('error additions', () => { it('give errors a toString method', () => { expect(run('return new TypeError("bad").toString();')).toBe('TypeError: bad'); diff --git a/packages/maple/src/values/arguments.ts b/packages/maple/src/values/arguments.ts index 7c8c195..f12dd1d 100644 --- a/packages/maple/src/values/arguments.ts +++ b/packages/maple/src/values/arguments.ts @@ -33,7 +33,7 @@ export function localesArgument(value: unknown): string | Array | undefi if (value === undefined) { return undefined; } - return Array.isArray(value) ? value.map(toString) : toString(value); + return Array.isArray(value) ? value.map((item) => toString(item)) : toString(value); } type IntlOptions = Intl.CollatorOptions & Intl.DateTimeFormatOptions & Intl.NumberFormatOptions; diff --git a/packages/maple/src/values/coercion.spec.ts b/packages/maple/src/values/coercion.spec.ts index ea56795..9957931 100644 --- a/packages/maple/src/values/coercion.spec.ts +++ b/packages/maple/src/values/coercion.spec.ts @@ -61,6 +61,28 @@ describe('coercion', () => { expect(() => toString([nested])).toThrow(UnplacedError); }); + + it('cuts a cycle through an array even where it runs through an error, as the host does', () => { + const error = new SandboxError('Error', 'm'); + error.name = [error]; + expect(toString(error)).toBe('m: m'); + + const list: Array = [1]; + const named = new SandboxError('Error', 'm'); + named.name = list; + list.push(named); + expect([toString(list), toString(named)]).toEqual(['1,m', '1,m: m']); + }); + + it('refuses a cycle between errors with a RangeError for the evaluator to place', () => { + const error = new SandboxError('Error', 'm'); + error.name = error; + + expect(() => toString(error)).toThrow( + expect.objectContaining({ name: 'RangeError', message: 'Maximum call stack size exceeded' }) + ); + expect(() => toString(error)).toThrow(UnplacedError); + }); }); describe('describeValue', () => { @@ -81,6 +103,10 @@ describe('coercion', () => { expect(describeValue(Object.create(null))).toBe('[object Object]'); expect(describeValue(nested)).toBe('[object Array]'); expect(describeValue(error)).toBe('[object Error]'); + + const cyclic = new SandboxError('Error', 'm'); + cyclic.message = cyclic; + expect(describeValue(cyclic)).toBe('[object Error]'); }); }); diff --git a/packages/maple/src/values/coercion.ts b/packages/maple/src/values/coercion.ts index d0ef70d..6fd2864 100644 --- a/packages/maple/src/values/coercion.ts +++ b/packages/maple/src/values/coercion.ts @@ -21,11 +21,61 @@ export const ARGUMENTS_PLACEHOLDER = '[object Arguments]'; /** The ToPrimitive hint; only a Date converts differently for `number` than for the others. */ export type PrimitiveHint = 'default' | 'number' | 'string'; +/** + * What one conversion is in the middle of, handed down to the conversions nested in it: the + * arrays it is joining, so a cycle through one is cut as JavaScript cuts it, and how deep it + * converts errors inside one another. A conversion started from outside makes a new one. + */ +export class Conversion { + readonly #joining = new Set>(); + + #errorDepth = 0; + + /** Joins `array` with `run`, or gives `''` for an array this conversion is already joining. */ + public join(array: ReadonlyArray, run: () => string): string { + if (this.#joining.has(array)) { + return ''; + } + if (this.#joining.size >= MAX_NESTING) { + throw new UnplacedError( + 'RangeError', + `An array nested deeper than ${MAX_NESTING} levels cannot be converted to a string` + ); + } + this.#joining.add(array); + try { + return run(); + } finally { + this.#joining.delete(array); + } + } + + /** + * Converts an error with `run`. Nothing cuts a cycle through an error's `name` or `message`, so + * one runs into the limit and fails as JavaScript fails when it runs out of stack. + */ + public convertError(run: () => T): T { + if (this.#errorDepth >= MAX_NESTING) { + throw new UnplacedError('RangeError', 'Maximum call stack size exceeded'); + } + this.#errorDepth++; + try { + return run(); + } finally { + this.#errorDepth--; + } + } +} + /** * ToPrimitive without `toString` / `valueOf` hooks: a plain object is always `[object Object]`, * an array always its joined elements, so a script cannot influence a conversion. */ -export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Primitive { +export function toPrimitive( + value: unknown, + hint: PrimitiveHint = 'default', + conversion?: Conversion +): Primitive { if ( value === undefined || value === null || @@ -42,10 +92,10 @@ export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Pr throw new Error(`A ${typeof value} reached the sandbox`); } if (value instanceof SandboxObject) { - return value.toPrimitive(hint); + return value.toPrimitive(hint, conversion); } if (Array.isArray(value)) { - return joinArray(value, new Set()); + return joinArray(value, conversion ?? new Conversion()); } if (isArgumentsObject(value)) { return ARGUMENTS_PLACEHOLDER; @@ -56,8 +106,8 @@ export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Pr return OBJECT_PLACEHOLDER; } -export function toString(value: unknown): string { - const primitive = toPrimitive(value, 'string'); +export function toString(value: unknown, conversion?: Conversion): string { + const primitive = toPrimitive(value, 'string', conversion); return typeof primitive === 'string' ? primitive : String(primitive); } @@ -182,31 +232,21 @@ function kindOf(value: unknown): string { /** `Array.prototype.join` with the hook-free element conversion; nested arrays join with commas. */ export function joinValues(array: ReadonlyArray, separator: string): string { - return joinArray(array, new Set(), separator); + return joinArray(array, new Conversion(), separator); } -/** Nullish elements are empty and a cycle is cut with an empty string, as the host does it. */ -function joinArray(array: ReadonlyArray, seen: Set, separator = ','): string { - if (seen.has(array)) { - return ''; - } - if (seen.size >= MAX_NESTING) { - throw new UnplacedError( - 'RangeError', - `An array nested deeper than ${MAX_NESTING} levels cannot be converted to a string` - ); - } - seen.add(array); - const parts: Array = []; - for (const element of array) { - if (element === null || element === undefined) { - parts.push(''); - } else if (Array.isArray(element)) { - parts.push(joinArray(element, seen)); - } else { - parts.push(toString(element)); +/** + * Nullish elements are empty, and an array the conversion is already joining is empty too, as the + * host does it, even when the cycle runs through an error's `name` or `message`. + */ +function joinArray(array: ReadonlyArray, conversion: Conversion, separator = ','): string { + return conversion.join(array, () => { + // By index, since a script can give its array an own `map` that would shadow the host's + const parts: Array = []; + for (let index = 0; index < array.length; index++) { + const element = array[index]; + parts.push(element === null || element === undefined ? '' : toString(element, conversion)); } - } - seen.delete(array); - return parts.join(separator); + return parts.join(separator); + }); } diff --git a/packages/maple/src/values/sandbox-error.ts b/packages/maple/src/values/sandbox-error.ts index 9798326..77e3189 100644 --- a/packages/maple/src/values/sandbox-error.ts +++ b/packages/maple/src/values/sandbox-error.ts @@ -1,5 +1,5 @@ import type { Node } from 'acorn'; -import { type Primitive, toString } from './coercion.ts'; +import { Conversion, 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. */ @@ -63,18 +63,24 @@ export class SandboxError extends SandboxObject { /** * What JavaScript's `Error.prototype.toString` gives: an `undefined` name reads as `Error` and an * `undefined` message as empty, and an empty part leaves out the `: `. Converting an assigned - * value can throw, as it can in JavaScript. + * value can throw, as it can in JavaScript, and so can a `name` or `message` that leads back to + * the error. */ - public override toPrimitive(): Primitive { - const name = this.name === undefined ? 'Error' : toString(this.name); - const message = this.message === undefined ? '' : toString(this.message); - if (name === '') { - return message; - } - if (message === '') { - return name; - } - return `${name}: ${message}`; + public override toPrimitive( + _hint: PrimitiveHint, + 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); + if (name === '') { + return message; + } + if (message === '') { + return name; + } + return `${name}: ${message}`; + }); } /** The fixed part of the error as the host receives it; the boundary adds `properties`. */ diff --git a/packages/maple/src/values/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index 2a3dd22..1ff8f19 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -1,4 +1,4 @@ -import { describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; +import { type Conversion, describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; import type { MemberContext, MethodLookup } from './members.ts'; import { type Attributes, @@ -81,8 +81,11 @@ export abstract class SandboxObject { return this.isShared ? runState.getOwnPropertiesOf(this) : this.properties; } - /** ToPrimitive without hooks, so a script cannot influence a conversion: `[object Map]`. */ - public toPrimitive(_hint: PrimitiveHint): Primitive { + /** + * 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`. + */ + public toPrimitive(_hint: PrimitiveHint, _conversion?: Conversion): Primitive { return `[object ${this.tag}]`; } From 958bb99b3260e3b7c16ec46ee4f40b3bb659ae63 Mon Sep 17 00:00:00 2001 From: NetWin Date: Mon, 19 Oct 2026 22:12:46 +0200 Subject: [PATCH 8/8] fix(maple): call the host's array methods past a script's own property Array methods ran the host's implementation as a.slice(), which an own property of the same name on the script's array shadowed, ending the run with an internal error. They now call it through Array.prototype. --- CONTRIBUTING.md | 1 + .../src/builtins/array/array-prototype.ts | 4 +- .../maple/src/builtins/array/array.spec.ts | 57 +++++++++++++++++++ .../maple/src/builtins/array/methods/at.ts | 2 +- .../src/builtins/array/methods/concat.ts | 2 +- .../src/builtins/array/methods/copy-within.ts | 4 +- .../src/builtins/array/methods/entries.ts | 2 +- .../maple/src/builtins/array/methods/fill.ts | 4 +- .../src/builtins/array/methods/includes.ts | 2 +- .../src/builtins/array/methods/index-of.ts | 2 +- .../maple/src/builtins/array/methods/keys.ts | 2 +- .../builtins/array/methods/last-index-of.ts | 4 +- .../maple/src/builtins/array/methods/pop.ts | 2 +- .../maple/src/builtins/array/methods/push.ts | 2 +- .../src/builtins/array/methods/reverse.ts | 3 +- .../maple/src/builtins/array/methods/shift.ts | 2 +- .../maple/src/builtins/array/methods/slice.ts | 3 +- .../src/builtins/array/methods/splice.ts | 7 ++- .../array/methods/to-locale-string.ts | 2 +- .../src/builtins/array/methods/to-reversed.ts | 2 +- .../src/builtins/array/methods/to-sorted.ts | 2 +- .../src/builtins/array/methods/to-spliced.ts | 12 +++- .../src/builtins/array/methods/unshift.ts | 2 +- .../src/builtins/array/methods/values.ts | 2 +- .../maple/src/builtins/array/methods/with.ts | 3 +- 25 files changed, 106 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d28e96..0a9a2cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,7 @@ The declared exceptions are the guarded `performance.now()` behind `durationMs`, If the method takes a callback, invoke it through `context.call` so it runs under the budget. Never hand a script closure to a host implementation. 3. If the method needs no callback and cannot observe prototypes, it may delegate to the host implementation on a Maple-owned value (for example `Array.prototype.with`). + Call it through the prototype, as `Array.prototype.with.call(a, …)`, never as `a.with(…)`: a script can give its array an own property of that name. Wrap it in `guardHostErrors` when the spec requires a `RangeError`, `URIError` or `SyntaxError` for it, or when it can build a string or array longer than the host allows. 4. Return only sandbox values: primitives, Maple-created objects and arrays, or wrappers. Never return a native `Date`, `RegExp`, `Map`, `Set` or function. diff --git a/packages/maple/src/builtins/array/array-prototype.ts b/packages/maple/src/builtins/array/array-prototype.ts index 1d44c14..88ca227 100644 --- a/packages/maple/src/builtins/array/array-prototype.ts +++ b/packages/maple/src/builtins/array/array-prototype.ts @@ -40,7 +40,9 @@ import { ARRAY_METHOD_WITH } from './methods/with.ts'; /** * Callbacks always run through the interpreter, so they stay under the budget and the host never - * holds a script function. Methods that mutate refuse read-only input arrays. + * holds a script function. Methods that mutate refuse read-only input arrays. A method that runs + * the host's implementation calls it through `Array.prototype`, never as `a.slice()`, since a + * script can give its array an own property of that name. */ export const ARRAY_PROTOTYPE: Prototype> = new Prototype( 'Array', diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index 26a8391..a6bc66b 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -278,6 +278,63 @@ describe('array methods', () => { ).toEqual(['1-2', '1,2', '1,2', '1,2']); }); + it('call the host implementation even where the array has an own property of that name', () => { + expect(run('const a = [1, 2, 3]; a.at = 1; return [].at.call(a, -1);')).toBe(3); + expect(run('const a = [1]; a.concat = 1; return [].concat.call(a, [2], 3);')).toEqual([ + 1, 2, 3 + ]); + expect( + run('const a = [1, 2, 3]; a.copyWithin = 1; [].copyWithin.call(a, 0, 1); return [...a];') + ).toEqual([2, 3, 3]); + expect(run('const a = ["x"]; a.entries = 1; return [...[].entries.call(a)];')).toEqual([ + [0, 'x'] + ]); + expect(run('const a = [1, 2]; a.fill = 1; [].fill.call(a, 0); return [...a];')).toEqual([ + 0, 0 + ]); + expect(run('const a = [1, 2]; a.includes = 1; return [].includes.call(a, 2);')).toBe(true); + expect(run('const a = [1, 2]; a.indexOf = 1; return [].indexOf.call(a, 2);')).toBe(1); + expect(run('const a = ["x", "y"]; a.keys = 1; return [...[].keys.call(a)];')).toEqual([0, 1]); + expect( + run( + 'const a = [1, 2, 1]; a.lastIndexOf = 1; return [[].lastIndexOf.call(a, 1), [].lastIndexOf.call(a, 1, 1)];' + ) + ).toEqual([2, 0]); + expect(run('const a = [1, 2]; a.pop = 1; return [[].pop.call(a), [...a]];')).toEqual([ + 2, + [1] + ]); + expect(run('const a = [1]; a.push = 1; [].push.call(a, 2); return [...a];')).toEqual([1, 2]); + expect(run('const a = [1, 2]; a.reverse = 1; [].reverse.call(a); return [...a];')).toEqual([ + 2, 1 + ]); + expect(run('const a = [1, 2]; a.shift = 1; return [[].shift.call(a), [...a]];')).toEqual([ + 1, + [2] + ]); + expect(run('const a = [3, 1, 2]; a.slice = 1; return [].slice.call(a, 1);')).toEqual([1, 2]); + expect( + run( + 'const a = [1, 2, 3]; a.splice = 1; return [[].splice.call(a, 1), [].splice.call(a, 0, 1, 9), [...a]];' + ) + ).toEqual([[2, 3], [1], [9]]); + expect(run('const a = [1, 2]; a.map = 1; return [].toLocaleString.call(a);')).toBe('1,2'); + expect(run('const a = [1, 2]; a.toReversed = 1; return [].toReversed.call(a);')).toEqual([ + 2, 1 + ]); + expect(run('const a = [2, 1]; a.toSorted = 1; return [].toSorted.call(a);')).toEqual([1, 2]); + expect( + run( + 'const a = [1, 2, 3]; a.toSpliced = 1; return [[].toSpliced.call(a, 1), [].toSpliced.call(a, 0, 1, 9)];' + ) + ).toEqual([[1], [9, 2, 3]]); + expect(run('const a = [1]; a.unshift = 1; [].unshift.call(a, 0); return [...a];')).toEqual([ + 0, 1 + ]); + expect(run('const a = ["x"]; a.values = 1; return [...[].values.call(a)];')).toEqual(['x']); + expect(run('const a = [1, 2]; a.with = 1; return [].with.call(a, 0, 9);')).toEqual([9, 2]); + }); + it('mutate script arrays', () => { expect(run('const l = [1]; const n = l.push(2, 3); return [n, l];')).toEqual([3, [1, 2, 3]]); expect(run('const l = [1, 2]; return [l.pop(), l];')).toEqual([2, [1]]); diff --git a/packages/maple/src/builtins/array/methods/at.ts b/packages/maple/src/builtins/array/methods/at.ts index c7148ef..de0e567 100644 --- a/packages/maple/src/builtins/array/methods/at.ts +++ b/packages/maple/src/builtins/array/methods/at.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_AT: Method> = method( Array.prototype.at.length, - (a, [index]) => a.at(toNumber(index)) + (a, [index]) => Array.prototype.at.call(a, toNumber(index)) ); diff --git a/packages/maple/src/builtins/array/methods/concat.ts b/packages/maple/src/builtins/array/methods/concat.ts index 6fbd505..3f0852a 100644 --- a/packages/maple/src/builtins/array/methods/concat.ts +++ b/packages/maple/src/builtins/array/methods/concat.ts @@ -2,5 +2,5 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const ARRAY_METHOD_CONCAT: Method> = method( Array.prototype.concat.length, - (a, items, { fail }) => guardHostErrors(fail, () => a.concat(...items)) + (a, items, { fail }) => guardHostErrors(fail, () => Array.prototype.concat.call(a, ...items)) ); diff --git a/packages/maple/src/builtins/array/methods/copy-within.ts b/packages/maple/src/builtins/array/methods/copy-within.ts index 76f827e..ead9184 100644 --- a/packages/maple/src/builtins/array/methods/copy-within.ts +++ b/packages/maple/src/builtins/array/methods/copy-within.ts @@ -9,6 +9,8 @@ export const ARRAY_METHOD_COPY_WITHIN: Method> = method( const to = toNumber(target); const from = toNumber(start); const until = optionalNumberArgument(end); - return guardIntegrityErrors(context.fail, a, () => a.copyWithin(to, from, until)); + return guardIntegrityErrors(context.fail, a, () => + Array.prototype.copyWithin.call(a, to, from, until) + ); } ); diff --git a/packages/maple/src/builtins/array/methods/entries.ts b/packages/maple/src/builtins/array/methods/entries.ts index 532410d..7b0f24b 100644 --- a/packages/maple/src/builtins/array/methods/entries.ts +++ b/packages/maple/src/builtins/array/methods/entries.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_ENTRIES: Method> = method( Array.prototype.entries.length, - (a) => new IteratorWrapper(a.entries()) + (a) => new IteratorWrapper(Array.prototype.entries.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/fill.ts b/packages/maple/src/builtins/array/methods/fill.ts index 6763801..7e8e098 100644 --- a/packages/maple/src/builtins/array/methods/fill.ts +++ b/packages/maple/src/builtins/array/methods/fill.ts @@ -7,6 +7,8 @@ export const ARRAY_METHOD_FILL: Method> = method( (a, [value, start, end], context) => { const from = optionalNumberArgument(start); const to = optionalNumberArgument(end); - return guardIntegrityErrors(context.fail, a, () => a.fill(value, from, to)); + return guardIntegrityErrors(context.fail, a, () => + Array.prototype.fill.call(a, value, from, to) + ); } ); diff --git a/packages/maple/src/builtins/array/methods/includes.ts b/packages/maple/src/builtins/array/methods/includes.ts index 56ed975..62c78f9 100644 --- a/packages/maple/src/builtins/array/methods/includes.ts +++ b/packages/maple/src/builtins/array/methods/includes.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_INCLUDES: Method> = method( Array.prototype.includes.length, - (a, [value, from]) => a.includes(value, optionalNumberArgument(from)) + (a, [value, from]) => Array.prototype.includes.call(a, value, optionalNumberArgument(from)) ); diff --git a/packages/maple/src/builtins/array/methods/index-of.ts b/packages/maple/src/builtins/array/methods/index-of.ts index aae6943..cdd5e23 100644 --- a/packages/maple/src/builtins/array/methods/index-of.ts +++ b/packages/maple/src/builtins/array/methods/index-of.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_INDEX_OF: Method> = method( Array.prototype.indexOf.length, - (a, [value, from]) => a.indexOf(value, optionalNumberArgument(from)) + (a, [value, from]) => Array.prototype.indexOf.call(a, value, optionalNumberArgument(from)) ); diff --git a/packages/maple/src/builtins/array/methods/keys.ts b/packages/maple/src/builtins/array/methods/keys.ts index 48b36b0..ec6105e 100644 --- a/packages/maple/src/builtins/array/methods/keys.ts +++ b/packages/maple/src/builtins/array/methods/keys.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_KEYS: Method> = method( Array.prototype.keys.length, - (a) => new IteratorWrapper(a.keys()) + (a) => new IteratorWrapper(Array.prototype.keys.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/last-index-of.ts b/packages/maple/src/builtins/array/methods/last-index-of.ts index 2140a6a..b804dd2 100644 --- a/packages/maple/src/builtins/array/methods/last-index-of.ts +++ b/packages/maple/src/builtins/array/methods/last-index-of.ts @@ -4,5 +4,7 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_LAST_INDEX_OF: Method> = method( Array.prototype.lastIndexOf.length, (a, args) => - args.length < 2 ? a.lastIndexOf(args[0]) : a.lastIndexOf(args[0], toNumber(args[1])) + args.length < 2 + ? Array.prototype.lastIndexOf.call(a, args[0]) + : Array.prototype.lastIndexOf.call(a, args[0], toNumber(args[1])) ); diff --git a/packages/maple/src/builtins/array/methods/pop.ts b/packages/maple/src/builtins/array/methods/pop.ts index 2b7c087..bd270a1 100644 --- a/packages/maple/src/builtins/array/methods/pop.ts +++ b/packages/maple/src/builtins/array/methods/pop.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_POP: Method> = method( Array.prototype.pop.length, - (a, _args, context) => guardIntegrityErrors(context.fail, a, () => a.pop()) + (a, _args, context) => guardIntegrityErrors(context.fail, a, () => Array.prototype.pop.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/push.ts b/packages/maple/src/builtins/array/methods/push.ts index c8d3886..0c3029b 100644 --- a/packages/maple/src/builtins/array/methods/push.ts +++ b/packages/maple/src/builtins/array/methods/push.ts @@ -5,6 +5,6 @@ export const ARRAY_METHOD_PUSH: Method> = method( Array.prototype.push.length, (a, items, context) => guardHostErrors(context.fail, () => - guardIntegrityErrors(context.fail, a, () => a.push(...items)) + guardIntegrityErrors(context.fail, a, () => Array.prototype.push.call(a, ...items)) ) ); diff --git a/packages/maple/src/builtins/array/methods/reverse.ts b/packages/maple/src/builtins/array/methods/reverse.ts index 13272a3..a922235 100644 --- a/packages/maple/src/builtins/array/methods/reverse.ts +++ b/packages/maple/src/builtins/array/methods/reverse.ts @@ -5,6 +5,5 @@ export const ARRAY_METHOD_REVERSE: Method> = method( Array.prototype.reverse.length, (a, _args, context) => // The host's own reverse runs no script code and keeps holes where JavaScript puts them - // oxlint-disable-next-line unicorn/no-array-reverse - reversing in place is what this method does - guardIntegrityErrors(context.fail, a, () => a.reverse()) + guardIntegrityErrors(context.fail, a, () => Array.prototype.reverse.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/shift.ts b/packages/maple/src/builtins/array/methods/shift.ts index 62913a8..d8f5063 100644 --- a/packages/maple/src/builtins/array/methods/shift.ts +++ b/packages/maple/src/builtins/array/methods/shift.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_SHIFT: Method> = method( Array.prototype.shift.length, - (a, _args, context) => guardIntegrityErrors(context.fail, a, () => a.shift()) + (a, _args, context) => guardIntegrityErrors(context.fail, a, () => Array.prototype.shift.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/slice.ts b/packages/maple/src/builtins/array/methods/slice.ts index feb0ff3..affc1b6 100644 --- a/packages/maple/src/builtins/array/methods/slice.ts +++ b/packages/maple/src/builtins/array/methods/slice.ts @@ -3,5 +3,6 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_SLICE: Method> = method( Array.prototype.slice.length, - (a, [start, end]) => a.slice(optionalNumberArgument(start), optionalNumberArgument(end)) + (a, [start, end]) => + Array.prototype.slice.call(a, optionalNumberArgument(start), optionalNumberArgument(end)) ); diff --git a/packages/maple/src/builtins/array/methods/splice.ts b/packages/maple/src/builtins/array/methods/splice.ts index bb01f4a..5a76e1c 100644 --- a/packages/maple/src/builtins/array/methods/splice.ts +++ b/packages/maple/src/builtins/array/methods/splice.ts @@ -2,12 +2,17 @@ import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts' import { method, type Method } from '../../../values/native.ts'; import { splice } from '../utils/splice.ts'; +// `call` is typed by the last overload, which needs a delete count, so `splice(start)` gets its own +const spliceToEnd: (this: Array, start: number) => Array = Array.prototype.splice; + export const ARRAY_METHOD_SPLICE: Method> = method( Array.prototype.splice.length, (a, args, context) => { return splice(a, args, (start, count, items) => { return guardIntegrityErrors(context.fail, a, () => - count === undefined ? a.splice(start) : a.splice(start, count, ...items) + count === undefined + ? spliceToEnd.call(a, start) + : Array.prototype.splice.call(a, start, count, ...items) ); }); } diff --git a/packages/maple/src/builtins/array/methods/to-locale-string.ts b/packages/maple/src/builtins/array/methods/to-locale-string.ts index 8092a6d..f41da9e 100644 --- a/packages/maple/src/builtins/array/methods/to-locale-string.ts +++ b/packages/maple/src/builtins/array/methods/to-locale-string.ts @@ -8,7 +8,7 @@ export const ARRAY_METHOD_TO_LOCALE_STRING: Method> = method( (a, [locales, options], { fail }) => { const tags = localesArgument(locales); const intlOptions = intlOptionsArgument(options); - const mapped = a.map((element) => { + const mapped = Array.prototype.map.call(a, (element) => { if (element === null || element === undefined) { return ''; } diff --git a/packages/maple/src/builtins/array/methods/to-reversed.ts b/packages/maple/src/builtins/array/methods/to-reversed.ts index fa83e7b..c0681f1 100644 --- a/packages/maple/src/builtins/array/methods/to-reversed.ts +++ b/packages/maple/src/builtins/array/methods/to-reversed.ts @@ -2,5 +2,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_TO_REVERSED: Method> = method( Array.prototype.toReversed.length, - (a) => a.toReversed() + (a) => Array.prototype.toReversed.call(a) ); diff --git a/packages/maple/src/builtins/array/methods/to-sorted.ts b/packages/maple/src/builtins/array/methods/to-sorted.ts index c0f85a9..f651d21 100644 --- a/packages/maple/src/builtins/array/methods/to-sorted.ts +++ b/packages/maple/src/builtins/array/methods/to-sorted.ts @@ -3,5 +3,5 @@ import { comparator } from '../utils/comparator.ts'; export const ARRAY_METHOD_TO_SORTED: Method> = method( Array.prototype.toSorted.length, - (a, [fn], context) => a.toSorted(comparator(fn, context)) + (a, [fn], context) => Array.prototype.toSorted.call(a, comparator(fn, context)) ); diff --git a/packages/maple/src/builtins/array/methods/to-spliced.ts b/packages/maple/src/builtins/array/methods/to-spliced.ts index 7f63459..e87658e 100644 --- a/packages/maple/src/builtins/array/methods/to-spliced.ts +++ b/packages/maple/src/builtins/array/methods/to-spliced.ts @@ -1,11 +1,21 @@ import { method, type Method } from '../../../values/native.ts'; import { splice } from '../utils/splice.ts'; +// `call` is typed by the last overload, which takes no items, so the one with items gets its own +const toSplicedWithItems: ( + this: ReadonlyArray, + start: number, + skipCount: number, + ...items: Array +) => Array = Array.prototype.toSpliced; + export const ARRAY_METHOD_TO_SPLICED: Method> = method( Array.prototype.toSpliced.length, (a, args) => { return splice(a, args, (start, count, items) => { - return count === undefined ? a.toSpliced(start) : a.toSpliced(start, count, ...items); + return count === undefined + ? Array.prototype.toSpliced.call(a, start) + : toSplicedWithItems.call(a, start, count, ...items); }); } ); diff --git a/packages/maple/src/builtins/array/methods/unshift.ts b/packages/maple/src/builtins/array/methods/unshift.ts index 2bdb6cc..81f5e5f 100644 --- a/packages/maple/src/builtins/array/methods/unshift.ts +++ b/packages/maple/src/builtins/array/methods/unshift.ts @@ -5,6 +5,6 @@ export const ARRAY_METHOD_UNSHIFT: Method> = method( Array.prototype.unshift.length, (a, items, context) => guardHostErrors(context.fail, () => - guardIntegrityErrors(context.fail, a, () => a.unshift(...items)) + guardIntegrityErrors(context.fail, a, () => Array.prototype.unshift.call(a, ...items)) ) ); diff --git a/packages/maple/src/builtins/array/methods/values.ts b/packages/maple/src/builtins/array/methods/values.ts index 670d718..1ee1c10 100644 --- a/packages/maple/src/builtins/array/methods/values.ts +++ b/packages/maple/src/builtins/array/methods/values.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_VALUES: Method> = method( Array.prototype.values.length, - (a) => new IteratorWrapper(a.values()) + (a) => new IteratorWrapper(Array.prototype.values.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/with.ts b/packages/maple/src/builtins/array/methods/with.ts index 3ffc491..0bd5415 100644 --- a/packages/maple/src/builtins/array/methods/with.ts +++ b/packages/maple/src/builtins/array/methods/with.ts @@ -3,5 +3,6 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const ARRAY_METHOD_WITH: Method> = method( Array.prototype.with.length, - (a, [index, value], { fail }) => guardHostErrors(fail, () => a.with(toNumber(index), value)) + (a, [index, value], { fail }) => + guardHostErrors(fail, () => Array.prototype.with.call(a, toNumber(index), value)) );