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
24 changes: 24 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -71,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.
Expand Down
7 changes: 6 additions & 1 deletion packages/maple-parity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ 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. |

### Maple departs from JavaScript

Expand All @@ -70,6 +74,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. |
Expand All @@ -82,5 +88,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. |
72 changes: 72 additions & 0 deletions packages/maple-parity/src/cases/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
86 changes: 59 additions & 27 deletions packages/maple-parity/src/divergences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -183,6 +199,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', () => {
Expand Down Expand Up @@ -221,6 +267,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', () => {
Expand Down Expand Up @@ -375,33 +434,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.

Expand Down
27 changes: 21 additions & 6 deletions packages/maple/docs/builtins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`.

Expand Down Expand Up @@ -137,6 +139,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`,
Expand Down
14 changes: 7 additions & 7 deletions packages/maple/docs/divergences.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,21 @@ 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

- `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

Expand Down
Loading
Loading