From 02afa86fcaeeefab40805b0fdec82c56cd1f6e1b Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Sat, 13 Sep 2025 19:35:45 -0700 Subject: [PATCH 1/2] feat(patterns): helper for interface guard inheritance --- packages/exo/src/exo-tools.js | 2 +- packages/exo/test/exo-wobbly-point.test.js | 51 +++++++++++++++++-- packages/patterns/NEWS.md | 9 ++++ packages/patterns/index.js | 1 + .../patterns/src/patterns/getGuardPayloads.js | 27 +++++++++- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/exo/src/exo-tools.js b/packages/exo/src/exo-tools.js index 9667279216..7873c063bd 100644 --- a/packages/exo/src/exo-tools.js +++ b/packages/exo/src/exo-tools.js @@ -384,7 +384,7 @@ export const defendPrototype = ( methodGuards: mg, symbolMethodGuards, sloppy, - defaultGuards: dg = sloppy ? 'passable' : defaultGuards, + defaultGuards: dg = sloppy ? 'passable' : undefined, } = getInterfaceGuardPayload(interfaceGuard); methodGuards = harden({ ...mg, diff --git a/packages/exo/test/exo-wobbly-point.test.js b/packages/exo/test/exo-wobbly-point.test.js index 1f47e7b23e..521f170230 100644 --- a/packages/exo/test/exo-wobbly-point.test.js +++ b/packages/exo/test/exo-wobbly-point.test.js @@ -11,7 +11,7 @@ import test from '@endo/ses-ava/prepare-endo.js'; import { getMethodNames } from '@endo/eventual-send/utils.js'; import { passStyleOf, Far, GET_METHOD_NAMES } from '@endo/pass-style'; -import { M } from '@endo/patterns'; +import { M, getNamedMethodGuards } from '@endo/patterns'; import { Fail, q } from '@endo/errors'; import { GET_INTERFACE_GUARD } from '../src/get-interface.js'; @@ -41,6 +41,9 @@ const defineExoClassFromJSClass = klass => harden(defineExoClassFromJSClass); const ExoPointI = M.interface('ExoPoint', { + // Just following the interface inheritance pattern even though it is + // literally vacuous in this case. + ...getNamedMethodGuards(ExoBaseClass.implements), toString: M.call().returns(M.string()), getX: M.call().returns(M.gte(0)), getY: M.call().returns(M.number()), @@ -136,18 +139,53 @@ test('ExoPoint instances', t => { }); }); +class ExoWobblyPointBad extends ExoPoint { + static init(x, y, getWobble) { + return { ...super.init(x, y), getWobble }; + } + + getX() { + // @ts-expect-error Property 'state' does not exist on type 'ExoPoint'. + const { getWobble } = this.state; + return super.getX() + getWobble(); + } + + // Made `wobble` public just to test interface inheritance + wobble() { + // @ts-expect-error Property 'state' does not exist on type 'ExoPoint'. + const { getWobble } = this.state; + return getWobble(); + } +} +harden(ExoWobblyPointBad); + +test('test need for interface extension', t => { + t.throws(() => defineExoClassFromJSClass(ExoWobblyPointBad), { + message: 'methods ["wobble"] not guarded by "ExoPoint"', + }); +}); + class ExoWobblyPoint extends ExoPoint { + static implements = M.interface('ExoWobblyPointI', { + ...getNamedMethodGuards(ExoPoint.implements), + wobble: M.call().returns(M.gte(0)), + }); + static init(x, y, getWobble) { return { ...super.init(x, y), getWobble }; } getX() { - const { - // @ts-expect-error Property 'state' does not exist on type 'ExoPoint'. - state: { getWobble }, - } = this; + // @ts-expect-error Property 'state' does not exist on type 'ExoPoint'. + const { getWobble } = this.state; return super.getX() + getWobble(); } + + wobble() { + // @ts-expect-error Property 'state' does not exist on type 'ExoPoint'. + const { getWobble } = this.state; + return getWobble(); + } } harden(ExoWobblyPoint); @@ -170,12 +208,15 @@ test('FarWobblyPoint inheritance', t => { 'getY', 'setY', 'toString', + 'wobble', ]); t.is(`${wpt}`, '<4,5>'); t.is(`${wpt}`, '<5,5>'); t.is(`${wpt}`, '<6,5>'); wpt.setY(6); t.is(`${wpt}`, '<7,6>'); + t.is(wpt.wobble(), 5); + t.is(`${wpt}`, '<9,6>'); const otherPt = makeExoPoint(1, 2); t.false(otherPt instanceof ExoWobblyPoint); diff --git a/packages/patterns/NEWS.md b/packages/patterns/NEWS.md index 62e75abef9..6038439380 100644 --- a/packages/patterns/NEWS.md +++ b/packages/patterns/NEWS.md @@ -3,6 +3,15 @@ User-visible changes in `@endo/patterns`: # Next release - The `sloppy` option for `@endo/patterns` interface guards is deprecated. Use `defaultGuards` instead. +- `@endo/patterns` now exports a new `getNamedMethodGuards(interfaceGuards)` that returns that interface guard's record of method guards. The motivation is to support interface inheritance expressed by patterns like + ```js + const I2 = M.interface('I2', { + ...getNamedMethodGuards(I1), + doMore: M.call().returns(M.any()), + }); + ``` + See `@endo/exo`'s `exo-wobbly-point.test.js` to see it in action together + with an experiment in class inheritance. # 1.7.0 (2025-07-11) diff --git a/packages/patterns/index.js b/packages/patterns/index.js index 781c0eb494..f1d1f111ac 100644 --- a/packages/patterns/index.js +++ b/packages/patterns/index.js @@ -73,6 +73,7 @@ export { getMethodGuardPayload, getInterfaceGuardPayload, getInterfaceMethodKeys, + getNamedMethodGuards, } from './src/patterns/getGuardPayloads.js'; // eslint-disable-next-line import/export diff --git a/packages/patterns/src/patterns/getGuardPayloads.js b/packages/patterns/src/patterns/getGuardPayloads.js index 4163db4b26..d1fa0718c6 100644 --- a/packages/patterns/src/patterns/getGuardPayloads.js +++ b/packages/patterns/src/patterns/getGuardPayloads.js @@ -234,7 +234,7 @@ const adaptMethodGuard = methodGuard => { * we smooth the transition to https://github.com/endojs/endo/pull/1712, * tolerating both the legacy and current guard shapes. * - * Unlike LegacyAwaitArgGuardShape, tolerating LegacyInterfaceGuardShape + * Unlike `LegacyAwaitArgGuardShape`, tolerating `LegacyInterfaceGuardShape` * does not seem like a currently exploitable bug, because there is not * currently any context where either an interfaceGuard or a copyRecord would * both be meaningful. @@ -284,3 +284,28 @@ export const getInterfaceMethodKeys = interfaceGuard => { ]); }; harden(getInterfaceMethodKeys); + +// Tested in @endo/exo by exo-wobbly-point.test.js since that's already +// about class inheritance, which naturally goes with interface +// inheritance. +/** + * This ignores symbol-named method guards (which cannot be represented + * directly in a `CopyRecord` anyway). It returns only a `CopyRecord` of + * the string-named method guards. This is useful for interface guard + * inheritance patterns like + * ```js + * const I2 = M.interface('I2', { + * ...getNamedMethodGuards(I1), + * doMore: M.call().returns(M.any()), + * }); + * ``` + * While we could do more to support symbol-named method guards, + * this feature is deprecated, and hopefully will disappear soon. + * (TODO link to PRs removing symbol-named methods and method guards.) + * + * @template {Record} [T=Record] + * @param {InterfaceGuard} interfaceGuard + */ +export const getNamedMethodGuards = interfaceGuard => + getInterfaceGuardPayload(interfaceGuard).methodGuards; +harden(getNamedMethodGuards); From d3c67f58e83864ea6aa3d75b69368051bc8b8918 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Wed, 17 Sep 2025 16:38:03 -0700 Subject: [PATCH 2/2] fixup: typo --- packages/patterns/NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/patterns/NEWS.md b/packages/patterns/NEWS.md index 6038439380..bd5e2e88ad 100644 --- a/packages/patterns/NEWS.md +++ b/packages/patterns/NEWS.md @@ -3,7 +3,7 @@ User-visible changes in `@endo/patterns`: # Next release - The `sloppy` option for `@endo/patterns` interface guards is deprecated. Use `defaultGuards` instead. -- `@endo/patterns` now exports a new `getNamedMethodGuards(interfaceGuards)` that returns that interface guard's record of method guards. The motivation is to support interface inheritance expressed by patterns like +- `@endo/patterns` now exports a new `getNamedMethodGuards(interfaceGuard)` that returns that interface guard's record of method guards. The motivation is to support interface inheritance expressed by patterns like ```js const I2 = M.interface('I2', { ...getNamedMethodGuards(I1),