diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 7cd93a31b..e10e96895 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -11,12 +11,9 @@ export type { BuildMethodGuardOptions, MethodGuardPayload, } from './guard-algebra.ts'; +export type { NarrowingDelta } from './narrow-interface-guard.ts'; export { join, narrow, pathUnder } from './narrowing.ts'; -export type { - JoinOptions, - NarrowOptions, - NarrowingDelta, -} from './narrowing.ts'; +export type { JoinOptions, NarrowOptions } from './narrowing.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; export { S } from './described.ts'; diff --git a/packages/kernel-utils/src/narrow-interface-guard.test.ts b/packages/kernel-utils/src/narrow-interface-guard.test.ts new file mode 100644 index 000000000..edead8f6b --- /dev/null +++ b/packages/kernel-utils/src/narrow-interface-guard.test.ts @@ -0,0 +1,139 @@ +import { M, getMethodGuardPayload, matches } from '@endo/patterns'; +import type { InterfaceGuard } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts'; +import type { MethodGuardPayload } from './guard-algebra.ts'; +import { narrowInterfaceGuard } from './narrow-interface-guard.ts'; +import type { NarrowingDelta } from './narrow-interface-guard.ts'; + +// One fixture reaching all three positional categories. +const makeBaseGuard = (): InterfaceGuard => + M.interface('Store', { + read: M.callWhen(M.string()).optional(M.number()).returns(M.any()), + write: M.callWhen(M.string()).rest(M.number()).returns(M.any()), + drop: M.callWhen(M.string()).returns(M.any()), + }); + +const payloadOf = (guard: InterfaceGuard, method: string): MethodGuardPayload => + getMethodPayload(getInterfaceMethodGuards(guard)[method]!); + +const narrowFrom = ( + baseGuard: InterfaceGuard, + delta: NarrowingDelta, +): InterfaceGuard => + narrowInterfaceGuard({ name: 'Narrowed', baseGuard, delta }); + +describe('narrowInterfaceGuard', () => { + it('drops methods the delta does not name', () => { + const result = narrowFrom(makeBaseGuard(), { read: [] }); + + expect(Object.keys(getInterfaceMethodGuards(result))).toStrictEqual([ + 'read', + ]); + }); + + it('drops every method given an empty delta', () => { + const result = narrowFrom(makeBaseGuard(), {}); + + expect(getInterfaceMethodGuards(result)).toStrictEqual({}); + }); + + it('leaves a method as the base has it when its delta is empty', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [] }); + + expect(payloadOf(result, 'read')).toStrictEqual( + payloadOf(baseGuard, 'read'), + ); + }); + + it('conjoins a pattern onto a required argument', () => { + const result = narrowFrom(makeBaseGuard(), { read: [M.eq('a')] }); + const [argGuard] = payloadOf(result, 'read').argGuards; + + expect(matches('a', argGuard)).toBe(true); + expect(matches('b', argGuard)).toBe(false); + expect(matches(1, argGuard)).toBe(false); + }); + + it('leaves positions the delta does not reach as the base has them', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [M.eq('a')] }); + + expect(payloadOf(result, 'read').optionalArgGuards).toStrictEqual( + payloadOf(baseGuard, 'read').optionalArgGuards, + ); + }); + + it('conjoins onto an optional argument without making it required', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [undefined, M.lte(10)] }); + const { argGuards, optionalArgGuards } = payloadOf(result, 'read'); + + expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'read').argGuards); + expect(matches(5, optionalArgGuards![0])).toBe(true); + expect(matches(20, optionalArgGuards![0])).toBe(false); + }); + + it('conjoins onto the rest guard past the fixed arity', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { write: [undefined, M.lte(10)] }); + const { argGuards, restArgGuard } = payloadOf(result, 'write'); + + expect(argGuards).toStrictEqual(payloadOf(baseGuard, 'write').argGuards); + expect(matches(5, restArgGuard)).toBe(true); + expect(matches(20, restArgGuard)).toBe(false); + }); + + it('inherits the return guard verbatim', () => { + const baseGuard = makeBaseGuard(); + + const result = narrowFrom(baseGuard, { read: [M.eq('a')] }); + + expect(payloadOf(result, 'read').returnGuard).toBe( + payloadOf(baseGuard, 'read').returnGuard, + ); + }); + + it('asyncifies a synchronous method guard', () => { + const baseGuard = M.interface('Sync', { + read: M.call(M.string()).returns(M.any()), + }); + + const result = narrowFrom(baseGuard, { read: [] }); + const { callKind } = getMethodGuardPayload( + getInterfaceMethodGuards(result).read!, + ); + + expect(callKind).toBe('async'); + }); + + it.each([ + { + scenario: 'names a method the base does not have', + makeGuard: makeBaseGuard, + delta: { missing: [] }, + message: 'the base has no such method', + }, + { + scenario: + 'addresses a position past the arity of a base without a rest guard', + makeGuard: makeBaseGuard, + delta: { drop: [undefined, M.eq('x')] }, + message: 'the base has arity 1 and no rest guard', + }, + { + scenario: 'narrows a method the base guards by default', + makeGuard: () => M.interface('Any', {}, { defaultGuards: 'passable' }), + delta: { read: [M.eq('a')] }, + message: 'there is no guard to conjoin onto', + }, + ])('rejects a delta that $scenario', ({ makeGuard, delta, message }) => { + expect(() => narrowFrom(makeGuard(), delta)).toThrow(message); + }); +}); diff --git a/packages/kernel-utils/src/narrow-interface-guard.ts b/packages/kernel-utils/src/narrow-interface-guard.ts new file mode 100644 index 000000000..588ccb10e --- /dev/null +++ b/packages/kernel-utils/src/narrow-interface-guard.ts @@ -0,0 +1,131 @@ +import { M, getInterfaceGuardPayload } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; + +import { + buildMethodGuard, + getInterfaceMethodGuards, + getMethodPayload, +} from './guard-algebra.ts'; + +/** + * Patterns to conjoin onto a base capability's method guards, addressed by + * argument position. + * + * A method the delta does not name is dropped, so forgetting a method removes + * authority rather than granting it. Within a method, a hole leaves that + * position as the base has it, and an empty array leaves every position as the + * base has it. + */ +export type NarrowingDelta = Record; + +/** + * Conjoin a delta pattern onto a base guard. + * + * @param base - The base guard. + * @param pattern - The pattern to conjoin, or undefined at a hole. + * @returns The conjunction, or the base guard unchanged at a hole. + */ +const conjoin = (base: Pattern, pattern: Pattern | undefined): Pattern => + pattern === undefined ? base : M.and(base, pattern); + +/** + * Narrow one method guard by conjoining the delta's patterns onto the + * positions they address. + * + * Positions are walked as required arguments, then optionals, then the rest + * guard, and each stays in the category it lands in. Every position past the + * fixed arity conjoins onto the one rest guard, which is the only thing a rest + * position can express. + * + * @param methodName - The method being narrowed, for error messages. + * @param baseMethodGuard - The guard to narrow. + * @param patterns - The delta's patterns for this method. + * @returns The narrowed guard, asyncified for forwarding. + */ +const narrowMethodGuard = ( + methodName: string, + baseMethodGuard: MethodGuard, + patterns: (Pattern | undefined)[], +): MethodGuard => { + const { argGuards, optionalArgGuards, restArgGuard, returnGuard } = + getMethodPayload(baseMethodGuard); + const optionals = optionalArgGuards ?? []; + const maxArity = argGuards.length + optionals.length; + + const beyondArity = patterns.findIndex( + (pattern, index) => index >= maxArity && pattern !== undefined, + ); + if (beyondArity !== -1 && restArgGuard === undefined) { + throw new Error( + `Cannot narrow argument ${beyondArity} of method "${methodName}": the base has arity ${maxArity} and no rest guard.`, + ); + } + + return buildMethodGuard({ + base: M.callWhen( + ...argGuards.map((guard, index) => conjoin(guard, patterns[index])), + ), + optionals: optionals.map((guard, index) => + conjoin(guard, patterns[argGuards.length + index]), + ), + restGuard: + restArgGuard === undefined + ? undefined + : patterns.slice(maxArity).reduce(conjoin, restArgGuard), + returnGuard, + }); +}; + +/** + * Derive the interface guard of a narrowing of a base capability. + * + * Each delta pattern is conjoined onto the base's guard at the argument + * position it addresses. Arity, the required/optional/rest split, and return + * guards are inherited verbatim — a narrowed return guard could fail where the + * base succeeds, which would not be an unaltered forward. Methods the delta + * does not name are dropped. + * + * The result is constructed as a conjunction with the base's guard rather than + * checked against it, so it admits no call the base does not — the + * precondition that lets `join` disjoin deltas without deciding pattern + * subtyping. + * + * @param options - Options bag. + * @param options.name - The name for the derived interface guard. + * @param options.baseGuard - The interface guard being narrowed. + * @param options.delta - The patterns to conjoin, by method and position. + * @returns The derived interface guard. + */ +export const narrowInterfaceGuard = ({ + name, + baseGuard, + delta, +}: { + name: string; + baseGuard: InterfaceGuard; + delta: NarrowingDelta; +}): InterfaceGuard => { + const baseMethodGuards = getInterfaceMethodGuards(baseGuard); + const { defaultGuards } = getInterfaceGuardPayload(baseGuard) as unknown as { + defaultGuards?: 'passable' | 'raw'; + }; + + const narrowedMethodGuards: Record = {}; + for (const [methodName, patterns] of Object.entries(delta)) { + const baseMethodGuard = baseMethodGuards[methodName]; + if (baseMethodGuard === undefined) { + throw new Error( + defaultGuards === undefined + ? `Cannot narrow method "${methodName}": the base has no such method.` + : `Cannot narrow method "${methodName}": the base guards it by default, so there is no guard to conjoin onto.`, + ); + } + narrowedMethodGuards[methodName] = narrowMethodGuard( + methodName, + baseMethodGuard, + patterns, + ); + } + + return M.interface(name, narrowedMethodGuards); +}; diff --git a/packages/kernel-utils/src/narrowing.ts b/packages/kernel-utils/src/narrowing.ts index 7831f7aaf..d98533961 100644 --- a/packages/kernel-utils/src/narrowing.ts +++ b/packages/kernel-utils/src/narrowing.ts @@ -2,13 +2,7 @@ import type { Guarded, Methods } from '@endo/exo'; import { M } from '@endo/patterns'; import type { Pattern } from '@endo/patterns'; -/** - * A narrowing, addressed by method name and then by argument position. - * - * A method absent from the delta is dropped from the narrowing. An `undefined` - * slot leaves that argument position as the base has it. - */ -export type NarrowingDelta = Record; +import type { NarrowingDelta } from './narrow-interface-guard.ts'; /** * `base` is `object` rather than `Methods` because an `@endo/exo` carries no