Skip to content

Commit 8989cee

Browse files
grypezclaude
andauthored
feat(kernel-utils): add described*() combinators for guard+schema authoring (#958)
## Explanation Adds a `./described` export whose combinators author an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source, so a discoverable exo's enforced shape and its `__getDescription__` hint cannot drift. The combinator namespace is exported as `S` (mirroring `@endo/patterns`'s `M` and `@endo/eventual-send`'s `E`). `D` was avoided because it reads as liveslots' `D()` device operator to readers familiar with that codebase. `S` is the single authoring surface — there are no bare combinator function exports: - Leaves: `S.string`/`S.number`/`S.boolean`/`S.arrayOf`/`S.record`/`S.object`/`S.nothing` each yield a `{ pattern, schema }` pair. - `S.arg` names a positional parameter. - `S.method` and `S.interface` assemble them into `{ guard, schema }` / `{ interfaceGuard, schemas }`, ready to splat into `makeDiscoverableExo`. Because the enforced pattern and the descriptive schema are projected from the same authored leaves, their conformance is a construction invariant. Method guards use `M.callWhen(...).returns(...)` (exo methods are invoked across an eventual-send boundary) and the interface guard sets `defaultGuards: 'passable'` so the `__getDescription__` method injected by `makeDiscoverableExo` is allowed. Optional arguments must be trailing (enforced by `S.method`). ### Before this PR ```ts // guard and schema written by hand, independently — nothing keeps them in sync. const interfaceGuard = M.interface('Math', { add: M.callWhen(M.arrayOf(M.number())).returns(M.number()), }); const schemas = { add: { description: 'Add a list of numbers.', args: { summands: { type: 'array', items: { type: 'number' } } }, returns: { type: 'number' }, }, }; // Relax the guard to accept M.string() and the schema still advertises number[]: // the membrane and the prompt now describe different methods, and nothing catches it. ``` ### After this PR ```ts // one source — the guard and the MethodSchema are projected from the // same authored leaves, so they cannot drift. const { interfaceGuard, schemas } = S.interface('Math', { add: S.method( 'Add a list of numbers.', [S.arg('summands', S.arrayOf(S.number()))], S.number('The sum of the numbers.'), ), }); ``` ## Test plan - [x] `yarn workspace @metamask/kernel-utils test:dev:quiet` (304 pass, incl. 13 new `described` tests) - [x] `yarn workspace @metamask/kernel-utils build` - [x] `yarn workspace @metamask/kernel-utils lint` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive API and schema metadata; behavior change is limited to optional-arg validation/conversion where `required` is set. > > **Overview** > Adds **`@metamask/kernel-utils`** `./described` (and root **`S`**) so discoverable exos can author **`@endo/patterns`** guards and **`MethodSchema`** from one definition, avoiding drift between membrane enforcement and **`__getDescription__`**. > > **`S`** exposes leaves (`string`, `number`, `boolean`, `arrayOf`, `record`, `object`, `nothing`), **`S.arg`**, **`S.method`** (async **`M.callWhen`**, trailing optional args), and **`S.interface`** ( **`defaultGuards: 'passable'`** for injected description). **`MethodSchema`** gains optional **`required`**; **`methodArgsToStruct`** honors it for Superstruct validation. **`service-discovery-types`** **`methodSchemaToMethodSpec`** now marks parameters not in **`required`** as **`optional`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit af20bba. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f07e97d commit 8989cee

12 files changed

Lines changed: 597 additions & 6 deletions

File tree

packages/kernel-utils/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add a `./described` export with a combinator namespace `S` (`S.string`/`S.number`/`S.boolean`/`S.arrayOf`/`S.record`/`S.object`/`S.nothing` leaves, plus `S.arg`/`S.method`/`S.interface`) that authors an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source, so a discoverable exo's enforced shape and its `__getDescription__` hint cannot drift ([#958](https://github.com/MetaMask/ocap-kernel/pull/958))
13+
- Add an optional `required` field to `MethodSchema` (mirroring `required` on object `JsonSchema`) naming which arguments are required, and a `{ required }` option on `methodArgsToStruct` that validates unlisted arguments as optional, so a method's argument schema can faithfully represent the optional trailing arguments its guard already allows ([#958](https://github.com/MetaMask/ocap-kernel/pull/958))
1214
- Add `getLibp2pRelayHome()` to the `./nodejs` exports, returning the libp2p relay's bookkeeping directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) — kept separate from `$OCAP_HOME` so one relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))
1315
- `startRelay()` accepts an optional `publicIp` that is fed to libp2p's `appendAnnounce`, so a relay running on a NAT-backed host can announce its publicly-reachable IPv4 alongside its bound NIC addresses ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))
1416

packages/kernel-utils/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@
5959
"default": "./dist/discoverable.cjs"
6060
}
6161
},
62+
"./described": {
63+
"import": {
64+
"types": "./dist/described.d.mts",
65+
"default": "./dist/described.mjs"
66+
},
67+
"require": {
68+
"types": "./dist/described.d.cts",
69+
"default": "./dist/described.cjs"
70+
}
71+
},
6272
"./libp2p": {
6373
"import": {
6474
"types": "./dist/libp2p-relay.d.mts",
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import {
2+
matches,
3+
getInterfaceGuardPayload,
4+
getMethodGuardPayload,
5+
} from '@endo/patterns';
6+
import { describe, expect, it } from 'vitest';
7+
8+
import { S } from './described.ts';
9+
10+
type MethodGuardPayload = {
11+
argGuards: unknown[];
12+
optionalArgGuards?: unknown[];
13+
returnGuard: unknown;
14+
};
15+
16+
const payloadOf = (guard: unknown): MethodGuardPayload =>
17+
getMethodGuardPayload(guard as never) as unknown as MethodGuardPayload;
18+
19+
describe('leaves', () => {
20+
it.each([
21+
{
22+
name: 'string',
23+
described: S.string('a word'),
24+
schema: { type: 'string', description: 'a word' },
25+
ok: 'hello',
26+
bad: 42,
27+
},
28+
{
29+
name: 'number',
30+
described: S.number(),
31+
schema: { type: 'number' },
32+
ok: 42,
33+
bad: 'hello',
34+
},
35+
{
36+
name: 'boolean',
37+
described: S.boolean(),
38+
schema: { type: 'boolean' },
39+
ok: true,
40+
bad: 1,
41+
},
42+
])(
43+
'builds a $name leaf whose pattern and schema agree',
44+
({ described, schema, ok, bad }) => {
45+
expect(described.schema).toStrictEqual(schema);
46+
expect(matches(ok, described.pattern)).toBe(true);
47+
expect(matches(bad, described.pattern)).toBe(false);
48+
},
49+
);
50+
51+
it('builds an arrayOf leaf', () => {
52+
const described = S.arrayOf(S.number(), 'the summands');
53+
expect(described.schema).toStrictEqual({
54+
type: 'array',
55+
items: { type: 'number' },
56+
description: 'the summands',
57+
});
58+
expect(matches([1, 2, 3], described.pattern)).toBe(true);
59+
expect(matches(['a'], described.pattern)).toBe(false);
60+
});
61+
62+
it('builds an open record leaf that allows any keys', () => {
63+
const described = S.record('attachments');
64+
expect(described.schema).toStrictEqual({
65+
type: 'object',
66+
properties: {},
67+
additionalProperties: true,
68+
description: 'attachments',
69+
});
70+
expect(matches({ anything: 1, goes: 'here' }, described.pattern)).toBe(
71+
true,
72+
);
73+
expect(matches(42, described.pattern)).toBe(false);
74+
});
75+
76+
it('builds a closed object leaf with required and optional properties', () => {
77+
const described = S.object(
78+
{ id: S.string(), label: S.string() },
79+
{ optional: ['label'] },
80+
);
81+
expect(described.schema).toStrictEqual({
82+
type: 'object',
83+
properties: { id: { type: 'string' }, label: { type: 'string' } },
84+
required: ['id'],
85+
additionalProperties: false,
86+
});
87+
expect(matches({ id: 'x' }, described.pattern)).toBe(true);
88+
expect(matches({ id: 'x', label: 'y' }, described.pattern)).toBe(true);
89+
expect(matches({ label: 'y' }, described.pattern)).toBe(false);
90+
});
91+
92+
it('rejects extra keys on a closed object leaf', () => {
93+
const described = S.object({ id: S.string() });
94+
expect(matches({ id: 'x' }, described.pattern)).toBe(true);
95+
expect(matches({ id: 'x', extra: 1 }, described.pattern)).toBe(false);
96+
});
97+
98+
it('builds a void return leaf with no schema', () => {
99+
const described = S.nothing();
100+
expect(described.schema).toBeUndefined();
101+
expect(matches(undefined, described.pattern)).toBe(true);
102+
expect(matches('something', described.pattern)).toBe(false);
103+
});
104+
});
105+
106+
describe('S.method', () => {
107+
it('builds a guard and schema from named args', () => {
108+
const method = S.method(
109+
'Add a list of numbers.',
110+
[S.arg('summands', S.arrayOf(S.number()))],
111+
S.number('The sum of the numbers.'),
112+
);
113+
expect(method.schema).toStrictEqual({
114+
description: 'Add a list of numbers.',
115+
args: { summands: { type: 'array', items: { type: 'number' } } },
116+
required: ['summands'],
117+
returns: { type: 'number', description: 'The sum of the numbers.' },
118+
});
119+
const payload = payloadOf(method.guard);
120+
expect(payload.argGuards).toHaveLength(1);
121+
expect(payload.optionalArgGuards ?? []).toHaveLength(0);
122+
});
123+
124+
it('omits `returns` from the schema for a void method', () => {
125+
const method = S.method(
126+
'Return a final response.',
127+
[S.arg('final', S.string())],
128+
S.nothing(),
129+
);
130+
expect(method.schema.returns).toBeUndefined();
131+
expect('returns' in method.schema).toBe(false);
132+
});
133+
134+
it('places optional args in the guard as trailing optionals', () => {
135+
const method = S.method(
136+
'Return a final response.',
137+
[
138+
S.arg('final', S.string()),
139+
S.arg('attachments', S.record(), { optional: true }),
140+
],
141+
S.nothing(),
142+
);
143+
const payload = payloadOf(method.guard);
144+
expect(payload.argGuards).toHaveLength(1);
145+
expect(payload.optionalArgGuards).toHaveLength(1);
146+
expect(method.schema.args).toStrictEqual({
147+
final: { type: 'string' },
148+
attachments: {
149+
type: 'object',
150+
properties: {},
151+
additionalProperties: true,
152+
},
153+
});
154+
// The optional arg is omitted from `required`, so the schema's enforced
155+
// shape matches the guard's optional trailing arg.
156+
expect(method.schema.required).toStrictEqual(['final']);
157+
});
158+
159+
it('handles a no-arg method', () => {
160+
const method = S.method('Get the moon phase.', [], S.string());
161+
expect(method.schema.args).toStrictEqual({});
162+
expect(payloadOf(method.guard).argGuards).toHaveLength(0);
163+
});
164+
165+
it('throws when an optional argument precedes a required one', () => {
166+
expect(() =>
167+
S.method(
168+
'bad',
169+
[S.arg('a', S.string(), { optional: true }), S.arg('b', S.string())],
170+
S.nothing(),
171+
),
172+
).toThrow(/optional arguments must be trailing/u);
173+
});
174+
});
175+
176+
describe('S.interface', () => {
177+
it('collects method guards and schemas, defaulting unlisted methods to passable', () => {
178+
const { interfaceGuard, schemas } = S.interface('Math', {
179+
add: S.method(
180+
'Add a list of numbers.',
181+
[S.arg('summands', S.arrayOf(S.number()))],
182+
S.number('The sum of the numbers.'),
183+
),
184+
count: S.method(
185+
'Count characters.',
186+
[S.arg('word', S.string('The string to measure.'))],
187+
S.number(),
188+
),
189+
});
190+
191+
expect(Object.keys(schemas)).toStrictEqual(['add', 'count']);
192+
const payload = getInterfaceGuardPayload(interfaceGuard) as unknown as {
193+
interfaceName: string;
194+
methodGuards: Record<string, unknown>;
195+
defaultGuards?: string;
196+
};
197+
expect(payload.interfaceName).toBe('Math');
198+
expect(Object.keys(payload.methodGuards)).toStrictEqual(['add', 'count']);
199+
expect(payload.defaultGuards).toBe('passable');
200+
});
201+
});

0 commit comments

Comments
 (0)