Skip to content

Commit b8ac175

Browse files
grypezclaude
andcommitted
feat(kernel-utils): add described*() combinators for guard+schema authoring
Add a `./described` export whose combinators author an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source. The combinator namespace is exported as `S` (mirroring `@endo/patterns`'s `M`): each leaf (`S.string`/`number`/`boolean`/`arrayOf`/`record`/`object`/`nothing`) yields a `{ pattern, schema }` pair; `S.arg` names a positional parameter; and `S.method` / `S.interface` assemble them into a `{ 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 rather than an after-the-fact check. Method guards use `M.callWhen(...).returns(...)` (exo methods are invoked across an eventual-send boundary) and the interface guard sets `defaultGuards: 'passable'` so the injected `__getDescription__` is allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 26528cf commit b8ac175

6 files changed

Lines changed: 508 additions & 0 deletions

File tree

packages/kernel-utils/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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
1213
- 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))
1314
- `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))
1415

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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
});
86+
expect(matches({ id: 'x' }, described.pattern)).toBe(true);
87+
expect(matches({ id: 'x', label: 'y' }, described.pattern)).toBe(true);
88+
expect(matches({ label: 'y' }, described.pattern)).toBe(false);
89+
});
90+
91+
it('builds a void return leaf with no schema', () => {
92+
const described = S.nothing();
93+
expect(described.schema).toBeUndefined();
94+
expect(matches(undefined, described.pattern)).toBe(true);
95+
expect(matches('something', described.pattern)).toBe(false);
96+
});
97+
});
98+
99+
describe('S.method', () => {
100+
it('builds a guard and schema from named args', () => {
101+
const method = S.method(
102+
'Add a list of numbers.',
103+
[S.arg('summands', S.arrayOf(S.number()))],
104+
S.number('The sum of the numbers.'),
105+
);
106+
expect(method.schema).toStrictEqual({
107+
description: 'Add a list of numbers.',
108+
args: { summands: { type: 'array', items: { type: 'number' } } },
109+
returns: { type: 'number', description: 'The sum of the numbers.' },
110+
});
111+
const payload = payloadOf(method.guard);
112+
expect(payload.argGuards).toHaveLength(1);
113+
expect(payload.optionalArgGuards ?? []).toHaveLength(0);
114+
});
115+
116+
it('omits `returns` from the schema for a void method', () => {
117+
const method = S.method(
118+
'Return a final response.',
119+
[S.arg('final', S.string())],
120+
S.nothing(),
121+
);
122+
expect(method.schema.returns).toBeUndefined();
123+
expect('returns' in method.schema).toBe(false);
124+
});
125+
126+
it('places optional args in the guard as trailing optionals', () => {
127+
const method = S.method(
128+
'Return a final response.',
129+
[
130+
S.arg('final', S.string()),
131+
S.arg('attachments', S.record(), { optional: true }),
132+
],
133+
S.nothing(),
134+
);
135+
const payload = payloadOf(method.guard);
136+
expect(payload.argGuards).toHaveLength(1);
137+
expect(payload.optionalArgGuards).toHaveLength(1);
138+
expect(method.schema.args).toStrictEqual({
139+
final: { type: 'string' },
140+
attachments: {
141+
type: 'object',
142+
properties: {},
143+
additionalProperties: true,
144+
},
145+
});
146+
});
147+
148+
it('handles a no-arg method', () => {
149+
const method = S.method('Get the moon phase.', [], S.string());
150+
expect(method.schema.args).toStrictEqual({});
151+
expect(payloadOf(method.guard).argGuards).toHaveLength(0);
152+
});
153+
154+
it('throws when an optional argument precedes a required one', () => {
155+
expect(() =>
156+
S.method(
157+
'bad',
158+
[S.arg('a', S.string(), { optional: true }), S.arg('b', S.string())],
159+
S.nothing(),
160+
),
161+
).toThrow(/optional arguments must be trailing/u);
162+
});
163+
});
164+
165+
describe('S.interface', () => {
166+
it('collects method guards and schemas, defaulting unlisted methods to passable', () => {
167+
const { interfaceGuard, schemas } = S.interface('Math', {
168+
add: S.method(
169+
'Add a list of numbers.',
170+
[S.arg('summands', S.arrayOf(S.number()))],
171+
S.number('The sum of the numbers.'),
172+
),
173+
count: S.method(
174+
'Count characters.',
175+
[S.arg('word', S.string('The string to measure.'))],
176+
S.number(),
177+
),
178+
});
179+
180+
expect(Object.keys(schemas)).toStrictEqual(['add', 'count']);
181+
const payload = getInterfaceGuardPayload(interfaceGuard) as unknown as {
182+
interfaceName: string;
183+
methodGuards: Record<string, unknown>;
184+
defaultGuards?: string;
185+
};
186+
expect(payload.interfaceName).toBe('Math');
187+
expect(Object.keys(payload.methodGuards)).toStrictEqual(['add', 'count']);
188+
expect(payload.defaultGuards).toBe('passable');
189+
});
190+
});

0 commit comments

Comments
 (0)