Skip to content
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence
`expect(followsJavaScript(code)).toEqual({ maple, jsInterpreter })`, and what Maple rejects as
`expect(rejected(code)).toBe(code)`. Both assert all three engines; add the reason to the README. A divergence where
Maple follows JavaScript needs no discussion; one where Maple departs from it does.
- Node runs in the test process, last in every helper. A case that changes a built-in (`Math.max = ...`) restores it in a
`finally` with its original descriptor.
- `@netwin/maple` maps to `packages/maple/src/index.ts` through tsconfig `paths`, which Vitest reads too.

## Conventions
Expand Down
3 changes: 3 additions & 0 deletions packages/maple-parity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ js-interpreter differs from JavaScript here; Maple does what Node does.
| `f.bind(null, 1)`: Maple names it `bound f` with the rest of `f`'s length, js-interpreter `fBound`, 0 | A bound function's `name` and `length` are ES2015; js-interpreter follows ES5. |
| `f.apply(null, 1)`, `f.apply(null, 'ab')` | A primitive is no list of arguments, so it is a `TypeError`. js-interpreter ignores a number and spreads a string. |
| `JSON.parse(text, reviver)`: Maple applies the reviver, with the holder as `this` | As in JavaScript; js-interpreter ignores the reviver and returns the parsed value as it is. |
| `Object.getOwnPropertyNames(/a/g)`: Maple lists only `lastIndex`, and `delete r.source` keeps it | `source` and the flags are accessors on the prototype since ES2015; js-interpreter follows ES5, where they are own. |
| `delete f.name`: Maple leaves `''`, which cannot be written, js-interpreter `undefined`, which can | `Function.prototype.name` is `''` and read-only since ES2015; js-interpreter follows ES5, which has no `name`. |
| `delete Math.PI`, `delete r.lastIndex`: Maple throws a `TypeError` | Neither can be deleted, which strict mode reports; js-interpreter throws for neither and deletes `lastIndex`. |

### Maple departs from JavaScript

Expand Down
16 changes: 16 additions & 0 deletions packages/maple-parity/src/cases/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ describe('Error.prototype.message', () => {
});
});

describe('delete:error.message', () => {
it('leaves the empty message of the prototype', () => {
expect(
parity('var e = new TypeError("m"); return [delete e.message, e.message, String(e)];')
).toEqual([true, '', 'TypeError']);
});

it('takes a later assignment as a new own property', () => {
expect(
parity(
'var e = new Error("m"); delete e.message; e.message = "n"; return [String(e), Object.keys(e)];'
)
).toEqual(['Error: n', ['message']]);
});
});

describe('instance:error.message', () => {
it('Error message is converted to a string', () => {
expect(parity('return [new Error(42).message, new Error(true).message];')).toEqual([
Expand Down
7 changes: 7 additions & 0 deletions packages/maple-parity/src/cases/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ describe('instance:function.length', () => {
)
).toStrictEqual([0, 2, 3]);
});

it('can be deleted, leaving 0', () => {
expect(parity('function two(a, b) {} return [delete two.length, two.length];')).toEqual([
true,
0
]);
});
});

describe('length and name of built-in functions', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/maple-parity/src/cases/math.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ describe('Math.max', () => {
)
).toEqual(9);
});
it('can be replaced by a script', () => {
const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {};
try {
expect(
parity('Math.max = function () { return 0; }; return [Math.max(1, 2), Object.keys(Math)];')
).toEqual([0, []]);
} finally {
Object.defineProperty(Math, 'max', max);
}
});
it('can be deleted by a script', () => {
const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {};
try {
expect(parity('delete Math.max; return [typeof Math.max, "max" in Math];')).toEqual([
'undefined',
false
]);
} finally {
Object.defineProperty(Math, 'max', max);
}
});
});

describe('Math.min', () => {
Expand Down
50 changes: 50 additions & 0 deletions packages/maple-parity/src/divergences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,56 @@ describe('JSON.parse', () => {
});
});

// A RegExp's source and flags are accessors on the prototype since ES2015, so lastIndex is its one
// own property; js-interpreter follows ES5, where they are own properties of every regexp.

