Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 4 additions & 2 deletions packages/maple/docs/boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,10 @@ safe to pass to `JSON.stringify`. `undefined` at the top level is returned as `u

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

A result nested deeper than 1000 levels ends the run with `RUNTIME_ERROR` and a `RangeError`
without a position, since the program has already finished. In a nested run, the intrinsic receives
Expand Down
4 changes: 4 additions & 0 deletions packages/maple/docs/builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ on the right-hand side of `instanceof`, and it adds no frame to `stack`.
with or without `new`. Errors Maple raises itself are the same kind of object, so a script can
catch and inspect them.

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

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

Expand Down
19 changes: 12 additions & 7 deletions packages/maple/docs/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ side, a script function, a bound function or `String` included, throws a `TypeEr

## Functions

Script functions and built-in functions have JavaScript's read-only `length` and `name`, names
inferred from bindings included. Host functions, and functions bound from them, have no properties
Script functions and built-in functions have JavaScript's `length` and `name`, names inferred from
bindings included. Neither can be written, but both can be deleted, which leaves what
`Function.prototype` has: `0` and `''`, which cannot be written either. Host functions, and functions bound from them, have no properties
at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply`
and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in
method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script
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 @@ -111,9 +114,11 @@ Inside the sandbox exactly these values exist:

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

## Methods

Expand Down
11 changes: 8 additions & 3 deletions packages/maple/src/boundary/to-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type UnconvertibleKind = 'iterator' | 'WeakMap' | 'WeakSet' | 'cyclic value';
*/
const DROPPED_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor']);

/** What an error hands the host as fields of its own, so not again among its `properties`. */
const ERROR_FIELDS: ReadonlySet<string> = new Set(['name', 'message', 'stack', 'cause']);

/** Thrown by a strict `toHost` for a value that must not leave the sandbox. */
export class UnconvertibleValueError extends Error {
public readonly kind: UnconvertibleKind;
Expand Down Expand Up @@ -218,8 +221,10 @@ function copyContainer(value: object, walk: Walk, depth: number): unknown {

/**
* `properties` is there only when the script added any, and `cause` only when the error has one,
* so an error without them keeps the shape it always had. `cause` has its own field whether it is
* enumerable or not, and never repeats in `properties`. A wrapper's added properties stay behind:
* so an error without them keeps the shape it always had. `name`, `message`, `stack` and `cause`
* have fields of their own and never repeat in `properties`, not even when the script deleted one
* and assigned it again, which makes it an added property. `cause` has its field whether it is
* enumerable or not. A wrapper's added properties stay behind:
* a Date or a Map leaves as the host's own.
*/
function copyError(error: SandboxError, walk: Walk, depth: number): SandboxErrorInfo {
Expand All @@ -231,7 +236,7 @@ function copyError(error: SandboxError, walk: Walk, depth: number): SandboxError

walk.copies.set(error, copy);

const keys = error.properties.enumerableKeys().filter((key) => key !== 'cause');
const keys = error.properties.enumerableKeys().filter((key) => !ERROR_FIELDS.has(key));
const cause = error.properties.getAdded('cause');
if (keys.length === 0 && cause === undefined) {
return copy;
Expand Down
5 changes: 3 additions & 2 deletions packages/maple/src/builtins/json/json.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,10 @@ describe('JSON.stringify', () => {
expect(error.code).toBe('LIMIT_EXCEEDED');
});

it('is a read-only namespace', () => {
it('is a namespace whose methods a run can replace', () => {
expect(run('return typeof JSON;')).toBe('object');
expect(run('return typeof JSON.parse;')).toBe('function');
expect(runAndCatch(maple, 'JSON.parse = 1;').sandboxError?.name).toBe('TypeError');
expect(run('JSON.parse = 1; return JSON.parse;')).toBe(1);
expect(run('return typeof JSON.parse;')).toBe('function');
});
});
14 changes: 13 additions & 1 deletion packages/maple/src/builtins/map/map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('Map', () => {
expect(run('return new Map().forEach.length;')).toBe(1);
});

it('has no own properties besides size but takes the ones a script adds', () => {
it('has no own properties but takes the ones a script adds', () => {
expect(run('return ["size" in new Map(), "get" in new Map()];')).toEqual([true, true]);
expect(run('return new Map().foo;')).toBeUndefined();
expect(run('return Object.keys(new Map([[1, 2]]));')).toEqual([]);
Expand All @@ -106,6 +106,18 @@ describe('Map', () => {
expect(run('return JSON.stringify(new Map([[1, 2]]));')).toBe('{}');
});

it('answers to size without having it as its own, as JavaScript keeps it on the prototype', () => {
expect(run('return Object.hasOwn(new Map(), "size");')).toBe(false);
expect(run('return Object.getOwnPropertyNames(new Map([[1, 2]]));')).toEqual([]);
expect(run('const m = new Map([[1, 2]]); return [delete m.size, m.size];')).toEqual([true, 1]);
expect(run('const m = new Map(); Object.freeze(m); return Object.isFrozen(m);')).toBe(true);
expect(run('const s = new Set([1]); return [Object.hasOwn(s, "size"), "size" in s];')).toEqual([
false,
true
]);
expect(run('const s = new Set([1]); return [delete s.size, s.size];')).toEqual([true, 1]);
});

it('crosses the boundary as a native Map and is read-only from frozen input', () => {
const data = { lookup: new Map([['a', { n: 1 }]]) };

Expand Down
30 changes: 28 additions & 2 deletions packages/maple/src/builtins/math/math.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('Math', () => {
expect(run('return Math.SQRT2;')).toBe(Math.SQRT2);
});

it('is a read-only namespace', () => {
it('is a namespace whose constants cannot be written or deleted', () => {
expect(run('return typeof Math;')).toBe('object');
expect(run('return typeof Math.max;')).toBe('function');
expect(run('return Math.foo;')).toBeUndefined();
Expand All @@ -70,11 +70,37 @@ describe('Math', () => {
expect(runAndCatch(maple, 'Math.PI = 3;').sandboxError?.message).toBe(
"Cannot assign to read-only property 'PI' of a Math"
);
expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.name).toBe('TypeError');
expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.message).toBe(
"Cannot delete property 'PI' of #<Object>"
);
expect(runAndCatch(maple, 'new Math();').code).toBe('DISALLOWED_SYNTAX');
expect(runAndCatch(maple, 'Math();').sandboxError?.message).toBe('Math is not a function');
});

it('lets a run replace and delete its methods, as JavaScript does', () => {
expect(
run(
'Math.max = () => 0; return [Math.max(1, 2), Object.keys(Math), Object.hasOwn(Math, "max")];'
)
).toEqual([0, [], true]);
expect(run('return [delete Math.max, Math.max, "max" in Math];')).toEqual([
true,
undefined,
false
]);
expect(run('delete Math.max; Math.max = 1; return [Math.max, Object.keys(Math)];')).toEqual([
1,
['max']
]);
expect(run('const max = Math.max; Math.max = 1; return [max(1, 2), Math.min(1, 2)];')).toEqual([
2, 1
]);
expect(runAndCatch(maple, 'Object.freeze(Math); Math.max = 1;').sandboxError?.message).toBe(
"Cannot assign to read-only property 'max' of a Math"
);
expect(run('return Math.max(1, 2);')).toBe(2);
});

it('never leaves the sandbox', () => {
expect(run('return Math;')).toBeUndefined();
expect(run('return [Math.max];')).toEqual([undefined]);
Expand Down
Loading
Loading