From f90237d7fc75bfc6e459b1ee79c96274c0eb935c Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:53:17 +0200 Subject: [PATCH 1/5] feat(evaluator): support this in script functions A function that is not an arrow gets strict-mode this: the object of a method call, undefined for a plain call, and the receiver call, apply and bind pass, never boxed. An arrow sees the this of the function it is written in. At the top level this is undefined, since there is no global object. Host functions and intrinsics still receive no this. Constructors and prototypes stay unsupported. --- packages/maple-parity/README.md | 5 +- .../maple-parity/src/cases/functions.spec.ts | 56 ++++++++ packages/maple-parity/src/divergences.spec.ts | 34 ++--- packages/maple/docs/builtins.md | 4 +- packages/maple/docs/divergences.md | 6 +- packages/maple/docs/extending.md | 6 +- packages/maple/docs/language.md | 21 +-- packages/maple/docs/security.md | 4 + packages/maple/src/escapes.spec.ts | 132 +++++++++++++++++- packages/maple/src/evaluator/evaluator.ts | 36 ++++- .../maple/src/evaluator/functions.spec.ts | 121 +++++++++++++++- packages/maple/src/evaluator/scope.spec.ts | 37 +++++ packages/maple/src/evaluator/scope.ts | 23 +++ 13 files changed, 442 insertions(+), 43 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index a69af50..76e3607 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -53,6 +53,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `with`, implicit globals, `arguments.callee` | Every script runs in strict mode, where these are errors. js-interpreter runs sloppy code and has no `callee`. | | A method called without its receiver: `const p = [].push; p(1)`, `(0, a.push)(1)`, `arr.map("".trim)` | As in strict mode, a plain call has no receiver, so it is a `TypeError`. js-interpreter passes the global object instead. | +| `this` at the top level and in a plain call: `typeof this` is `'undefined'` | As in strict mode, there is no global object to stand in. js-interpreter in sloppy mode passes its global object. | | `window`, `self`, `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` | Host APIs, not part of JavaScript. A run is synchronous and ends with its result, so nothing can be scheduled either. | | `String(Math)`, `"" + JSON`: Maple gives `[object Math]`, js-interpreter `[object Object]` | A namespace converts with its own tag, as in JavaScript; js-interpreter tags every object as a plain one. | | `Object.isExtensible(5)`: Maple gives `false`, js-interpreter `true` | A primitive is never extensible since ES2015; js-interpreter follows ES5. | @@ -73,14 +74,14 @@ 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 script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes. 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. | | `eval`, the `Function` constructor and its members (`Function.toString`, `Function.valueOf`, `Function.prototype`) | Creating code from a string is the escape the sandbox exists to prevent, so the global is not reachable at all. | -| The global `this`, the global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | +| The global `constructor` | There is no global object: globals are read-only bindings, and the host decides what exists. | ### Not supported yet diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 67b02d1..8aa6340 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -455,6 +455,62 @@ describe('method calls', () => { }); }); +describe('syntax:ThisExpression', () => { + it('is the object a function is called on', () => { + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o.f();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return o["f"]();')).toBe(1); + expect(parity('var o = { n: 1, f: function () { return this.n; } }; return (o.f)();')).toBe(1); + }); + + it('is undefined for a plain call', () => { + expect(parity('function f() { return typeof this; } return f();')).toBe('undefined'); + expect(parity('var o = { f: function () { return typeof this; } }; return (0, o.f)();')).toBe( + 'undefined' + ); + expect( + parity('var o = { f: function () { return typeof this; } }; var g = o.f; return g();') + ).toBe('undefined'); + expect(parity('return typeof this;')).toBe('undefined'); + }); + + it('is what call, apply and bind pass, unboxed', () => { + expect( + parity( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null)];' + ) + ).toEqual([5, 's', true, null]); + expect(parity('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver', () => { + expect(parity('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(parity('function f() { return this; } var o = { g: f.bind(1) }; return o.g();')).toBe(1); + }); + + it('is undefined again in a nested function', () => { + expect( + parity( + 'var o = { f: function () { function g() { return typeof this; } return g(); } }; return o.f();' + ) + ).toBe('undefined'); + }); + + it('lets a method return its object for chaining', () => { + expect( + parity( + 'var o = { n: 0, add: function (k) { this.n += k; return this; } }; return o.add(1).add(2).n;' + ) + ).toBe(3); + }); + + it('is read and written like any object', () => { + expect( + parity('var o = { f: function () { this.x = 1; return this.x; } }; return [o.f(), o.x];') + ).toEqual([1, 1]); + expect(allThrow('function f() { return this.x; } return f();')).toBe('TypeError'); + }); +}); + describe('assign:function property', () => { it('adds a property to a script function', () => { expect( diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 8b06e56..ba992d0 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -248,19 +248,27 @@ describe('Array.prototype.sort', () => { }); }); -// Maple departs from JavaScript on purpose. - -// The object model: Maple has no script-visible prototypes or receivers. An object's behaviour -// comes from its type, so `this`, constructors and prototype chains are left out, not sandboxed. - describe('syntax:ThisExpression', () => { - it('rejects this in a function', () => { - expect(rejected('function f() { return this; } return f();')).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); + // As in strict mode, there is no global object to stand in; sloppy js-interpreter passes its own + it('has no global this', () => { + expect(followsJavaScript('return typeof this;', { sloppy: true })).toEqual({ + maple: 'undefined', + jsInterpreter: 'object' + }); + }); + + it('is undefined in a plain call', () => { + expect( + followsJavaScript('function f() { return typeof this; } return f();', { sloppy: true }) + ).toEqual({ maple: 'undefined', jsInterpreter: 'object' }); }); }); +// Maple departs from JavaScript on purpose. + +// The object model: Maple has no script-visible prototypes. An object's behaviour comes from its +// type, so constructors and prototype chains are left out, not sandboxed. + describe('syntax:NewExpression', () => { it('rejects new on a script function', () => { expect(rejected('function Point(x) { this.x = x; } return new Point(1).x;')).toBe( @@ -417,14 +425,6 @@ describe('Function.prototype.length', () => { // There is no global object: globals are read-only bindings, and the host decides what exists. -describe('this', () => { - it('has no global this', () => { - expect(rejected('return typeof this;', { sloppy: true })).toBe( - MapleErrorCode.DISALLOWED_SYNTAX - ); - }); -}); - describe('constructor', () => { // js-interpreter's global object inherits from Object.prototype, so `constructor` is a global it('has no global named constructor', () => { diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 95f8688..45ef232 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -79,8 +79,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). Since there is no `this`, a reviver receives the holder as a third argument, -`(key, value, holder)`. +as `{}`). A reviver receives the holder as a third argument, `(key, value, holder)`, with `this` +undefined. A replacer, too, has `this` undefined rather than the holder. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 18eb2d1..6f4fda4 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -5,7 +5,7 @@ one that does; the linked section has the detail. ## Syntax and names -- `this`, `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, +- `class`, `super`, `async` / `await`, generators, getters and setters, tagged templates, `import` / `export`, `import.meta` and `new.target` throw `DISALLOWED_SYNTAX` ([Language](language.md#not-supported)). - An `async` or generator function throws when it is created, not when it is called. @@ -49,8 +49,8 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives the holder as a third argument instead of `this` - ([JSON](builtins.md#json)). +- A JSON reviver receives the holder as a third argument instead of `this`, and a JSON replacer + has `undefined` as `this` instead of the holder ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/extending.md b/packages/maple/docs/extending.md index f86bbd0..6e9ba62 100644 --- a/packages/maple/docs/extending.md +++ b/packages/maple/docs/extending.md @@ -5,7 +5,9 @@ A host function is any function found in the globals or in `data` that is not marked as an intrinsic. A script can call it, store it and pass it around, but never reach the native function. It has no properties, not even `length` or `name`: those are the host's business. Its arguments and -its return value are copied (see [Boundary](boundary.md#host-function-calls)). +its return value are copied (see [Boundary](boundary.md#host-function-calls)). It never receives a +`this`: called as a method, as in `obj.fn()`, or through `call`, `apply` or `bind`, it runs with +`this` undefined. A host call costs what any call costs; the work and the time inside the host function are not measured (see [Limits](limits.md)). @@ -55,6 +57,8 @@ interface Sandbox { program is one more call level and appears as `` in stack traces. Like any run, it neither sees nor leaves properties the calling run added to shared built-ins. Data that is not an object throws a plain `TypeError` to the intrinsic. +- An intrinsic receives no `this`, as a host function does, and `sandbox.call` calls a script + function with `this` undefined. - An exception the intrinsic throws is treated like a host function's. - The handle is usable only while the intrinsic runs. Once it has returned, every method throws a plain `Error`, so a handle or closure an intrinsic keeps cannot run script code outside the call. diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 3b25e18..468fe32 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -32,9 +32,9 @@ Expressions: ## Not supported -Reaching any of these throws `DISALLOWED_SYNTAX`: `this`, `class`, `super`, `async` / `await`, -generators and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, -`new.target`, and `arguments` at the script level, which is no function. +Reaching any of these throws `DISALLOWED_SYNTAX`: `class`, `super`, `async` / `await`, generators +and `yield`, getters and setters, tagged templates, `import` / `export`, `import.meta`, `new.target`, +and `arguments` at the script level, which is no function. An `async` or generator function throws when the closure is created, not when it is called. For a function declaration that is hoisting time, so the script fails before its first statement runs. @@ -72,11 +72,16 @@ Script functions and built-in functions have JavaScript's read-only `length` and 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. +method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script +function as its `this`. + +Inside a function that is not an arrow, `this` and `arguments` are those of a strict-mode function. +`this` is the object of a method call, so `obj.f()` and `obj[key]()` see `obj`, and `undefined` for +a plain call such as `f()` or `(0, obj.f)()`. `call`, `apply` and `bind` pass their receiver as it is, +never boxed, so `f.call(5)` sees `5`, and a bound function keeps its receiver whatever it is called +on. An arrow function has no `this` of its own and sees the one of the function it is written in. +At the top level of the script `this` is `undefined`, since there is no global object. A script +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 diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 5862765..beb3248 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -41,6 +41,10 @@ it can terminate. ignored by `+`, templates, comparison and the built-ins (see [Coercion](language.md#coercion)). The host never holds a script function: callbacks of built-ins run through the interpreter, and set algebra and `Iterator.from` refuse objects whose methods the script wrote. +- **No new reach through `this`.** A receiver is only ever a value the script already holds: the + object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, + or `undefined`. There is no global object, built-ins never pass an internal value as `this`, and + host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 811a5ee..b850846 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -34,6 +34,16 @@ function assertHostUntouched(): void { expect(Array.prototype).not.toHaveProperty('polluted'); } +/** Whether `call` invoked `cb` at all, and whether every invocation had `this` undefined. */ +function receiversIn(call: string): unknown { + return run(` + const seen = []; + const cb = function (x) { seen.push(this); return x; }; + ${call}; + return [seen.length > 0, seen.every((receiver) => receiver === undefined)]; + `); +} + describe('escape attempts', () => { afterEach(assertHostUntouched); @@ -79,7 +89,7 @@ describe('escape attempts', () => { expect(errorCode('Function("return this")();')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('eval("1");')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('globalThis;')).toBe('DISALLOWED_SYNTAX'); - expect(errorCode('return this;')).toBe('DISALLOWED_SYNTAX'); + expect(run('return this;')).toBeUndefined(); expect(errorCode('return arguments;')).toBe('DISALLOWED_SYNTAX'); expect(errorName('function f() { return arguments.callee; } f();')).toBe('TypeError'); expect(errorCode('Reflect.get({}, "a");')).toBe('DISALLOWED_SYNTAX'); @@ -317,6 +327,126 @@ describe('escape attempts', () => { }); }); + describe('this', () => { + it('is never a global object', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return (function () { return this; })();')).toBeUndefined(); + expect(run('return (() => this)();')).toBeUndefined(); + expect(run('function f() { return this; } return [f(), [1].map(f)[0]];')).toEqual([ + undefined, + undefined + ]); + }); + + it('guards its prototype keys like any other object', () => { + const method = (body: string): string | undefined => + errorName(`const o = { f() { return ${body}; } }; o.f();`); + + expect(method('this.constructor')).toBe('TypeError'); + expect(method('this.__proto__')).toBe('TypeError'); + expect(method('this["prototype"]')).toBe('TypeError'); + expect(method('this["__proto__"]')).toBe('TypeError'); + expect( + errorName('const f = () => 1; f.g = function () { return this.constructor; }; f.g();') + ).toBe('TypeError'); + expect(errorName('const o = { f() { this.__proto__ = { polluted: true }; } }; o.f();')).toBe( + 'TypeError' + ); + }); + + it('writes to a shared built-in for the current run only', () => { + const shared = new Maple(); + + expect( + shared.run('Math.f = function () { this.x = 1; return this; }; return Math.f() === Math;') + .value + ).toBe(true); + expect(shared.run('return [typeof Math.f, typeof Math.x];').value).toEqual([ + 'undefined', + 'undefined' + ]); + }); + + it('cannot change frozen input, and changes only the copy of unfrozen input', () => { + const data = { obj: { n: 1 } }; + const code = 'const o = { f() { this.x = 1; return this.x; } }; return o.f.call(obj);'; + + expect(errorName(code, data)).toBe('TypeError'); + expect( + runUnfrozen('obj.f = function () { this.x = 1; return this.x; }; return obj.f();', data) + ).toBe(1); + expect(runUnfrozen(code, data)).toBe(1); + expect(data).toEqual({ obj: { n: 1 } }); + }); + + it('is never passed to a host function or an intrinsic', () => { + const receivers: Array = []; + const probes = new Maple({ + globals: { + probe(this: unknown): void { + receivers.push(this); + }, + intrinsicProbe: intrinsic(function (this: unknown): void { + receivers.push(this); + }) + } + }); + + probes.run( + 'const o = { probe, intrinsicProbe }; o.probe(); o.intrinsicProbe(); probe.call(o); intrinsicProbe.apply(o, []); probe.bind(o)(); obj.probe();', + { + obj: { + probe(this: unknown): void { + receivers.push(this); + } + } + } + ); + + expect(receivers).toEqual(Array.from({ length: 6 }, () => undefined)); + }); + + it('is undefined for every built-in callback that is given no thisArg', () => { + const cases: ReadonlyArray = [ + '[2, 1, 3].sort(cb)', + '[2, 1, 3].toSorted(cb)', + '[1, 2].reduce(cb)', + '[1, 2].reduce(cb, 0)', + '[1, 2].reduceRight(cb)', + '[1].values().map(cb).toArray()', + '[1].values().filter(cb).toArray()', + '[1].values().flatMap((x) => [x]).map(cb).toArray()', + '[1].values().flatMap(function (x) { seen.push(this); return [x]; }).toArray()', + '[1].values().forEach(cb)', + '[1].values().some(cb)', + '[1].values().every(cb)', + '[1].values().find(cb)', + '[1, 2].values().reduce(cb)', + '"ab".replace("a", cb)', + '"ab".replace(/a/g, cb)', + '"aa".replaceAll("a", cb)', + 'Object.groupBy([1], cb)', + 'Map.groupBy([1], cb)', + 'JSON.stringify({ a: 1 }, function (key, value) { seen.push(this); return value; })', + 'Array.from([1], cb)', + 'Array.from({ length: 1 }, cb)', + '[1].map(cb)', + 'new Map([[1, 1]]).forEach(cb)', + 'new Set([1]).forEach(cb)' + ]; + for (const call of cases) { + expect([call, receiversIn(call)]).toEqual([call, [true, true]]); + } + }); + + it('returns to the host as a plain copy, without its functions', () => { + expect(run('return { n: 1, f() { return this; } }.f();')).toStrictEqual({ + n: 1, + f: undefined + }); + }); + }); + describe('functions never cross the boundary', () => { it('neither out through results nor into host functions', () => { expect(run('return () => 1;')).toBeUndefined(); diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 5697fa4..a2d812c 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -134,7 +134,6 @@ const HOST_FAILURE_MESSAGE = 'A host function failed'; const NODE_NAMES: Partial> = { ClassDeclaration: 'class', ClassExpression: 'class', - ThisExpression: 'this', Super: 'super', AwaitExpression: 'await', YieldExpression: 'yield', @@ -203,9 +202,13 @@ export class Evaluator { return this.#budget.stats(); } - /** Evaluates the program as a function body below `globals` and returns what the script returned. */ + /** + * Evaluates the program as a function body below `globals` and returns what the script returned. + * Like the body of a strict function called plainly, it has `undefined` as `this`. + */ public run(program: Program, globals: Scope): unknown { const scope = new Scope(globals); + scope.bindReceiver(undefined); const completion = this.evaluateFunctionBody(program.body, scope); return completion.type === 'return' ? completion.value : undefined; } @@ -684,6 +687,8 @@ export class Evaluator { return node.value; case 'Identifier': return this.readIdentifier(node, scope); + case 'ThisExpression': + return scope.receiver(); case 'TemplateLiteral': return this.evaluateTemplate(node, scope); case 'ObjectExpression': @@ -1039,6 +1044,15 @@ export class Evaluator { receiver = access.object; target = access.value; } + } else if (callee.type === 'ChainExpression' && callee.expression.type === 'MemberExpression') { + // `(a?.b)()` keeps `a` as the receiver; a short circuit ends at the parenthesis + this.tick(callee); + this.tick(callee.expression); + const access = this.accessMember(callee.expression, scope); + if (access !== SHORT_CIRCUIT) { + receiver = access.object; + target = access.value; + } } else { target = this.evaluateExpression(callee, scope); } @@ -1138,7 +1152,7 @@ export class Evaluator { ): unknown { this.tick(node); if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } this.enterCall(node); try { @@ -1157,7 +1171,7 @@ export class Evaluator { description: string ): unknown { if (callee instanceof Closure) { - return this.callClosure(callee, args, node); + return this.callClosure(receiver, callee, args, node); } if (callee instanceof NativeFunction) { return callee.invoke(receiver, args, this.callContext(node)); @@ -1305,14 +1319,24 @@ export class Evaluator { } } - /** `node` is where the call happened, which is what a depth error points at. */ - private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + /** + * `node` is where the call happened, which is what a depth error points at. An arrow ignores + * `receiver` and sees the `this` of the scope it closed over. + */ + private callClosure( + receiver: unknown, + closure: Closure, + args: ReadonlyArray, + node: Node + ): unknown { this.enterCall(node); const { node: fn } = closure; this.#frames.push({ name: closure.name || '', calledFrom: node }); try { const scope = new Scope(this.scopeForNamedFunction(closure)); if (fn.type !== 'ArrowFunctionExpression') { + // Strict mode: the receiver as it is, never boxed or replaced by a global object + scope.bindReceiver(receiver); // Built on the first read only; copying the arguments costs one operation per argument scope.defineLazy('arguments', () => { this.charge(args.length, node); diff --git a/packages/maple/src/evaluator/functions.spec.ts b/packages/maple/src/evaluator/functions.spec.ts index e1a8b19..2254cbb 100644 --- a/packages/maple/src/evaluator/functions.spec.ts +++ b/packages/maple/src/evaluator/functions.spec.ts @@ -400,6 +400,122 @@ describe('functions', () => { }); }); + describe('this', () => { + it('is the object of a member call', () => { + expect(run('const o = { n: 1, f() { return this.n; } }; return o.f();')).toBe(1); + expect(run('const o = { n: 2, f: function () { return this.n; } }; return o["f"]();')).toBe( + 2 + ); + expect(run('const o = { f() { return this; } }; return o?.f() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return o.f?.() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o.f)() === o;')).toBe(true); + expect(run('const o = { f() { return this; } }; return (o?.f)() === o;')).toBe(true); + }); + + it('is undefined for a plain call', () => { + expect(run('function f() { return this; } return f();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; return (0, o.f)();')).toBeUndefined(); + expect(run('const o = { f() { return this; } }; const g = o.f; return g();')).toBeUndefined(); + expect(run('return (function () { return typeof this; })();')).toBe('undefined'); + }); + + it('is undefined at the top level and in arrows defined there', () => { + expect(run('return this;')).toBeUndefined(); + expect(run('return typeof this;')).toBe('undefined'); + expect(run('const f = () => this; return f();')).toBeUndefined(); + expect(run('const o = { f: () => this }; return o.f();')).toBeUndefined(); + }); + + it('is what call, apply and bind pass, never boxed', () => { + expect( + run( + 'function f() { return this; } return [f.call(5), f.apply("s", []), f.bind(true)(), f.call(null), f.call()];' + ) + ).toEqual([5, 's', true, null, undefined]); + expect(run('function f() { return typeof this; } return f.call(5);')).toBe('number'); + }); + + it('keeps the bound receiver over a later one', () => { + expect(run('function f() { return this; } return f.bind(1).call(2);')).toBe(1); + expect(run('function f() { return this; } return { g: f.bind(1) }.g();')).toBe(1); + }); + + it('is lexical in an arrow, which ignores any receiver', () => { + expect(run('const o = { f() { return (() => this)(); } }; return o.f() === o;')).toBe(true); + expect( + run('const o = { f() { const g = () => this; return g.call(1); } }; return o.f() === o;') + ).toBe(true); + expect(run('const o = { f() { return [1].map(() => this)[0]; } }; return o.f() === o;')).toBe( + true + ); + }); + + it('is a function of its own in a nested function', () => { + expect( + run('const o = { f() { function g() { return this; } return g(); } }; return o.f();') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { function g() { return () => this; } return g()(); } }; return o.f();' + ) + ).toBeUndefined(); + }); + + it('chains methods that return it', () => { + expect(run('return { n: 0, add(k) { this.n += k; return this; } }.add(1).add(2).n;')).toBe(3); + }); + + it('is the receiver in default parameter values', () => { + expect(run('const o = { n: 1, f(a = this.n) { return a; } }; return o.f();')).toBe(1); + expect(run('const o = { f(a = () => this) { return a(); } }; return o.f() === o;')).toBe( + true + ); + }); + + it('sits next to arguments and a named function expression calling itself', () => { + expect( + run('const o = { f() { return [this === o, arguments.length]; } }; return o.f(1, 2);') + ).toEqual([true, 2]); + expect( + run( + 'const o = { n: 3, f: function g(k) { return k === 0 ? this.n : g.call(this, k - 1); } }; return o.f(2);' + ) + ).toBe(3); + expect( + run('const o = { f: function g(k) { return k ? g(0) : this; } }; return o.f(1);') + ).toBeUndefined(); + }); + + it('stays with each loop iteration', () => { + expect( + run(` + const o = { + f() { + const fs = []; + for (let i = 0; i < 2; i++) { fs.push(() => [this === o, i]); } + for (const k of [2]) { fs.push(() => [this === o, k]); } + return fs.map((g) => g()); + } + }; + return o.f(); + `) + ).toEqual([ + [true, 0], + [true, 1], + [true, 2] + ]); + }); + + it('fails like any undefined value when read from', () => { + expect(errorMessage('function f() { return this.x; } return f();')).toBe( + "Cannot read properties of undefined (reading 'x')" + ); + expect(errorMessage('function f() { this.x = 1; } f();')).toBe( + "Cannot set properties of undefined (setting 'x')" + ); + }); + }); + describe('toString and valueOf', () => { it('convert any function to the placeholder and give it back itself', () => { expect( @@ -430,9 +546,8 @@ describe('functions', () => { expect(runAndCatch(maple, 'function* g() {}').message).toBe('generators are not supported'); }); - it('throws for this and arguments only when reached', () => { - expect(maple.run('const f = () => this; return 1;').value).toBe(1); - expect(runAndCatch(maple, 'return (() => this)();').message).toBe("'this' is not supported"); + it('throws for a script-level arguments only when reached', () => { + expect(maple.run('const f = () => arguments; return 1;').value).toBe(1); expect(runAndCatch(maple, 'return arguments;').message).toBe("'arguments' is not supported"); }); diff --git a/packages/maple/src/evaluator/scope.spec.ts b/packages/maple/src/evaluator/scope.spec.ts index ffbc8be..46b90ed 100644 --- a/packages/maple/src/evaluator/scope.spec.ts +++ b/packages/maple/src/evaluator/scope.spec.ts @@ -96,4 +96,41 @@ describe('Scope', () => { expect(create).toHaveBeenCalledTimes(1); }); }); + + describe('receiver', () => { + it('comes from the nearest scope that binds one', () => { + const outer = new Scope(); + const middle = new Scope(outer); + const inner = new Scope(middle); + outer.bindReceiver(1); + + expect(inner.receiver()).toBe(1); + + middle.bindReceiver(undefined); + + expect(inner.receiver()).toBeUndefined(); + expect(outer.receiver()).toBe(1); + }); + + it('is not a binding named this', () => { + const scope = new Scope(); + scope.bindReceiver(1); + + expect(scope.lookup('this')).toBeUndefined(); + expect(scope.hasOwn('this')).toBe(false); + }); + + it('is carried along by a copy', () => { + const outer = new Scope(); + outer.bindReceiver(1); + const scope = new Scope(outer); + scope.bindReceiver(2); + + expect(scope.copy().receiver()).toBe(2); + }); + + it('fails when no scope binds one', () => { + expect(() => new Scope().receiver()).toThrow('No scope binds a receiver'); + }); + }); }); diff --git a/packages/maple/src/evaluator/scope.ts b/packages/maple/src/evaluator/scope.ts index 586d7ca..e618215 100644 --- a/packages/maple/src/evaluator/scope.ts +++ b/packages/maple/src/evaluator/scope.ts @@ -7,11 +7,17 @@ export interface Binding { value: unknown; } +/** Marks a scope that has no receiver of its own, so `this` comes from a parent. */ +const NO_RECEIVER: unique symbol = Symbol('no-receiver'); + /** One lexical environment; a `Map` so that names like `__proto__` are ordinary keys. */ export class Scope { readonly #bindings = new Map(); readonly #parent: Scope | undefined; + /** What `this` is in this scope; only the scope of a script or a non-arrow call has one. */ + #receiver: unknown = NO_RECEIVER; + constructor(parent?: Scope) { this.#parent = parent; } @@ -65,12 +71,29 @@ export class Scope { return this.#bindings.get(name) ?? this.#parent?.lookup(name); } + /** Makes `value` what `this` is here and in every scope below that has no receiver of its own. */ + public bindReceiver(value: unknown): void { + this.#receiver = value; + } + + /** The receiver of the nearest scope that has one; kept apart from the bindings, as a keyword. */ + public receiver(): unknown { + if (this.#receiver !== NO_RECEIVER) { + return this.#receiver; + } + if (!this.#parent) { + throw new Error('No scope binds a receiver'); + } + return this.#parent.receiver(); + } + /** * A sibling scope with copies of the own bindings, which gives a loop fresh per-iteration * bindings. */ public copy(): Scope { const scope = new Scope(this.#parent); + scope.#receiver = this.#receiver; for (const [name, binding] of this.#bindings) { scope.#bindings.set(name, { ...binding }); } From 80e5ccbe96c2951be38225741ff4581346d4eaa7 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:53:43 +0200 Subject: [PATCH 2/5] feat(builtins): pass thisArg to array, Map and Set callbacks every, filter, find, findIndex, findLast, findLastIndex, flatMap, forEach, map, some, Array.from and the forEach of Map and Set call their callback with the thisArg as this. Every other built-in callback keeps undefined. --- .../maple-parity/src/cases/functions.spec.ts | 18 +++ packages/maple/docs/builtins.md | 8 ++ .../maple/src/builtins/array/array.spec.ts | 133 ++++++++++++++++++ .../maple/src/builtins/array/methods/every.ts | 4 +- .../src/builtins/array/methods/filter.ts | 4 +- .../src/builtins/array/methods/find-index.ts | 4 +- .../builtins/array/methods/find-last-index.ts | 4 +- .../src/builtins/array/methods/find-last.ts | 4 +- .../maple/src/builtins/array/methods/find.ts | 3 +- .../src/builtins/array/methods/flat-map.ts | 4 +- .../src/builtins/array/methods/for-each.ts | 4 +- .../maple/src/builtins/array/methods/map.ts | 4 +- .../maple/src/builtins/array/methods/some.ts | 4 +- .../maple/src/builtins/array/statics/from.ts | 6 +- .../builtins/array/utils/array-callback.ts | 9 +- packages/maple/src/builtins/map/map.spec.ts | 19 +++ .../src/builtins/map/methods/for-each.ts | 4 +- .../src/builtins/set/methods/for-each.ts | 4 +- packages/maple/src/builtins/set/set.spec.ts | 17 +++ 19 files changed, 227 insertions(+), 30 deletions(-) diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 8aa6340..f91740f 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -511,6 +511,24 @@ describe('syntax:ThisExpression', () => { }); }); +describe('thisArg of array callbacks', () => { + it('is this in the callback', () => { + expect( + parity( + 'var t = { k: 10 }; var seen = []; var f = function (x) { seen.push(this.k + x); return true; }; [1].every(f, t); [1].filter(f, t); [1].forEach(f, t); [1].map(f, t); [1].some(f, t); return seen;' + ) + ).toEqual([11, 11, 11, 11, 11]); + }); + + it('passes a primitive unboxed', () => { + expect(parity('return [1].map(function () { return typeof this; }, 5);')).toEqual(['number']); + }); + + it('leaves this undefined when omitted', () => { + expect(parity('return [1].map(function () { return typeof this; });')).toEqual(['undefined']); + }); +}); + describe('assign:function property', () => { it('adds a property to a script function', () => { expect( diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 45ef232..ab63049 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -41,6 +41,11 @@ Instance: `length`, `at`, `concat`, `copyWithin`, `entries`, `every`, `fill`, `f Static: `Array.isArray`, `Array.from`, `Array.of`. +`every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex`, `flatMap`, `forEach`, `map`, +`some` and `Array.from` take a `thisArg` after the callback, which the callback sees as `this`. +The comparator of `sort` and `toSorted` and the reducer of `reduce` and `reduceRight` take none +and have `this` undefined, as in JavaScript. + ## Number and Boolean Number instance: `toFixed`, `toPrecision`, `toExponential`, `toLocaleString`, `toString`, `valueOf`. @@ -123,6 +128,9 @@ Set: `add`, `has`, `delete`, `clear`, `forEach`, `size`, `keys`, `values`, `entr `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`, `isSupersetOf`, `isDisjointFrom`. +`forEach` of a Map and of a Set takes a `thisArg` after the callback, which the callback sees as +`this`. + 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`. diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index a6bc66b..d202ef3 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -206,6 +206,127 @@ describe('array methods', () => { }); }); + describe('thisArg', () => { + it('every passes it to its callback as this', () => { + expect(run('return [1, 2].every(function (x) { return x < this.max; }, { max: 3 });')).toBe( + true + ); + expect(run('return [1].every(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].every(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('filter passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].filter(function (x) { return x > this.min; }, { min: 1 });') + ).toEqual([2, 3]); + expect(run('return [1].filter(function () { return this === undefined; });')).toEqual([1]); + expect( + run('const o = { f() { return [1].filter(() => this === o, 5); } }; return o.f();') + ).toEqual([1]); + }); + + it('find passes it to its callback as this', () => { + expect(run('return [1, 2, 3].find(function (x) { return x > this.min; }, { min: 1 });')).toBe( + 2 + ); + expect(run('return [1].find(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].find(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(1); + expect(run('return [1].findIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('findLast passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLast(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(3); + expect(run('return [1].findLast(function () { return this === undefined; });')).toBe(1); + expect( + run('const o = { f() { return [1].findLast(() => this === o, 5); } }; return o.f();') + ).toBe(1); + }); + + it('findLastIndex passes it to its callback as this', () => { + expect( + run('return [1, 2, 3].findLastIndex(function (x) { return x > this.min; }, { min: 1 });') + ).toBe(2); + expect(run('return [1].findLastIndex(function () { return this === undefined; });')).toBe(0); + expect( + run('const o = { f() { return [1].findLastIndex(() => this === o, 5); } }; return o.f();') + ).toBe(0); + }); + + it('flatMap passes it to its callback as this', () => { + expect( + run('return [1, 2].flatMap(function (x) { return [x, x * this.factor]; }, { factor: 10 });') + ).toEqual([1, 10, 2, 20]); + expect(run('return [1].flatMap(function () { return [this]; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].flatMap(() => [this === o], 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('forEach passes it to its callback as this', () => { + expect( + run( + 'let sum = 0; [1, 2].forEach(function (x) { sum += x * this.factor; }, { factor: 10 }); return sum;' + ) + ).toBe(30); + expect( + run('let seen = 1; [1].forEach(function () { seen = this; }); return seen;') + ).toBeUndefined(); + expect( + run( + 'const o = { f() { let seen; [1].forEach(() => { seen = this; }, 5); return seen === o; } }; return o.f();' + ) + ).toBe(true); + }); + + it('map passes it to its callback as this', () => { + expect( + run('return [1, 2].map(function (x) { return x + this.offset; }, { offset: 10 });') + ).toEqual([11, 12]); + expect(run('return [1].map(function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return [1].map(() => this === o, 5); } }; return o.f();') + ).toEqual([true]); + }); + + it('some passes it to its callback as this', () => { + expect( + run('return [1, 2].some(function (x) { return x === this.wanted; }, { wanted: 2 });') + ).toBe(true); + expect(run('return [1].some(function () { return this === undefined; });')).toBe(true); + expect( + run('const o = { f() { return [1].some(() => this === o, 5); } }; return o.f();') + ).toBe(true); + }); + + it('is passed as it is, never boxed', () => { + expect(run('return [1].map(function () { return typeof this; }, "s");')).toEqual(['string']); + expect(run('return [1].map(function () { return this; }, 5);')).toEqual([5]); + }); + + it('leaves the length of each method at 1', () => { + expect( + run( + 'return [[].every.length, [].filter.length, [].find.length, [].findIndex.length, [].findLast.length, [].findLastIndex.length, [].flatMap.length, [].forEach.length, [].map.length, [].some.length];' + ) + ).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + }); + }); + describe('flat and flatMap', () => { it('charge one operation per element visited, nested arrays included', () => { expect( @@ -403,6 +524,18 @@ describe('Array statics', () => { expect(run('return Array.foo;')).toBeUndefined(); }); + it('from calls the mapper with the thisArg as this', () => { + expect(run('return Array.from([1], function (x) { return [this, x]; }, 5);')).toEqual([[5, 1]]); + expect(run('return Array.from({ length: 1 }, function () { return this; }, "t");')).toEqual([ + 't' + ]); + expect(run('return Array.from([1], function () { return this; });')).toEqual([undefined]); + expect( + run('const o = { f() { return Array.from([1], () => this, 5)[0] === o; } }; return o.f();') + ).toBe(true); + expect(run('return Array.from.length;')).toBe(1); + }); + it('from reads an array-like length as ToLength does', () => { expect(run('return Array.from({ length: 1.5, 0: "a", 1: "b" });')).toEqual(['a']); expect(run('return Array.from({ length: -1, 0: "a" });')).toEqual([]); diff --git a/packages/maple/src/builtins/array/methods/every.ts b/packages/maple/src/builtins/array/methods/every.ts index 2f3d3b0..7b3dff2 100644 --- a/packages/maple/src/builtins/array/methods/every.ts +++ b/packages/maple/src/builtins/array/methods/every.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_EVERY: Method> = method( Array.prototype.every.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), false) === undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), false) === undefined ); diff --git a/packages/maple/src/builtins/array/methods/filter.ts b/packages/maple/src/builtins/array/methods/filter.ts index 6232100..d6d684a 100644 --- a/packages/maple/src/builtins/array/methods/filter.ts +++ b/packages/maple/src/builtins/array/methods/filter.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FILTER: Method> = method( Array.prototype.filter.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { if (call(element, index, a)) { diff --git a/packages/maple/src/builtins/array/methods/find-index.ts b/packages/maple/src/builtins/array/methods/find-index.ts index a278897..8400959 100644 --- a/packages/maple/src/builtins/array/methods/find-index.ts +++ b/packages/maple/src/builtins/array/methods/find-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_INDEX: Method> = method( Array.prototype.findIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last-index.ts b/packages/maple/src/builtins/array/methods/find-last-index.ts index 9a89907..f5fd346 100644 --- a/packages/maple/src/builtins/array/methods/find-last-index.ts +++ b/packages/maple/src/builtins/array/methods/find-last-index.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST_INDEX: Method> = method( Array.prototype.findLastIndex.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[0] ?? -1 + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[0] ?? -1 ); diff --git a/packages/maple/src/builtins/array/methods/find-last.ts b/packages/maple/src/builtins/array/methods/find-last.ts index b0f0798..d24c32d 100644 --- a/packages/maple/src/builtins/array/methods/find-last.ts +++ b/packages/maple/src/builtins/array/methods/find-last.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND_LAST: Method> = method( Array.prototype.findLast.length, - (a, [fn], context) => - findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntriesFromEnd(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/find.ts b/packages/maple/src/builtins/array/methods/find.ts index b062a21..a491fed 100644 --- a/packages/maple/src/builtins/array/methods/find.ts +++ b/packages/maple/src/builtins/array/methods/find.ts @@ -5,5 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_FIND: Method> = method( Array.prototype.find.length, - (a, [fn], context) => findEntryBy(a, indexedEntries(a), arrayCallback(fn, context), true)?.[1] + (a, [fn, thisArg], context) => + findEntryBy(a, indexedEntries(a), arrayCallback(fn, context, thisArg), true)?.[1] ); diff --git a/packages/maple/src/builtins/array/methods/flat-map.ts b/packages/maple/src/builtins/array/methods/flat-map.ts index 036cf17..f7e2635 100644 --- a/packages/maple/src/builtins/array/methods/flat-map.ts +++ b/packages/maple/src/builtins/array/methods/flat-map.ts @@ -5,8 +5,8 @@ import { flattenInto } from '../utils/flatten-into.ts'; export const ARRAY_METHOD_FLAT_MAP: Method> = method( Array.prototype.flatMap.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); const result: Array = []; for (const [index, element] of presentEntries(a)) { const mapped = call(element, index, a); diff --git a/packages/maple/src/builtins/array/methods/for-each.ts b/packages/maple/src/builtins/array/methods/for-each.ts index c552071..bd62bce 100644 --- a/packages/maple/src/builtins/array/methods/for-each.ts +++ b/packages/maple/src/builtins/array/methods/for-each.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_FOR_EACH: Method> = method( Array.prototype.forEach.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); for (const [index, element] of presentEntries(a)) { call(element, index, a); } diff --git a/packages/maple/src/builtins/array/methods/map.ts b/packages/maple/src/builtins/array/methods/map.ts index cd8d35d..d26fe1a 100644 --- a/packages/maple/src/builtins/array/methods/map.ts +++ b/packages/maple/src/builtins/array/methods/map.ts @@ -4,8 +4,8 @@ import { presentEntries } from '../utils/entries.ts'; export const ARRAY_METHOD_MAP: Method> = method( Array.prototype.map.length, - (a, [fn], context) => { - const call = arrayCallback(fn, context); + (a, [fn, thisArg], context) => { + const call = arrayCallback(fn, context, thisArg); // The result keeps the holes of the source, which the callback never sees const result: Array = []; result.length = a.length; diff --git a/packages/maple/src/builtins/array/methods/some.ts b/packages/maple/src/builtins/array/methods/some.ts index 322c189..4b4e6e9 100644 --- a/packages/maple/src/builtins/array/methods/some.ts +++ b/packages/maple/src/builtins/array/methods/some.ts @@ -5,6 +5,6 @@ import { findEntryBy } from '../utils/find-entry-by.ts'; export const ARRAY_METHOD_SOME: Method> = method( Array.prototype.some.length, - (a, [fn], context) => - findEntryBy(a, presentEntries(a), arrayCallback(fn, context), true) !== undefined + (a, [fn, thisArg], context) => + findEntryBy(a, presentEntries(a), arrayCallback(fn, context, thisArg), true) !== undefined ); diff --git a/packages/maple/src/builtins/array/statics/from.ts b/packages/maple/src/builtins/array/statics/from.ts index 951950f..f6eda2c 100644 --- a/packages/maple/src/builtins/array/statics/from.ts +++ b/packages/maple/src/builtins/array/statics/from.ts @@ -6,16 +6,14 @@ import { getMember } from '../../method-dispatch.ts'; export const ARRAY_STATIC_FROM: NativeFunction = new NativeFunction( 'from', Array.from.length, - (_, [source, mapper], context) => { + (_, [source, mapper, thisArg], context) => { const { fail } = context; if (mapper !== undefined && !isCallable(mapper)) { return fail('TypeError', `${describeValue(mapper)} is not a function`); } const items: Array = []; const add = (item: unknown): void => { - items.push( - mapper === undefined ? item : context.call(undefined, mapper, [item, items.length]) - ); + items.push(mapper === undefined ? item : context.call(thisArg, 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 ea880c3..c98e840 100644 --- a/packages/maple/src/builtins/array/utils/array-callback.ts +++ b/packages/maple/src/builtins/array/utils/array-callback.ts @@ -3,10 +3,13 @@ import { isCallable, type CallContext } from '../../../values/native.ts'; export type ArrayCallback = (...args: Array) => unknown; -/** The script callback as a host function; a non-callable argument fails like the host would. */ -export function arrayCallback(fn: unknown, context: CallContext): ArrayCallback { +/** + * The script callback as a host function; a non-callable argument fails like the host would. It + * runs with `thisArg` as `this`, which is `undefined` for the methods that take none. + */ +export function arrayCallback(fn: unknown, context: CallContext, thisArg?: unknown): ArrayCallback { if (!isCallable(fn)) { context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(undefined, fn, args); + return (...args) => context.call(thisArg, fn, args); } diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 1af68e9..657d957 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -56,6 +56,25 @@ describe('Map', () => { expect(run(`${setup} const [[k]] = m; return k;`)).toBe('b'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run( + 'const seen = []; new Map([[1, 2]]).forEach(function () { seen.push(this); }); return seen;' + ) + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Map([[1, 2]]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Map().forEach.length;')).toBe(1); + }); + it('has no own properties besides size 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(); diff --git a/packages/maple/src/builtins/map/methods/for-each.ts b/packages/maple/src/builtins/map/methods/for-each.ts index 5e17c66..83b2326 100644 --- a/packages/maple/src/builtins/map/methods/for-each.ts +++ b/packages/maple/src/builtins/map/methods/for-each.ts @@ -4,13 +4,13 @@ import type { MapWrapper } from '../../../values/wrappers/map-wrapper.ts'; export const MAP_METHOD_FOR_EACH: Method = method( Map.prototype.forEach.length, - (m, [fn], context) => { + (m, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const [key, value] of m.map) { - context.call(undefined, fn, [value, key, m]); + context.call(thisArg, fn, [value, key, m]); } return undefined; diff --git a/packages/maple/src/builtins/set/methods/for-each.ts b/packages/maple/src/builtins/set/methods/for-each.ts index 8328349..5aa3a3e 100644 --- a/packages/maple/src/builtins/set/methods/for-each.ts +++ b/packages/maple/src/builtins/set/methods/for-each.ts @@ -4,13 +4,13 @@ import type { SetWrapper } from '../../../values/wrappers/set-wrapper.ts'; export const SET_METHOD_FOR_EACH: Method = method( Set.prototype.forEach.length, - (s, [fn], context) => { + (s, [fn, thisArg], context) => { if (!isCallable(fn)) { return context.fail('TypeError', `${describeValue(fn)} is not a function`); } for (const value of s.set) { - context.call(undefined, fn, [value, value, s]); + context.call(thisArg, fn, [value, value, s]); } return undefined; diff --git a/packages/maple/src/builtins/set/set.spec.ts b/packages/maple/src/builtins/set/set.spec.ts index 906c8ec..60310e4 100644 --- a/packages/maple/src/builtins/set/set.spec.ts +++ b/packages/maple/src/builtins/set/set.spec.ts @@ -31,6 +31,23 @@ describe('Set', () => { ).toBe('[object Object] is not iterable'); }); + it('forEach calls its callback with the thisArg as this', () => { + expect( + run( + 'const seen = []; new Set([1]).forEach(function () { seen.push(this); }, 5); return seen;' + ) + ).toEqual([5]); + expect( + run('const seen = []; new Set([1]).forEach(function () { seen.push(this); }); return seen;') + ).toEqual([undefined]); + expect( + run( + 'const o = { f() { let s; new Set([1]).forEach(() => { s = this; }, 5); return s === o; } }; return o.f();' + ) + ).toBe(true); + expect(run('return new Set().forEach.length;')).toBe(1); + }); + it('is read-only from the input and crosses the boundary as a native Set', () => { const data = { tags: new Set(['a']) }; From 7ed2e5f4492a81e4f262e130a499a1abeb0ef3c9 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:54:12 +0200 Subject: [PATCH 3/5] feat(json): give a JSON reviver its holder as this A reviver no longer receives the holder as a third argument; it is this instead, as in JavaScript. Use this where a reviver read the third argument. --- packages/maple-parity/README.md | 1 + packages/maple-parity/src/divergences.spec.ts | 11 +++++++++++ packages/maple/docs/builtins.md | 4 ++-- packages/maple/docs/divergences.md | 4 ++-- packages/maple/docs/security.md | 5 +++-- packages/maple/src/builtins/json/json.spec.ts | 17 +++++++++++++++-- .../maple/src/builtins/json/statics/parse.ts | 4 ++-- 7 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 76e3607..6b93003 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -67,6 +67,7 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | 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. | +| `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. | ### Maple departs from JavaScript diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index ba992d0..851d4c0 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -264,6 +264,17 @@ describe('syntax:ThisExpression', () => { }); }); +describe('JSON.parse', () => { + // js-interpreter ignores the reviver; Maple calls it with the holder as this + it('applies a reviver with the holder as this', () => { + expect( + followsJavaScript( + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' + ) + ).toEqual({ maple: { a: ['a'] }, jsInterpreter: { a: 1 } }); + }); +}); + // Maple departs from JavaScript on purpose. // The object model: Maple has no script-visible prototypes. An object's behaviour comes from its diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index ab63049..c1d034f 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -84,8 +84,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). A reviver receives the holder as a third argument, `(key, value, holder)`, with `this` -undefined. A replacer, too, has `this` undefined rather than the holder. +as `{}`). A reviver receives the holder as `this`, as in JavaScript, but not the third `context` +argument with the `source` text. A replacer has `this` undefined rather than the holder. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 6f4fda4..677c555 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -49,8 +49,8 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives the holder as a third argument instead of `this`, and a JSON replacer - has `undefined` as `this` instead of the holder ([JSON](builtins.md#json)). +- A JSON reviver receives no third `context` argument, and a JSON replacer has `undefined` as + `this` instead of the holder ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index beb3248..3c79e96 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -43,8 +43,9 @@ it can terminate. set algebra and `Iterator.from` refuse objects whose methods the script wrote. - **No new reach through `this`.** A receiver is only ever a value the script already holds: the object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, - or `undefined`. There is no global object, built-ins never pass an internal value as `this`, and - host functions and intrinsics receive none. + the holder a JSON reviver visits (part of the script's own value, or a fresh object around it), or + `undefined`. There is no global object, built-ins never pass an internal value as `this`, and host + functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index a40840f..2daf15f 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -36,7 +36,7 @@ describe('JSON.parse', () => { expect(run('try { JSON.parse(); } catch (e) { return e.name; }')).toBe('SyntaxError'); }); - it('applies a reviver bottom-up, with the holder as third argument', () => { + it('applies a reviver bottom-up, with the holder as this', () => { const text = '{"a":{"b":1},"c":[2,3]}'; expect( @@ -60,9 +60,14 @@ describe('JSON.parse', () => { expect(run('return JSON.parse("{}", (k, v) => (k === "" ? 42 : v));')).toBe(42); expect( run( - 'return JSON.parse(\'{"a":1}\', (k, v, holder) => (k === "a" ? Object.keys(holder) : v));' + 'return JSON.parse(\'{"a":1}\', function (k, v) { return k === "a" ? Object.keys(this) : v; });' ) ).toEqual({ a: ['a'] }); + expect( + run('return JSON.parse("[1]", function (k, v) { return k === "0" ? this.length : v; });') + ).toEqual([1]); + expect(run('return JSON.parse("1", function () { return Object.keys(this); });')).toEqual(['']); + expect(run('return JSON.parse(\'{"a":1}\', (k, v, holder) => holder);')).toBeUndefined(); expect( run( 'return JSON.parse(\'{"when":"1970-01-01T00:00:00.000Z"}\', (k, v) => (k === "when" ? new Date(v) : v)).when.getTime();' @@ -72,6 +77,14 @@ describe('JSON.parse', () => { }); it('keeps a reviver inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.constructor; });').sandboxError + ?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.parse("{}", function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); expect(run('return Object.keys(JSON.parse(\'{"__proto__": 1}\', (k, v) => v));')).toEqual([ '__proto__' ]); diff --git a/packages/maple/src/builtins/json/statics/parse.ts b/packages/maple/src/builtins/json/statics/parse.ts index b164aa5..236e386 100644 --- a/packages/maple/src/builtins/json/statics/parse.ts +++ b/packages/maple/src/builtins/json/statics/parse.ts @@ -26,7 +26,7 @@ export const JSON_STATIC_PARSE: NativeFunction = new NativeFunction( /** * The reviver visits children before their holder's key and the root last under the key `''`, - * as the spec requires. The holder comes as a third argument in place of `this`. + * as the spec requires, with the holder as `this`. */ function revive( holder: Record | Array, @@ -61,5 +61,5 @@ function revive( } } } - return context.call(undefined, reviver, [key, value, holder]); + return context.call(holder, reviver, [key, value]); } From d1c1e202fea439b180f02630594efb91237a7a51 Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 21:54:32 +0200 Subject: [PATCH 4/5] feat(json): give a JSON replacer its holder as this A function replacer is called with the object holding the key as this, and with a fresh object around the value at the top level, as in JavaScript. --- packages/maple/docs/builtins.md | 4 +- packages/maple/docs/divergences.md | 3 +- packages/maple/docs/security.md | 6 +-- packages/maple/src/builtins/json/json.spec.ts | 40 +++++++++++++++++++ .../src/builtins/json/statics/stringify.ts | 33 ++++++++++----- packages/maple/src/escapes.spec.ts | 1 - 6 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index c1d034f..a45d456 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -84,8 +84,8 @@ All functions and constants of ES2023 `Math`: `abs`, `acos`, `acosh`, `asin`, `a `JSON.parse(text, reviver?)` and `JSON.stringify(value, replacer?, space?)`, implemented by Maple so that wrappers serialize the way their native counterparts would (a Date as its ISO string, a Map -as `{}`). A reviver receives the holder as `this`, as in JavaScript, but not the third `context` -argument with the `source` text. A replacer has `this` undefined rather than the holder. +as `{}`). A reviver and a function replacer receive the holder as `this`, as in JavaScript; a +reviver does not receive the third `context` argument with the `source` text. ## RegExp diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index 677c555..a3c5a8e 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -49,8 +49,7 @@ one that does; the linked section has the detail. - Set algebra refuses set-like objects other than Sets and Maps, and `Iterator.from` refuses an object with a script `next` ([Built-ins](builtins.md#map-and-set)). -- A JSON reviver receives no third `context` argument, and a JSON replacer has `undefined` as - `this` instead of the holder ([JSON](builtins.md#json)). +- A JSON reviver receives no third `context` argument ([JSON](builtins.md#json)). - The legacy `RegExp.$1` statics, the String HTML methods and `Intl` are not available. ## Errors and limits diff --git a/packages/maple/docs/security.md b/packages/maple/docs/security.md index 3c79e96..67175cc 100644 --- a/packages/maple/docs/security.md +++ b/packages/maple/docs/security.md @@ -43,9 +43,9 @@ it can terminate. set algebra and `Iterator.from` refuse objects whose methods the script wrote. - **No new reach through `this`.** A receiver is only ever a value the script already holds: the object of a method call, what it passes to `call`, `apply`, `bind` or as a callback's `thisArg`, - the holder a JSON reviver visits (part of the script's own value, or a fresh object around it), or - `undefined`. There is no global object, built-ins never pass an internal value as `this`, and host - functions and intrinsics receive none. + the holder a JSON reviver or replacer visits (part of the script's own value, or a fresh object + around it), or `undefined`. There is no global object, built-ins never pass an internal value as + `this`, and host functions and intrinsics receive none. - **Copied input.** Everything the host passes in is a copy (see [Boundary](boundary.md#into-the-run)), so the host's own objects are never modified. The copy is frozen unless the host asks for a changeable one for a single run; instance globals are frozen diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index 2daf15f..638b409 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -182,6 +182,46 @@ describe('JSON.stringify', () => { ); }); + it('calls a function replacer with the holder as this', () => { + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? Object.keys(this) : v; });' + ) + ).toBe('[""]'); + expect( + run( + 'const o = { a: 1 }; return JSON.stringify(o, function (k, v) { return k === "a" ? this === o : v; });' + ) + ).toBe('{"a":true}'); + expect( + run('return JSON.stringify([1, 2], function (k, v) { return k === "" ? v : this.length; });') + ).toBe('[2,2]'); + expect( + run( + 'return JSON.stringify({ a: 1 }, function (k, v) { return k === "" ? { b: 2 } : this.b; });' + ) + ).toBe('{"b":2}'); + expect( + run( + 'return JSON.stringify({ when: new Date(0) }, function (k, v) { return this[k] instanceof Date ? this[k].getTime() : v; });' + ) + ).toBe('{"when":0}'); + expect( + run('const o = { f() { return JSON.stringify([1], () => this === o); } }; return o.f();') + ).toBe('true'); + }); + + it('keeps a replacer inside the sandbox rules', () => { + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.constructor; });') + .sandboxError?.name + ).toBe('TypeError'); + expect( + runAndCatch(maple, 'JSON.stringify({}, function () { return this.__proto__; });').sandboxError + ?.name + ).toBe('TypeError'); + }); + it("writes an array replacer's keys in its order, own properties only", () => { expect(run('return JSON.stringify({ a: 1, b: 2 }, ["b", "a", "b"]);')).toBe('{"b":2,"a":1}'); expect(run('return JSON.stringify({ a: 1 }, ["a", "missing", "toString"]);')).toBe('{"a":1}'); diff --git a/packages/maple/src/builtins/json/statics/stringify.ts b/packages/maple/src/builtins/json/statics/stringify.ts index 3250f8f..b57d56d 100644 --- a/packages/maple/src/builtins/json/statics/stringify.ts +++ b/packages/maple/src/builtins/json/statics/stringify.ts @@ -1,5 +1,6 @@ import { isPlainObject } from '../../../support/is-plain-object.ts'; import { MAX_NESTING } from '../../../support/nesting-limit.ts'; +import { defineOwn } from '../../../support/own-property.ts'; import { getOwnPropertiesOf } from '../../../values/members.ts'; import { type CallContext, isCallable, NativeFunction } from '../../../values/native.ts'; import { SandboxObject } from '../../../values/sandbox-object.ts'; @@ -16,7 +17,7 @@ interface PropertyView { interface Serialization { readonly gap: string; readonly allowed: ReadonlySet | undefined; - readonly replace: ((key: string, current: unknown) => unknown) | undefined; + readonly replace: ((holder: object, key: string, current: unknown) => unknown) | undefined; readonly ancestors: Array; readonly context: CallContext; } @@ -28,14 +29,18 @@ interface Serialization { export const JSON_STATIC_STRINGIFY: NativeFunction = new NativeFunction( 'stringify', JSON.stringify.length, - (_, [value, replacer, space], context) => - serialize('', value, '', { + (_, [value, replacer, space], context) => { + // The value is serialized as the property '' of a fresh object, which a replacer sees as this + const root: Record = {}; + defineOwn(root, '', value); + return serialize(root, '', value, '', { gap: gapOf(space), allowed: allowedKeys(replacer), replace: replacerOf(replacer, context), ancestors: [], context - }) + }); + } ); /** @@ -69,17 +74,19 @@ function allowedKeys(replacer: unknown): ReadonlySet | undefined { return keys; } -/** A function replacer, which runs as a script callback. */ +/** A function replacer, which runs as a script callback with the holder as `this`. */ function replacerOf( replacer: unknown, context: CallContext -): ((key: string, current: unknown) => unknown) | undefined { +): ((holder: object, key: string, current: unknown) => unknown) | undefined { return isCallable(replacer) - ? (key, current) => context.call(undefined, replacer, [key, current]) + ? (holder, key, current) => context.call(holder, replacer, [key, current]) : undefined; } +/** `holder` is the object or array whose property `key` is, as the replacer sees it. */ function serialize( + holder: object, key: string, raw: unknown, indent: string, @@ -94,7 +101,7 @@ function serialize( ? null : raw.date.toISOString() : raw; - const current = replace ? replace(key, json) : json; + const current = replace ? replace(holder, key, json) : json; if (current === null) { return 'null'; @@ -143,7 +150,7 @@ function serialize( const parts: Array = []; const length = current.length; for (let index = 0; index < length; index++) { - parts.push(serialize(String(index), current[index], inner, serialization) ?? 'null'); + parts.push(serialize(current, String(index), current[index], inner, serialization) ?? 'null'); } result = wrap('[', ']', parts, gap, indent, inner); } else { @@ -176,7 +183,13 @@ function serialize( ? [...allowed].filter((propertyKey) => properties.has(propertyKey)) : properties.keys(); for (const propertyKey of keys) { - const serialized = serialize(propertyKey, properties.get(propertyKey), inner, serialization); + const serialized = serialize( + current, + propertyKey, + properties.get(propertyKey), + inner, + serialization + ); if (serialized !== undefined) { parts.push(`${JSON.stringify(propertyKey)}:${gap ? ' ' : ''}${serialized}`); } diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index b850846..ed37e1e 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -427,7 +427,6 @@ describe('escape attempts', () => { '"aa".replaceAll("a", cb)', 'Object.groupBy([1], cb)', 'Map.groupBy([1], cb)', - 'JSON.stringify({ a: 1 }, function (key, value) { seen.push(this); return value; })', 'Array.from([1], cb)', 'Array.from({ length: 1 }, cb)', '[1].map(cb)', From 8cc9c43fdca1bac36ef109b03942a279dccb2dde Mon Sep 17 00:00:00 2001 From: NetWin Date: Tue, 20 Oct 2026 22:01:13 +0200 Subject: [PATCH 5/5] docs(contributing): update conventional commit instructions --- AGENTS.md | 5 +++-- CONTRIBUTING.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8301947..f3122ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,9 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence - Use kebab-case for file names. - Use `#private` for state fields only, and spell out `public` / `protected` / `private` on all other members. -- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope: the package, such as `maple`, or `*` for - the whole repository. The version and changelog come from them, so never edit `version` in +- Use [Conventional Commits](https://www.conventionalcommits.org) with a scope naming the area the commit changes, such + as `evaluator`, `json`, `playground` or `parity`, or `*` for the whole repository. Older commits name the package + (`maple`); new ones name the area. The version and changelog come from them, so never edit `version` in `packages/maple/package.json` or `CHANGELOG.md` by hand. - Don't add attribution to commits or pull requests: no `Co-Authored-By:` or other trailers, no "Generated with" lines and no session links. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9a2cd..2c24606 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,8 @@ It runs `test` on Node 22, 24 and 26: the minimum from the library's `engines` f ## Commits & releases 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. +Every commit needs a scope: the area it changes, such as `evaluator`, `boundary`, `builtins` or `json` in the library, `playground` or `parity`, or `*` when it spans the repository. +Older commits name the library's package, `maple`, instead; new ones name the area, so the changelog says what changed. 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.