diff --git a/packages/exo/index.js b/packages/exo/index.js index 571fdfedff..0936e295c7 100644 --- a/packages/exo/index.js +++ b/packages/exo/index.js @@ -5,4 +5,4 @@ export { makeExo, } from './src/exo-makers.js'; -export { GET_INTERFACE_GUARD } from './src/exo-tools.js'; +export { GET_INTERFACE_GUARD } from './src/get-interface.js'; diff --git a/packages/exo/src/exo-makers.js b/packages/exo/src/exo-makers.js index 5d2ca8a8c9..a8e7325027 100644 --- a/packages/exo/src/exo-makers.js +++ b/packages/exo/src/exo-makers.js @@ -13,6 +13,12 @@ const DEBUG = getEnvironmentOption('DEBUG', ''); // Turn on to give each exo instance its own toStringTag value. const LABEL_INSTANCES = DEBUG.split(',').includes('label-instances'); +/** + * @template {{}} T + * @param {T} proto + * @param {number} instanceCount + * @returns {T} + */ const makeSelf = (proto, instanceCount) => { const self = create(proto); if (LABEL_INSTANCES) { @@ -82,6 +88,21 @@ export const initEmpty = () => emptyRecord; * @property {ReceiveRevoker} [receiveRevoker] */ +/** + * @template {Methods} M + * @typedef {M & import('@endo/eventual-send').RemotableBrand<{}, M>} Farable + */ + +/** + * @template {Methods} M + * @typedef {Farable>} Guarded + */ + +/** + * @template {Record} F + * @typedef {{ [K in keyof F]: Guarded }} GuardedKit + */ + /** * @template {(...args: any[]) => any} I init function * @template {Methods} M methods @@ -90,9 +111,9 @@ export const initEmpty = () => emptyRecord; * [K in keyof M]: import("@endo/patterns").MethodGuard * }> | undefined} interfaceGuard * @param {I} init - * @param {M & ThisType<{ self: M, state: ReturnType }>} methods + * @param {M & ThisType<{ self: Guarded, state: ReturnType }>} methods * @param {FarClassOptions, M>>} [options] - * @returns {(...args: Parameters) => (M & import('@endo/eventual-send').RemotableBrand<{}, M>)} + * @returns {(...args: Parameters) => Guarded} */ export const defineExoClass = ( tag, @@ -120,7 +141,6 @@ export const defineExoClass = ( // Be careful not to freeze the state record const state = seal(init(...args)); instanceCount += 1; - /** @type {M} */ const self = makeSelf(proto, instanceCount); // Be careful not to freeze the state record @@ -130,9 +150,7 @@ export const defineExoClass = ( if (finish) { finish(context); } - return /** @type {M & import('@endo/eventual-send').RemotableBrand<{}, M>} */ ( - self - ); + return self; }; if (receiveRevoker) { @@ -149,13 +167,13 @@ harden(defineExoClass); * @template {(...args: any[]) => any} I init function * @template {Record} F facet methods * @param {string} tag - * @param {{ [K in keyof F]: import("@endo/patterns").InterfaceGuard<{ - * [M in keyof F[K]]: import("@endo/patterns").MethodGuard; - * }> } | undefined} interfaceGuardKit + * @param {{ [K in keyof F]: + * InterfaceGuard<{[M in keyof F[K]]: MethodGuard; }> + * } | undefined} interfaceGuardKit * @param {I} init - * @param {F & ThisType<{ facets: F, state: ReturnType }> } methodsKit - * @param {FarClassOptions,F>>} [options] - * @returns {(...args: Parameters) => F} + * @param {F & { [K in keyof F]: ThisType<{ facets: GuardedKit, state: ReturnType }> }} methodsKit + * @param {FarClassOptions, GuardedKit>>} [options] + * @returns {(...args: Parameters) => GuardedKit} */ export const defineExoClassKit = ( tag, @@ -186,8 +204,8 @@ export const defineExoClassKit = ( // Be careful not to freeze the state record const state = seal(init(...args)); // Don't freeze context until we add facets - /** @type {KitContext,F>} */ - const context = { state, facets: {} }; + /** @type {{ state: ReturnType, facets: unknown }} */ + const context = { state, facets: null }; instanceCount += 1; const facets = objectMap(prototypeKit, (proto, facetName) => { const self = makeSelf(proto, instanceCount); @@ -200,7 +218,7 @@ export const defineExoClassKit = ( if (finish) { finish(context); } - return context.facets; + return /** @type {GuardedKit} */ (context.facets); }; if (receiveRevoker) { @@ -222,7 +240,7 @@ harden(defineExoClassKit); * }> | undefined} interfaceGuard CAVEAT: static typing does not yet support `callWhen` transformation * @param {T} methods * @param {FarClassOptions>} [options] - * @returns {T & import('@endo/eventual-send').RemotableBrand<{}, T>} + * @returns {Guarded} */ export const makeExo = (tag, interfaceGuard, methods, options = undefined) => { const makeInstance = defineExoClass( diff --git a/packages/exo/src/exo-tools.js b/packages/exo/src/exo-tools.js index e17d7ba2e7..206ea77fa9 100644 --- a/packages/exo/src/exo-tools.js +++ b/packages/exo/src/exo-tools.js @@ -6,52 +6,96 @@ import { mustMatch, M, isAwaitArgGuard, + isRawGuard, getAwaitArgGuardPayload, getMethodGuardPayload, getInterfaceGuardPayload, getCopyMapEntries, } from '@endo/patterns'; +import { GET_INTERFACE_GUARD } from './get-interface.js'; + /** @typedef {import('@endo/patterns').Method} Method */ /** @typedef {import('@endo/patterns').MethodGuard} MethodGuard */ +/** @typedef {import('@endo/patterns').MethodGuardPayload} MethodGuardPayload */ /** * @template {Record} [T=Record] * @typedef {import('@endo/patterns').InterfaceGuard} InterfaceGuard */ -/** @typedef {import('@endo/patterns').InterfaceGuardKit} InterfaceGuardKit */ const { quote: q, Fail } = assert; const { apply, ownKeys } = Reflect; const { defineProperties, fromEntries } = Object; +/** + * A method guard, for inclusion in an interface guard, that does not + * enforce any constraints of incoming arguments or return results. + */ +const RawMethodGuard = M.call().rest(M.raw()).returns(M.raw()); + +const REDACTED_RAW_ARG = ''; + /** * A method guard, for inclusion in an interface guard, that enforces only that * all arguments are passable and that the result is passable. (In far classes, - * "any" means any *passable*.) This is the least possible enforcement for a - * method guard, and is implied by all other method guards. + * "any" means any *passable*.) This is the least possible non-raw + * enforcement for a method guard, and is implied by all other + * non-raw method guards. + */ +const PassableMethodGuard = M.call().rest(M.any()).returns(M.any()); + +/** + * @typedef {object} MatchConfig + * @property {number} declaredLen + * @property {boolean} hasRestArgGuard + * @property {boolean} restArgGuardIsRaw + * @property {Pattern} paramsPattern + * @property {number[]} redactedIndices */ -const MinMethodGuard = M.call().rest(M.any()).returns(M.any()); /** * @param {Passable[]} syncArgs - * @param {MethodGuardPayload} methodGuardPayload + * @param {MatchConfig} matchConfig * @param {string} [label] * @returns {Passable[]} Returns the args that should be passed to the * raw method */ -const defendSyncArgs = (syncArgs, methodGuardPayload, label = undefined) => { - const { argGuards, optionalArgGuards, restArgGuard } = methodGuardPayload; - const paramsPattern = M.splitArray( - argGuards, - optionalArgGuards, - restArgGuard, - ); - mustMatch(harden(syncArgs), paramsPattern, label); - if (restArgGuard !== undefined) { +const defendSyncArgs = (syncArgs, matchConfig, label = undefined) => { + const { + declaredLen, + hasRestArgGuard, + restArgGuardIsRaw, + paramsPattern, + redactedIndices, + } = matchConfig; + + // Use syncArgs if possible, but copy it when necessary to implement redactions. + let matchableArgs = syncArgs; + if (restArgGuardIsRaw && syncArgs.length > declaredLen) { + const restLen = syncArgs.length - declaredLen; + const redactedRest = Array(restLen).fill(REDACTED_RAW_ARG); + matchableArgs = [...syncArgs.slice(0, declaredLen), ...redactedRest]; + } else if ( + redactedIndices.length > 0 && + redactedIndices[0] < syncArgs.length + ) { + // Copy the arguments array, avoiding hardening the redacted ones (which are + // trivially matched using REDACTED_RAW_ARG as a sentinel value). + matchableArgs = [...syncArgs]; + } + + for (const i of redactedIndices) { + if (i >= matchableArgs.length) { + break; + } + matchableArgs[i] = REDACTED_RAW_ARG; + } + + mustMatch(harden(matchableArgs), paramsPattern, label); + + if (hasRestArgGuard) { return syncArgs; } - const declaredLen = - argGuards.length + (optionalArgGuards ? optionalArgGuards.length : 0); if (syncArgs.length <= declaredLen) { return syncArgs; } @@ -59,6 +103,60 @@ const defendSyncArgs = (syncArgs, methodGuardPayload, label = undefined) => { return syncArgs.slice(0, declaredLen); }; +/** + * Convert a method guard to a match config for more efficient per-call + * execution. This is a one-time conversion, so it's OK to be slow. + * + * Most of the work is done to detect `M.raw()` so that we build a match pattern + * and metadata instead of doing this in the hot path. + * @param {MethodGuardPayload} methodGuardPayload + * @returns {MatchConfig} + */ +const buildMatchConfig = methodGuardPayload => { + const { + argGuards, + optionalArgGuards = [], + restArgGuard, + } = methodGuardPayload; + + const matchableArgGuards = [...argGuards, ...optionalArgGuards]; + + const redactedIndices = []; + for (let i = 0; i < matchableArgGuards.length; i += 1) { + if (isRawGuard(matchableArgGuards[i])) { + matchableArgGuards[i] = REDACTED_RAW_ARG; + redactedIndices.push(i); + } + } + + // Pass through raw rest arguments without matching. + let matchableRestArgGuard = restArgGuard; + if (isRawGuard(matchableRestArgGuard)) { + matchableRestArgGuard = M.arrayOf(REDACTED_RAW_ARG); + } + const matchableMethodGuardPayload = harden({ + ...methodGuardPayload, + argGuards: matchableArgGuards.slice(0, argGuards.length), + optionalArgGuards: matchableArgGuards.slice(argGuards.length), + restArgGuard: matchableRestArgGuard, + }); + + const paramsPattern = M.splitArray( + matchableMethodGuardPayload.argGuards, + matchableMethodGuardPayload.optionalArgGuards, + matchableMethodGuardPayload.restArgGuard, + ); + + return harden({ + declaredLen: matchableArgGuards.length, + hasRestArgGuard: restArgGuard !== undefined, + restArgGuardIsRaw: restArgGuard !== matchableRestArgGuard, + paramsPattern, + redactedIndices, + matchableMethodGuardPayload, + }); +}; + /** * @param {Method} method * @param {MethodGuardPayload} methodGuardPayload @@ -67,16 +165,17 @@ const defendSyncArgs = (syncArgs, methodGuardPayload, label = undefined) => { */ const defendSyncMethod = (method, methodGuardPayload, label) => { const { returnGuard } = methodGuardPayload; + const isRawReturn = isRawGuard(returnGuard); + const matchConfig = buildMatchConfig(methodGuardPayload); const { syncMethod } = { // Note purposeful use of `this` and concise method syntax syncMethod(...syncArgs) { - const realArgs = defendSyncArgs( - harden(syncArgs), - methodGuardPayload, - label, - ); + // Only harden args and return value if not dealing with a raw value guard. + const realArgs = defendSyncArgs(syncArgs, matchConfig, label); const result = apply(method, this, realArgs); - mustMatch(harden(result), returnGuard, `${label}: result`); + if (!isRawReturn) { + mustMatch(harden(result), returnGuard, `${label}: result`); + } return result; }, }; @@ -184,6 +283,7 @@ const defendMethod = (method, methodGuard, label) => { * @param {CallableFunction} behaviorMethod * @param {boolean} [thisfulMethods] * @param {MethodGuard} [methodGuard] + * @param {import('@endo/patterns').DefaultGuardType} [defaultGuards] */ const bindMethod = ( methodTag, @@ -191,6 +291,7 @@ const bindMethod = ( behaviorMethod, thisfulMethods = false, methodGuard = undefined, + defaultGuards = undefined, ) => { assert.typeof(behaviorMethod, 'function'); @@ -226,12 +327,23 @@ const bindMethod = ( return apply(behaviorMethod, null, [context, ...args]); }, }; + if (!methodGuard && thisfulMethods) { + switch (defaultGuards) { + case undefined: + case 'passable': + methodGuard = PassableMethodGuard; + break; + case 'raw': + methodGuard = RawMethodGuard; + break; + default: + throw Fail`Unrecognized defaultGuards ${q(defaultGuards)}`; + } + } if (methodGuard) { method = defendMethod(method, methodGuard, methodTag); - } else if (thisfulMethods) { - // For far classes ensure that inputs and outputs are passable. - method = defendMethod(method, MinMethodGuard, methodTag); } + defineProperties(method, { name: { value: methodTag }, length: { @@ -241,24 +353,12 @@ const bindMethod = ( return method; }; -/** - * The name of the automatically added default meta-method for - * obtaining an exo's interface, if it has one. - * - * TODO Name to be bikeshed. Perhaps even whether it is a - * string or symbol to be bikeshed. - * - * TODO Beware that an exo's interface can change across an upgrade, - * so remotes that cache it can become stale. - */ -export const GET_INTERFACE_GUARD = Symbol.for('getInterfaceGuard'); - /** * * @template {Record} T * @param {T} behaviorMethods * @param {InterfaceGuard<{ [M in keyof T]: MethodGuard }>} interfaceGuard - * @returns {T} + * @returns {T & import('./get-interface.js').GetInterfaceGuard} */ const withGetInterfaceGuardMethod = (behaviorMethods, interfaceGuard) => harden({ @@ -275,7 +375,6 @@ const withGetInterfaceGuardMethod = (behaviorMethods, interfaceGuard) => * @param {T} behaviorMethods * @param {boolean} [thisfulMethods] * @param {InterfaceGuard<{ [M in keyof T]: MethodGuard }>} [interfaceGuard] - * @returns {T & import('@endo/eventual-send').RemotableBrand<{}, T>} */ export const defendPrototype = ( tag, @@ -294,25 +393,30 @@ export const defendPrototype = ( } /** @type {Record | undefined} */ let methodGuards; + /** @type {import('@endo/patterns').DefaultGuardType} */ + let defaultGuards; if (interfaceGuard) { const { interfaceName, methodGuards: mg, symbolMethodGuards, - sloppy = false, + sloppy, + defaultGuards: dg = sloppy ? 'passable' : defaultGuards, } = getInterfaceGuardPayload(interfaceGuard); methodGuards = harden({ ...mg, ...(symbolMethodGuards && fromEntries(getCopyMapEntries(symbolMethodGuards))), }); + defaultGuards = dg; { const methodNames = ownKeys(behaviorMethods); + assert(methodGuards); const methodGuardNames = ownKeys(methodGuards); const unimplemented = listDifference(methodGuardNames, methodNames); unimplemented.length === 0 || Fail`methods ${q(unimplemented)} not implemented by ${q(tag)}`; - if (!sloppy) { + if (defaultGuards === undefined) { const unguarded = listDifference(methodNames, methodGuardNames); unguarded.length === 0 || Fail`methods ${q(unguarded)} not guarded by ${q(interfaceName)}`; @@ -323,7 +427,6 @@ export const defendPrototype = ( interfaceGuard, ); } - for (const prop of ownKeys(behaviorMethods)) { prototype[prop] = bindMethod( `In ${q(prop)} method of (${tag})`, @@ -332,19 +435,26 @@ export const defendPrototype = ( thisfulMethods, // TODO some tool does not yet understand the `?.[` syntax methodGuards && methodGuards[prop], + defaultGuards, ); } - return Far(tag, /** @type {T} */ (prototype)); + return Far( + tag, + /** @type {T & import('./get-interface.js').GetInterfaceGuard} */ ( + prototype + ), + ); }; harden(defendPrototype); /** + * @template {Record} F * @param {string} tag - * @param {Record} contextProviderKit - * @param {Record>} behaviorMethodsKit + * @param {{ [K in keyof F]: KitContextProvider }} contextProviderKit + * @param {F} behaviorMethodsKit * @param {boolean} [thisfulMethods] - * @param {InterfaceGuardKit} [interfaceGuardKit] + * @param {{ [K in keyof F]: InterfaceGuard> }} [interfaceGuardKit] */ export const defendPrototypeKit = ( tag, @@ -371,13 +481,14 @@ export const defendPrototypeKit = ( const extraFacetNames = listDifference(contextMapNames, facetNames); extraFacetNames.length === 0 || Fail`Facets ${q(extraFacetNames)} of ${q(tag)} missing contexts`; - return objectMap(behaviorMethodsKit, (behaviorMethods, facetName) => + const protoKit = objectMap(behaviorMethodsKit, (behaviorMethods, facetName) => defendPrototype( - `${tag} ${facetName}`, + `${tag} ${String(facetName)}`, contextProviderKit[facetName], behaviorMethods, thisfulMethods, interfaceGuardKit && interfaceGuardKit[facetName], ), ); + return protoKit; }; diff --git a/packages/exo/src/get-interface.js b/packages/exo/src/get-interface.js new file mode 100644 index 0000000000..9fd4baf9d0 --- /dev/null +++ b/packages/exo/src/get-interface.js @@ -0,0 +1,22 @@ +// @ts-check + +/** + * The name of the automatically added default meta-method for + * obtaining an exo's interface, if it has one. + * + * TODO Name to be bikeshed. Perhaps even whether it is a + * string or symbol to be bikeshed. + * + * TODO Beware that an exo's interface can change across an upgrade, + * so remotes that cache it can become stale. + */ +export const GET_INTERFACE_GUARD = Symbol.for('getInterfaceGuard'); + +/** + * @template {Record} M + * @typedef {{ + * [GET_INTERFACE_GUARD]: () => import('@endo/patterns').InterfaceGuard<{ + * [K in keyof M]: import('@endo/patterns').MethodGuard + * }> + * }} GetInterfaceGuard + */ diff --git a/packages/exo/test/test-heap-classes.js b/packages/exo/test/test-heap-classes.js index 8f5e753a92..a5ef1346a5 100644 --- a/packages/exo/test/test-heap-classes.js +++ b/packages/exo/test/test-heap-classes.js @@ -1,14 +1,15 @@ +// @ts-check // eslint-disable-next-line import/order import { test } from './prepare-test-env-ava.js'; // eslint-disable-next-line import/order import { getInterfaceMethodKeys, M } from '@endo/patterns'; import { + GET_INTERFACE_GUARD, defineExoClass, defineExoClassKit, makeExo, -} from '../src/exo-makers.js'; -import { GET_INTERFACE_GUARD } from '../src/exo-tools.js'; +} from '../index.js'; const NoExtraI = M.interface('NoExtra', { foo: M.call().returns(), @@ -77,6 +78,7 @@ test('test defineExoClass', t => { }); const foo = makeFoo(); t.deepEqual(foo[GET_INTERFACE_GUARD](), FooI); + // @ts-expect-error intentional for test t.throws(() => foo[symbolic]('invalid arg'), { message: 'In "[Symbol(symbolic)]" method of (Foo): arg 0: string "invalid arg" - Must be a boolean', @@ -203,6 +205,115 @@ test('sloppy option', t => { ); }); +const makeBehavior = () => ({ + behavior() { + return 'something'; + }, +}); + +const PassableGreeterI = M.interface( + 'greeter', + {}, + { defaultGuards: 'passable' }, +); +test('passable guards', t => { + const greeter = makeExo('greeter', PassableGreeterI, { + sayHello(immutabe) { + t.is(Object.isFrozen(immutabe), true); + return 'hello'; + }, + }); + + const mutable = {}; + t.is(greeter.sayHello(mutable), 'hello', `passableGreeter can sayHello`); + t.is(Object.isFrozen(mutable), true, `mutable is frozen`); + t.throws(() => greeter.sayHello(makeBehavior()), { + message: + /In "sayHello" method of \(greeter\): Remotables must be explicitly declared/, + }); +}); + +const RawGreeterI = M.interface('greeter', {}, { defaultGuards: 'raw' }); + +const testGreeter = (t, greeter, msg) => { + const mutable = {}; + t.is(greeter.sayHello(mutable), 'hello', `${msg} can sayHello`); + t.deepEqual(mutable, { x: 3 }, `${msg} mutable is mutated`); + mutable.y = 4; + t.deepEqual(mutable, { x: 3, y: 4 }, `${msg} mutable is mutated again}`); +}; + +test('raw guards', t => { + const greeter = makeExo('greeter', RawGreeterI, { + sayHello(mutable) { + mutable.x = 3; + return 'hello'; + }, + }); + t.deepEqual(greeter[GET_INTERFACE_GUARD](), RawGreeterI); + testGreeter(t, greeter, 'raw defaultGuards'); + + const Greeter2I = M.interface('greeter2', { + sayHello: M.call(M.raw()).returns(M.string()), + rawIn: M.call(M.raw()).returns(M.any()), + rawOut: M.call(M.any()).returns(M.raw()), + passthrough: M.call(M.raw()).returns(M.raw()), + tortuous: M.call(M.any(), M.raw(), M.any()) + .optional(M.any(), M.raw()) + .returns(M.any()), + }); + const greeter2 = makeExo('greeter2', Greeter2I, { + sayHello(mutable) { + mutable.x = 3; + return 'hello'; + }, + rawIn(obj) { + t.is(Object.isFrozen(obj), false); + return obj; + }, + rawOut(obj) { + t.is(Object.isFrozen(obj), true); + return { ...obj }; + }, + passthrough(obj) { + t.is(Object.isFrozen(obj), false); + return obj; + }, + tortuous(hardA, softB, hardC, optHardD, optSoftE = {}) { + // Test that `M.raw()` does not freeze the arguments, unlike `M.any()`. + t.is(Object.isFrozen(hardA), true); + t.is(Object.isFrozen(softB), false); + softB.b = 2; + t.is(Object.isFrozen(hardC), true); + t.is(Object.isFrozen(optHardD), true); + t.is(Object.isFrozen(optSoftE), false); + return {}; + }, + }); + t.deepEqual(greeter2[GET_INTERFACE_GUARD](), Greeter2I); + testGreeter(t, greeter, 'explicit raw'); + + t.is(Object.isFrozen(greeter2.rawIn({})), true); + t.is(Object.isFrozen(greeter2.rawOut({})), false); + t.is(Object.isFrozen(greeter2.passthrough({})), false); + + t.is(Object.isFrozen(greeter2.tortuous({}, {}, {}, {}, {})), true); + t.is(Object.isFrozen(greeter2.tortuous({}, {}, {})), true); + + t.throws( + () => greeter2.tortuous(makeBehavior(), {}, {}), + { + message: + /In "tortuous" method of \(greeter2\): Remotables must be explicitly declared/, + }, + 'passable behavior not allowed', + ); + t.notThrows( + () => greeter2.tortuous({}, makeBehavior(), {}), + 'raw behavior allowed', + ); +}); + const GreeterI = M.interface('greeter', { sayHello: M.call().returns('hello'), }); diff --git a/packages/patterns/index.js b/packages/patterns/index.js index 7b3d6ff1ab..9bd5dbb5f5 100644 --- a/packages/patterns/index.js +++ b/packages/patterns/index.js @@ -61,6 +61,8 @@ export { isAwaitArgGuard, assertAwaitArgGuard, getAwaitArgGuardPayload, + isRawGuard, + assertRawGuard, assertMethodGuard, getMethodGuardPayload, getInterfaceMethodKeys, diff --git a/packages/patterns/src/patterns/internal-types.js b/packages/patterns/src/patterns/internal-types.js index 072cac4fe7..ceb3ec4085 100644 --- a/packages/patterns/src/patterns/internal-types.js +++ b/packages/patterns/src/patterns/internal-types.js @@ -21,8 +21,10 @@ /** @typedef {import('../types.js').AwaitArgGuardPayload} AwaitArgGuardPayload */ /** @typedef {import('../types.js').AwaitArgGuard} AwaitArgGuard */ +/** @typedef {import('../types.js').RawGuard} RawGuard */ /** @typedef {import('../types.js').ArgGuard} ArgGuard */ /** @typedef {import('../types.js').MethodGuardPayload} MethodGuardPayload */ +/** @typedef {import('../types.js').SyncValueGuard} SyncValueGuard */ /** @typedef {import('../types.js').MethodGuard} MethodGuard */ /** * @template {Record} [T=Record] @@ -32,7 +34,7 @@ * @template {Record} [T = Record] * @typedef {import('../types.js').InterfaceGuard} InterfaceGuard */ -/** @typedef {import('../types.js').MethodGuardMaker0} MethodGuardMaker0 */ +/** @typedef {import('../types.js').MethodGuardMaker} MethodGuardMaker */ /** @typedef {import('../types').MatcherNamespace} MatcherNamespace */ /** @typedef {import('../types').Key} Key */ diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index 2a22fa6744..49e266a49b 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -1706,6 +1706,9 @@ const makePatternKit = () => { await: argPattern => // eslint-disable-next-line no-use-before-define makeAwaitArgGuard(argPattern), + raw: () => + // eslint-disable-next-line no-use-before-define + makeRawGuard(), }); return harden({ @@ -1739,6 +1742,7 @@ MM = M; // //////////////////////////// Guards /////////////////////////////////////// +// M.await(...) const AwaitArgGuardPayloadShape = harden({ argGuard: M.pattern(), }); @@ -1788,25 +1792,46 @@ const makeAwaitArgGuard = argPattern => { return result; }; -const PatternListShape = M.arrayOf(M.pattern()); +// M.raw() + +const RawGuardPayloadShape = M.record(); + +const RawGuardShape = M.kind('guard:rawGuard'); + +export const isRawGuard = specimen => matches(specimen, RawGuardShape); -const ArgGuardShape = M.or(M.pattern(), AwaitArgGuardShape); +export const assertRawGuard = specimen => + mustMatch(specimen, RawGuardShape, 'rawGuard'); + +/** + * @returns {import('../types.js').RawGuard} + */ +const makeRawGuard = () => makeTagged('guard:rawGuard', {}); + +// M.call(...) +// M.callWhen(...) + +const SyncValueGuardShape = M.or(RawGuardShape, M.pattern()); + +const SyncValueGuardListShape = M.arrayOf(SyncValueGuardShape); + +const ArgGuardShape = M.or(RawGuardShape, AwaitArgGuardShape, M.pattern()); const ArgGuardListShape = M.arrayOf(ArgGuardShape); const SyncMethodGuardPayloadShape = harden({ callKind: 'sync', - argGuards: PatternListShape, - optionalArgGuards: M.opt(PatternListShape), - restArgGuard: M.opt(M.pattern()), - returnGuard: M.pattern(), + argGuards: SyncValueGuardListShape, + optionalArgGuards: M.opt(SyncValueGuardListShape), + restArgGuard: M.opt(SyncValueGuardShape), + returnGuard: SyncValueGuardShape, }); const AsyncMethodGuardPayloadShape = harden({ callKind: 'async', argGuards: ArgGuardListShape, optionalArgGuards: M.opt(ArgGuardListShape), - restArgGuard: M.opt(M.pattern()), - returnGuard: M.pattern(), + restArgGuard: M.opt(SyncValueGuardShape), + returnGuard: SyncValueGuardShape, }); const MethodGuardPayloadShape = M.or( @@ -1842,8 +1867,8 @@ harden(getMethodGuardPayload); * @param {'sync'|'async'} callKind * @param {ArgGuard[]} argGuards * @param {ArgGuard[]} [optionalArgGuards] - * @param {ArgGuard} [restArgGuard] - * @returns {MethodGuardMaker0} + * @param {SyncValueGuard} [restArgGuard] + * @returns {MethodGuardMaker} */ const makeMethodGuardMaker = ( callKind, @@ -1886,9 +1911,10 @@ const InterfaceGuardPayloadShape = M.splitRecord( { interfaceName: M.string(), methodGuards: M.recordOf(M.string(), MethodGuardShape), - sloppy: M.boolean(), }, { + defaultGuards: M.or(M.undefined(), 'passable', 'raw'), + sloppy: M.boolean(), symbolMethodGuards: M.mapOf(M.symbol(), MethodGuardShape), }, ); @@ -1940,11 +1966,12 @@ harden(getInterfaceMethodKeys); * @template {Record} [M = Record] * @param {string} interfaceName * @param {M} methodGuards - * @param {{ sloppy?: boolean }} [options] + * @param {{ sloppy?: boolean, defaultGuards?: import('../types.js').DefaultGuardType }} [options] * @returns {InterfaceGuard} */ const makeInterfaceGuard = (interfaceName, methodGuards, options = {}) => { - const { sloppy = false } = options; + const { sloppy = false, defaultGuards = sloppy ? 'passable' : undefined } = + options; // For backwards compatibility, string-keyed method guards are represented in // a CopyRecord. But symbol-keyed methods cannot be, so we put those in a // CopyMap when present. @@ -1967,7 +1994,7 @@ const makeInterfaceGuard = (interfaceName, methodGuards, options = {}) => { ...(symbolMethodGuardsEntries.length ? { symbolMethodGuards: makeCopyMap(symbolMethodGuardsEntries) } : {}), - sloppy, + defaultGuards, }); assertInterfaceGuard(result); return /** @type {InterfaceGuard} */ (result); @@ -1975,6 +2002,7 @@ const makeInterfaceGuard = (interfaceName, methodGuards, options = {}) => { const GuardPayloadShapes = harden({ 'guard:awaitArgGuard': AwaitArgGuardPayloadShape, + 'guard:rawGuard': RawGuardPayloadShape, 'guard:methodGuard': MethodGuardPayloadShape, 'guard:interfaceGuard': InterfaceGuardPayloadShape, }); diff --git a/packages/patterns/src/types.js b/packages/patterns/src/types.js index 2c18776e73..5b7ff5577e 100644 --- a/packages/patterns/src/types.js +++ b/packages/patterns/src/types.js @@ -456,21 +456,32 @@ export {}; * Matches any Passable that is matched by `subPatt` or is the exact value `undefined`. */ +/** + * @typedef {undefined | 'passable' | 'raw'} DefaultGuardType + */ + +/** + * @typedef {>( + * interfaceName: string, + * methodGuards: M, + * options: {defaultGuards?: undefined, sloppy?: false }) => InterfaceGuard + * } MakeInterfaceGuardStrict + */ /** * @typedef {( * interfaceName: string, * methodGuards: any, - * options: {sloppy: true}) => InterfaceGuard> + * options: {defaultGuards?: 'passable' | 'raw', sloppy?: true }) => InterfaceGuard * } MakeInterfaceGuardSloppy */ /** * @typedef {>( * interfaceName: string, * methodGuards: M, - * options?: {sloppy?: boolean}) => InterfaceGuard + * options?: {defaultGuards?: DefaultGuardType, sloppy?: boolean}) => InterfaceGuard * } MakeInterfaceGuardGeneral */ -/** @typedef {MakeInterfaceGuardSloppy & MakeInterfaceGuardGeneral} MakeInterfaceGuard */ +/** @typedef {MakeInterfaceGuardStrict & MakeInterfaceGuardSloppy & MakeInterfaceGuardGeneral} MakeInterfaceGuard */ /** * @typedef {object} GuardMakers @@ -478,14 +489,34 @@ export {}; * @property {MakeInterfaceGuard} interface * Guard the interface of an exo object * - * @property {(...argPatterns: Pattern[]) => MethodGuardMaker0} call - * Guard a synchronous call + * @property {(...argPatterns: SyncValueGuard[]) => MethodGuardMaker} call + * Guard a synchronous call. Arguments not guarded by `M.raw()` are + * automatically hardened and must be at least Passable. * - * @property {(...argGuards: ArgGuard[]) => MethodGuardMaker0} callWhen - * Guard an async call + * @property {(...argGuards: ArgGuard[]) => MethodGuardMaker} callWhen + * Guard an async call. Arguments not guarded by `M.raw()` are automatically + * hardened and must be at least Passable. * * @property {(argPattern: Pattern) => AwaitArgGuard} await - * Guard an await + * Guard a positional parameter in `M.callWhen`, awaiting it and matching its + * fulfillment against the provided pattern. + * For example, `M.callWhen(M.await(M.nat())).returns()` will await the first + * argument, check that its fulfillment satisfies `M.nat()`, and only then call + * the guarded method with that fulfillment. If the argument is a non-promise + * value that already satisfies `M.nat()`, then the result of `await`ing it will + * still pass, and `M.callWhen` will still delay the guarded method call to a + * future turn. + * If the argument is a promise that rejects rather than fulfills, or if its + * fulfillment does not satisfy the nested pattern, then the call is rejected + * without ever invoking the guarded method. + * + * Any `AwaitArgGuard` may not appear as a rest pattern or a result pattern, + * only a top-level single parameter pattern. + * + * @property {() => RawGuard} raw + * In parameter position, pass this argument through without any hardening or checking. + * In rest position, pass the rest of the arguments through without any hardening or checking. + * In return position, return the result without any hardening or checking. */ /** @@ -502,6 +533,7 @@ export {}; * Omit & Partial<{ [K in Extract]: never }>, * symbolMethodGuards?: * CopyMap, T[Extract]>, + * defaultGuards?: DefaultGuardType, * sloppy?: boolean, * }} InterfaceGuardPayload */ @@ -512,11 +544,7 @@ export {}; */ /** - * @typedef {Record} InterfaceGuardKit - */ - -/** - * @typedef {object} MethodGuardMaker0 + * @typedef {MethodGuardOptional & MethodGuardRestReturns} MethodGuardMaker * A method name and parameter/return signature like: * ```js * foo(a, b, c = d, ...e) => f @@ -528,51 +556,38 @@ export {}; * foo: M.call(AShape, BShape).optional(CShape).rest(EShape).returns(FShape), * } * ``` - * @property {(...optArgGuards: ArgGuard[]) => MethodGuardMaker1} optional - * @property {(rArgGuard: Pattern) => MethodGuardMaker2} rest - * @property {(returnGuard?: Pattern) => MethodGuard} returns +/** + * @typedef {object} MethodGuardReturns + * @property {(returnGuard?: SyncValueGuard) => MethodGuard} returns + * Arguments have been specified, now finish by creating a `MethodGuard`. + * If the return guard is not `M.raw()`, the return value is automatically + * hardened and must be Passable. */ - /** - * @typedef {object} MethodGuardMaker1 - * A method name and parameter/return signature like: - * ```js - * foo(a, b, c = d, ...e) => f - * ``` - * should be guarded by something like: - * ```js - * { - * ...otherMethodGuards, - * foo: M.call(AShape, BShape).optional(CShape).rest(EShape).returns(FShape), - * } - * ``` - * @property {(rArgGuard: Pattern) => MethodGuardMaker2} rest - * @property {(returnGuard?: Pattern) => MethodGuard} returns + * @typedef {object} MethodGuardRest + * @property {(restArgGuard: SyncValueGuard) => MethodGuardReturns} rest + * If the rest argument guard is not `M.raw()`, all rest arguments are + * automatically hardened and must be Passable. */ - /** - * @typedef {object} MethodGuardMaker2 - * A method name and parameter/return signature like: - * ```js - * foo(a, b, c = d, ...e) => f - * ``` - * should be guarded by something like: - * ```js - * { - * ...otherMethodGuards, - * foo: M.call(AShape, BShape).optional(CShape).rest(EShape).returns(FShape), - * } - * ``` - * @property {(returnGuard?: Pattern) => MethodGuard} returns + * @typedef {MethodGuardRest & MethodGuardReturns} MethodGuardRestReturns + * Mandatory and optional arguments have been specified, now specify `rest`, or + * finish with `returns`. + */ +/** + * @typedef {object} MethodGuardOptional + * @property {(...optArgGuards: ArgGuard[]) => MethodGuardRestReturns} optional + * Optional arguments not guarded with `M.raw()` are automatically hardened and + * must be Passable. */ /** * @typedef {{ * callKind: 'sync' | 'async', - * argGuards: ArgGuard[] - * optionalArgGuards?: ArgGuard[] - * restArgGuard?: Pattern - * returnGuard: Pattern + * argGuards: ArgGuard[], + * optionalArgGuards?: ArgGuard[], + * restArgGuard?: SyncValueGuard, + * returnGuard: SyncValueGuard, * }} MethodGuardPayload */ @@ -590,4 +605,14 @@ export {}; * @typedef {CopyTagged<'guard:awaitArgGuard', AwaitArgGuardPayload>} AwaitArgGuard */ -/** @typedef {AwaitArgGuard | Pattern} ArgGuard */ +/** + * @typedef {{}} RawGuardPayload + */ + +/** + * @typedef {CopyTagged<'guard:rawGuard', RawGuardPayload>} RawGuard + */ + +/** @typedef {RawGuard | Pattern} SyncValueGuard */ + +/** @typedef {AwaitArgGuard | RawGuard | Pattern} ArgGuard */ diff --git a/packages/patterns/src/utils.js b/packages/patterns/src/utils.js index 557c54474a..e6f7722df7 100644 --- a/packages/patterns/src/utils.js +++ b/packages/patterns/src/utils.js @@ -92,15 +92,17 @@ harden(fromUniqueEntries); * a CopyRecord. * * @template {Record} O + * @template R result * @param {O} original - * @template R map result * @param {(value: O[keyof O], key: keyof O) => R} mapFn - * @returns {{ [P in keyof O]: R}} + * @returns {Record} */ export const objectMap = (original, mapFn) => { const ents = entries(original); - const mapEnts = ents.map(([k, v]) => [k, mapFn(v, k)]); - return harden(fromEntries(mapEnts)); + const mapEnts = ents.map( + ([k, v]) => /** @type {[keyof O, R]} */ ([k, mapFn(v, k)]), + ); + return /** @type {Record} */ (harden(fromEntries(mapEnts))); }; harden(objectMap);