describe('Object.getOwnPropertyNames:RegExp', () => {
it('lists lastIndex only', () => {
expect(followsJavaScript('return Object.getOwnPropertyNames(/a/g);')).toEqual({
maple: ['lastIndex'],
jsInterpreter: ['lastIndex', 'source', 'global', 'ignoreCase', 'multiline']
});
});

it('keeps source when a script deletes it', () => {
expect(followsJavaScript('var r = /a/g; return [delete r.source, r.source];')).toEqual({
maple: [true, 'a'],
jsInterpreter: [true, '(?:)']
});
});
});

describe('delete:function.name', () => {
it("leaves Function.prototype's empty name", () => {
expect(followsJavaScript('function f() {} return [delete f.name, f.name];')).toEqual({
maple: [true, ''],
jsInterpreter: [true, undefined]
});
});

it('leaves a name that cannot be written', () => {
expect(
followsJavaScript('function f() {} delete f.name; f.name = "g"; return f.name;')
).toEqual({ maple: MapleErrorCode.RUNTIME_ERROR, jsInterpreter: 'g' });
});
});

describe('delete:not configurable', () => {
it('throws for a constant of a namespace', () => {
expect(followsJavaScript('delete Math.PI;')).toEqual({
maple: MapleErrorCode.RUNTIME_ERROR,
jsInterpreter: undefined
});
});

it('throws for the lastIndex of a regexp', () => {
expect(followsJavaScript('var r = /a/g; return [delete r.lastIndex, r.lastIndex];')).toEqual({
maple: MapleErrorCode.RUNTIME_ERROR,
jsInterpreter: [true, undefined]
});
});
});

// Maple departs from JavaScript on purpose.

// The object model: Maple has no script-visible prototypes. An object's behaviour comes from its
Expand Down
15 changes: 10 additions & 5 deletions packages/maple-parity/src/parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ import { MapleError, MapleErrorCode } from '@netwin/maple';
import { expect } from 'vitest';
import { type Outcome, runJsInterpreter, runMaple, runNode } from './engines.ts';

// Node runs last in every helper. It runs in this process, so a case that changes a built-in
// (`Math.max = ...`) changes it here until the case restores it, and no other engine may run
// in the meantime: js-interpreter and Maple use the host's built-ins themselves.

/**
* Runs `code` in all three engines, asserts that Maple returns what Node and js-interpreter
* return, and hands that value back so the test can spell out what it is.
*/
export function parity(code: string, data?: Record<string, unknown>): unknown {
const value = valueOf(runMaple(code, data), 'Maple');
expect(value, 'Node').toStrictEqual(valueOf(runNode(code, data), 'Node'));
expect(value, 'js-interpreter').toStrictEqual(
valueOf(runJsInterpreter(code, data), 'js-interpreter')
);
const jsInterpreter = valueOf(runJsInterpreter(code, data), 'js-interpreter');
const node = valueOf(runNode(code, data), 'Node');
expect(value, 'Node').toStrictEqual(node);
expect(value, 'js-interpreter').toStrictEqual(jsInterpreter);
return value;
}

Expand Down Expand Up @@ -46,8 +50,9 @@ export function allThrow(code: string, data?: Record<string, unknown>): string {
*/
export function rejected(code: string, { sloppy = false } = {}): MapleErrorCode {
valueOf(runJsInterpreter(code, {}, !sloppy), 'js-interpreter');
const rejection = rejectionOf(runMaple(code));
valueOf(runNode(code, {}, !sloppy), 'Node');
return rejectionOf(runMaple(code));
return rejection;
}

/**
Expand Down
7 changes: 5 additions & 2 deletions packages/maple/docs/boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ globals), the arguments of a host function, its return value, and the result of
| Sandbox error | – | plain object | – | plain object |
| Function | host function | `undefined` | `INTERNAL_ERROR` | `undefined` |
| Iterator | – | `TypeError` | – | `undefined` |
| `WeakMap`, `WeakSet` | `TypeError` before | `TypeError` | `INTERNAL_ERROR` | `undefined` |
| Anything else | `TypeError` before | – | `INTERNAL_ERROR` | – |
| Value reached twice | shared | shared | shared | shared |
| Cycle | kept | `TypeError` | kept | kept |
Expand Down Expand Up @@ -131,8 +132,10 @@ safe to pass to `JSON.stringify`. `undefined` at the top level is returned as `u

A sandbox error becomes a plain object with `name`, `message` and `stack`, and with `properties`
holding what the script added to it, absent when it added nothing. An error with an own `cause`
carries it as `cause`. `name` and `message` arrive as strings, as `toString()` reads them. The
properties a script added to a wrapper stay behind, since a native `Date` or `Map` takes its place.
carries it as `cause`. `name`, `message` and `stack` arrive as strings, as the script left them: a
deleted `stack` arrives as `''`, and one the script deleted and assigned again arrives in its own
field, not in `properties`. The properties a script added to a wrapper stay behind, since a native
`Date` or `Map` takes its place.

A result nested deeper than 1000 levels ends the run with `RUNTIME_ERROR` and a `RangeError`
without a position, since the program has already finished. In a nested run, the intrinsic receives
Expand Down
16 changes: 15 additions & 1 deletion packages/maple/docs/builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ data key of the same name shadows them:
`undefined`, `NaN`, `Infinity`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`,
`encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`, `escape`, `unescape`,
`String`, `Number`, `Boolean`, `Array`, `Object`, `Math`, `JSON`, `Date`, `RegExp`, `Map`, `Set`,
`Iterator`, `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`,
`WeakMap`, `WeakSet`, `Iterator`, `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`,
`EvalError`, `atob`, `btoa`.

`atob` and `btoa` are web APIs rather than ECMAScript, provided because Node, browsers and workers
Expand Down Expand Up @@ -134,6 +134,16 @@ Set: `add`, `has`, `delete`, `clear`, `forEach`, `size`, `keys`, `values`, `entr
The set algebra takes a Set or a Map as the other operand. JavaScript's other set-like objects are
refused with a `TypeError`, so the host never calls a script `has` or `keys`.

## WeakMap and WeakSet

WeakMap: `get`, `set`, `has`, `delete`.

WeakSet: `add`, `has`, `delete`.

Any object can be a key or an entry, functions and built-ins included. Scripts have no symbols, so
nothing else can. A WeakMap or WeakSet never comes in with the input and leaves a run as
`undefined` ([Boundary](boundary.md)).

## Iterators

`keys`, `values` and `entries` of arrays, Maps and Sets, and `matchAll`, return iterators with
Expand Down Expand Up @@ -166,6 +176,10 @@ on the right-hand side of `instanceof`, and it adds no frame to `stack`.
with or without `new`. Errors Maple raises itself are the same kind of object, so a script can
catch and inspect them.

`name`, `message` and `stack` can be deleted. Deleting `message` leaves `''` and deleting `name`
leaves the constructor's name, as the prototype has them in JavaScript; deleting `stack` leaves
nothing. Assigning one of them afterwards adds an own property, which `Object.keys` lists.

`stack` is read-only and fixed when the error is created. It lists the script's own frames and
nothing of the host, innermost first:

Expand Down
51 changes: 28 additions & 23 deletions packages/maple/docs/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function declaration that is hoisting time, so the script fails before its first

Reading one of these names also throws `DISALLOWED_SYNTAX`, unless the script or the host binds it:

- Language: `Proxy`, `Reflect`, `Symbol`, `WeakMap`, `WeakSet`, `WeakRef`, `FinalizationRegistry`,
- Language: `Proxy`, `Reflect`, `Symbol`, `WeakRef`, `FinalizationRegistry`,
`Promise`, `SharedArrayBuffer`, `Atomics`, `WebAssembly`, `Intl`, `globalThis`, `eval`, `Function`
- Server runtimes: `require`, `module`, `exports`, `process`, `global`, `Buffer`, `__dirname`,
`__filename`, `Deno`, `Bun`
Expand All @@ -58,18 +58,19 @@ name. `typeof` never throws for a name that is not bound, so `typeof window !==
## Constructors

`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 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`.
`URIError`, `EvalError`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Array` and
`Object`. Any other `new` 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
`instanceof` accepts those fifteen plus `Iterator` on the right-hand side. Any other right-hand
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, and functions bound from them, have no properties
Script functions and built-in functions have JavaScript's `length` and `name`, names inferred from
bindings included. Neither can be written, but both can be deleted, which leaves what
`Function.prototype` has: `0` and `''`, which cannot be written either. Host functions, and functions bound from them, have no properties
at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply`
and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in
method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script
Expand All @@ -84,8 +85,10 @@ At the top level of the script `this` is `undefined`, since there is no global o
function still cannot be constructed ([Constructors](#constructors)).

Every function, and every namespace such as `Math`, takes the properties a script adds, as in
`fn.cache = {}`. Built-ins, host functions and namespaces are shared by every run, so what a run
adds to one, or `freeze`s on it, is gone when the next run starts.
`fn.cache = {}`. As in JavaScript, the methods of a built-in can be replaced and deleted, as in
`Math.max = fn`, while its constants, such as `Math.PI` or `Number.EPSILON`, can be neither.
Built-ins, host functions and namespaces are shared by every run, so what a run adds to one,
replaces on it, deletes from it or `freeze`s on it is gone when the next run starts.

A tagged template `` tag`a${x}b` `` calls `tag` with the strings array and then each substitution as
a value, not converted to a string, with `this` bound as for a call of the same form. The strings
Expand All @@ -98,22 +101,24 @@ arrays of its own, even for the same source.

Inside the sandbox exactly these values exist:

| Kind | Representation | `typeof` |
| -------------- | ------------------------------------------------------------------------- | ------------ |
| Primitives | Host primitives: `undefined`, `null`, booleans, numbers, strings | as in JS |
| Plain objects | Host objects created by Maple or by the input copy; own properties only | `'object'` |
| Arrays | Host arrays created by Maple or by the input copy | `'object'` |
| Closures | Maple's own function objects | `'function'` |
| Host functions | Branded wrappers; the native function is not reachable | `'function'` |
| Intrinsics | Branded wrappers, same surface as host functions | `'function'` |
| Wrappers | Branded wrappers around a native `RegExp`, `Date`, `Map`, `Set`, iterator | `'object'` |
| Errors | Maple's own error objects with `name` and `message` | `'object'` |
| Kind | Representation | `typeof` |
| -------------- | ----------------------------------------------------------------------------------------------- | ------------ |
| Primitives | Host primitives: `undefined`, `null`, booleans, numbers, strings | as in JS |
| Plain objects | Host objects created by Maple or by the input copy; own properties only | `'object'` |
| Arrays | Host arrays created by Maple or by the input copy | `'object'` |
| Closures | Maple's own function objects | `'function'` |
| Host functions | Branded wrappers; the native function is not reachable | `'function'` |
| Intrinsics | Branded wrappers, same surface as host functions | `'function'` |
| Wrappers | Branded wrappers around a native `RegExp`, `Date`, `Map`, `Set`, `WeakMap`, `WeakSet`, iterator | `'object'` |
| Errors | Maple's own error objects with `name` and `message` | `'object'` |

Wrappers are instances of Maple's own classes, which a script cannot construct, so a plain object
can never pose as one. The native value inside is never exposed; every method goes through a
dispatch table. A wrapper has no own properties except the few listed with its type (`size`, the
RegExp flags and `lastIndex`), plus the ones the script adds, which are kept apart from the native
value.
dispatch table. A wrapper's only own property is a RegExp's `lastIndex`, plus the ones the script
adds, which are kept apart from the native value. `size` and the RegExp flags and `source` are
properties of the type, as JavaScript keeps them on the prototype: every value answers to them and
`in` finds them, but `Object.hasOwn` and `Object.getOwnPropertyNames` leave them out, and deleting
one changes nothing.

## Methods

Expand Down
6 changes: 6 additions & 0 deletions packages/maple/src/boundary/from-host.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ describe('fromHost', () => {
expect(() => fromHost([1, Symbol('s')], 'data')).toThrow(
new TypeError('Unsupported value at data[1]: symbol')
);
expect(() => fromHost({ cache: new WeakMap() }, 'data')).toThrow(
new TypeError('Unsupported value at data.cache: WeakMap')
);
expect(() => fromHost([new WeakSet()], 'data')).toThrow(
new TypeError('Unsupported value at data[0]: WeakSet')
);
expect(() => fromHost(new Uint8Array(1), 'data')).toThrow(
new TypeError('Unsupported value at data: Uint8Array')
);
Expand Down
Loading
Loading