@playfast/reform-proof 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-proof",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.7",
4
+ "version": "0.0.9",
5
5
  "type": "module",
6
6
  "description": "Headless testing toolkit for reform — drive a scene's compositions and assert on the props they compute, with no DOM, timers, or text matching.",
7
7
  "keywords": [
@@ -27,7 +27,7 @@
27
27
  "./*": "./src/*.ts"
28
28
  },
29
29
  "files": [
30
- "dist",
30
+ "src",
31
31
  "README.md"
32
32
  ],
33
33
  "scripts": {
package/src/engine.ts ADDED
@@ -0,0 +1,452 @@
1
+ import { Effect, Layer, Match, ManagedRuntime } from 'effect'
2
+ import { yieldWrapGet } from 'effect/Utils'
3
+ import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
4
+ import {
5
+ Bus,
6
+ CaptureSink,
7
+ type CaptureSinkApi,
8
+ Composition,
9
+ type CompositionClass,
10
+ type CompositionService,
11
+ isFeatureBinding,
12
+ isStructure,
13
+ publish,
14
+ type RenderEnv,
15
+ type Scene,
16
+ type SlotChild,
17
+ type SlotClass,
18
+ type SlotFill,
19
+ type Structure,
20
+ type Trigger,
21
+ type UiCapture,
22
+ type UiContract,
23
+ } from '@playfast/reform'
24
+ import { matchProps } from './index'
25
+ import type { Action, DriveResult, Facade, Proof, ProofResult, SlotFacade, StepFrame } from './index'
26
+
27
+ // reform-proof engine — the headless renderer the proof system runs on. It drives the
28
+ // real reduce loop through a capturing FACADE: no DOM, no React, no view eval — each
29
+ // composition returns a `Structure`, and the engine reads props/events/fills off the
30
+ // data. `executeProof`/`driveProof` are the two seams the `ProofRunner` layer exposes
31
+ // (see ./runner): run a proof to completion, or step it a frame at a time.
32
+
33
+ /** An erased facade — the runtime shape before the contract type is re-attached. */
34
+ type AnyFacade = Facade<UiContract>
35
+ type AnySlotFacade = SlotFacade<UiContract>
36
+
37
+ // The drain loop, channels, RPC, and reducers run on forked fibers, so a
38
+ // dispatched event takes an unknown number of ticks to flow through. Each poll
39
+ // first drains COOPERATIVELY — a burst of `yieldNow` lets those forked fibers
40
+ // cascade (event → channel → procedure → in-memory RPC → fact → reducer) with no
41
+ // real time — so a synchronous flow reaches its final state within one poll. A
42
+ // real-time `sleep` is still paid each poll to remain the correctness authority
43
+ // for a genuinely time-delayed client (the cooperative drain alone could read a
44
+ // flow waiting on a real timer as falsely "stable" and settle early). The render
45
+ // then re-runs until the captured tree stops changing, bounded so a stuck flow
46
+ // fails fast instead of hanging.
47
+ const SETTLE_DRAIN = 30
48
+ const SETTLE_STEP = '1 milli'
49
+ const SETTLE_MAX_RENDERS = 100
50
+
51
+ /** Cooperatively run forked engine fibers to a fixpoint without advancing real time. */
52
+ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
53
+
54
+ // A keyed view backed by a getter — the only place dynamic keys are needed.
55
+ const keyed = <V>(get: (key: string) => V): Record<string, V> => {
56
+ const target: Record<string, V> = Object.create(null)
57
+ return new Proxy(target, { get: (_t, key) => get(String(key)) })
58
+ }
59
+
60
+ const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
61
+
62
+ // The event triggers a `Structure` frame carries (plan 03b). At the erased
63
+ // `UiContract` boundary the contract's event map is `Record<never, never>`, so
64
+ // each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
65
+ // omitted `events` defaults to the empty map. This is the structure-path analog of
66
+ // the view path's `capture.events`.
67
+ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
68
+ Object.fromEntries(Object.entries(structure.events ?? {}))
69
+
70
+ interface Sink {
71
+ readonly api: CaptureSinkApi
72
+ readonly captures: ReadonlyArray<UiCapture>
73
+ reset(): void
74
+ }
75
+
76
+ const makeSink = (): Sink => {
77
+ // A const holder whose array we swap on reset (no reassigned binding).
78
+ const state: { captures: UiCapture[] } = { captures: [] }
79
+ return {
80
+ api: { record: (capture) => state.captures.push(capture) },
81
+ get captures() {
82
+ return state.captures
83
+ },
84
+ reset: () => {
85
+ state.captures = []
86
+ },
87
+ }
88
+ }
89
+
90
+ type RuntimeServices = CompositionService | SlotChild | Bus
91
+
92
+ /**
93
+ * Build a proof's runtime layer: the scene's closed wiring with the capture sink
94
+ * merged in, so `Ui`'s `serviceOption(CaptureSink)` finds it and records each
95
+ * render (absent in production, the same layer renders to React). The scene's
96
+ * `provide` is typed to the host-read subset (`MountedServices`); a proof also
97
+ * resolves slot-binding tags (`CompositionClass`), which the same closed layer
98
+ * supplies at runtime — restate that broader `RuntimeServices` surface here. This
99
+ * is the one erasure boundary, the same seam the react host crosses when it reads
100
+ * a slot tag with the requirement erased.
101
+ */
102
+ const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
103
+ const sceneLayer = scene.provide.reduce((a, b) =>
104
+ Layer.merge(a, b),
105
+ ) as unknown as Layer.Layer<RuntimeServices, never, never>
106
+ return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
107
+ }
108
+
109
+ /** A composition queued to render, with the props its parent handed it. On the
110
+ * structure path it also carries the slot-fill `key` it was mounted under, so the
111
+ * facade can select a child by key (`byKey`); the legacy Node path omits it. */
112
+ interface Mounted {
113
+ readonly comp: CompositionClass<unknown>
114
+ readonly props: unknown
115
+ readonly key?: string
116
+ }
117
+
118
+ const makeFacade = (
119
+ runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
120
+ root: CompositionClass<unknown>,
121
+ sink: Sink,
122
+ // Records every event name dispatched through the facade, so a proof's
123
+ // requirement-declared event coverage can be verified after the body runs.
124
+ dispatched: Set<string>,
125
+ ): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
126
+ // Slot bindings (`provide(slot, composition)`) are static, so resolve the
127
+ // whole child tree once, up front — keeping `renderTree` and slot navigation
128
+ // synchronous (no nested runtime drive).
129
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
130
+ // A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
131
+ // composition the feature mounts; for a `default` feature that composition's logic
132
+ // is already live (its eager `.live` was merged into the scene by `provide`), so
133
+ // unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
134
+ // placeholders — is the proof host's Part B increment; until then a scene wires
135
+ // features as `default`, the eagerly-resolvable form.)
136
+ const childComposition = (child: SlotChild): CompositionClass<unknown> =>
137
+ isFeatureBinding(child) ? child.composition : child
138
+ const collect = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
139
+ Effect.gen(function* () {
140
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
141
+ if (bindings.has(slotClass)) continue
142
+ const child = childComposition(yield* slotClass.tag)
143
+ bindings.set(slotClass, child)
144
+ yield* collect(child)
145
+ }
146
+ })
147
+ runtime.runSync(collect(root))
148
+
149
+ // One breadth-first render of the whole tree. Each view reports its
150
+ // `(props, events)` to the sink; each slot it renders enqueues a child (built
151
+ // on the next level), so a single sweep renders everything.
152
+ const renderLevel = (
153
+ frontier: ReadonlyArray<Mounted>,
154
+ ): Effect.Effect<void, never, CompositionService> =>
155
+ Effect.gen(function* () {
156
+ if (frontier.length === 0) return
157
+ const next: Array<Mounted> = []
158
+ for (const { comp, props, key } of frontier) {
159
+ const service = yield* comp.tag
160
+ const env: RenderEnv = { props, tracker: { add: () => {} } }
161
+ const frame = yield* Composition.render(service, env)
162
+ // Every composition returns a `Structure` (no view is ever executed — the
163
+ // proof is view-free / React-free): read its computed props straight off the
164
+ // value, record them + the events it carries into the sink (so the facade,
165
+ // `triggerOf`, and navigation work uniformly), and walk its slot FILLS to
166
+ // enqueue children.
167
+ if (!isStructure(frame)) {
168
+ return yield* Effect.dieMessage(
169
+ `reform-proof: composition ${uiNameOf(comp)} did not return a Structure`,
170
+ )
171
+ }
172
+ driveStructure(comp, frame, next, key)
173
+ }
174
+ yield* renderLevel(next)
175
+ })
176
+
177
+ // Consume a `Structure` frame: record the node's computed props (the structure
178
+ // value already holds them — the proof never derives them from a view) AND the
179
+ // event triggers it carries on `structure.events` (plan 03b), then enqueue one
180
+ // child per slot fill with the per-item props the fill carries. Recording the
181
+ // events into the capture the same way the view path does means `triggerOf` /
182
+ // `app.actions` resolve identically for Structure frames; the index-keyed enqueue
183
+ // order matches the legacy node walk.
184
+ const driveStructure = (
185
+ comp: CompositionClass<unknown>,
186
+ structure: Structure<UiContract>,
187
+ next: Array<Mounted>,
188
+ key: string | undefined,
189
+ ): void => {
190
+ sink.api.record({
191
+ name: uiNameOf(comp),
192
+ props: structure.props,
193
+ events: eventsOf(structure),
194
+ ...(key !== undefined ? { key } : {}),
195
+ })
196
+ const fills: Record<string, SlotFill<unknown>> = structure.slots
197
+ for (const [slotName, fill] of Object.entries(fills)) {
198
+ const slotClass = comp.manifest.slots?.[slotName]
199
+ const child = slotClass && bindings.get(slotClass)
200
+ if (child === undefined) continue
201
+ enqueueFill(slotName, fill, child, next)
202
+ }
203
+ }
204
+
205
+ // Expand one slot fill into the children to render next. `Each` mounts one
206
+ // child per keyed item with that item's props and key; `One` mounts a single
207
+ // child under a synthesized singleton key; `Absent` mounts nothing (the
208
+ // data-driven `cond && …`). The key rides each `Mounted` so the child's capture
209
+ // can record it, letting the facade select by key (`byKey`).
210
+ const enqueueFill = (
211
+ slotName: string,
212
+ fill: SlotFill<unknown>,
213
+ child: CompositionClass<unknown>,
214
+ next: Array<Mounted>,
215
+ ): void => {
216
+ Match.value(fill).pipe(
217
+ Match.when({ _tag: 'Each' }, (each) => {
218
+ for (const item of each.items) next.push({ comp: child, props: item.props, key: item.key })
219
+ }),
220
+ Match.when({ _tag: 'One' }, (oneFill) => {
221
+ next.push({ comp: child, props: oneFill.props, key: `${slotName}.0` })
222
+ }),
223
+ Match.when({ _tag: 'Absent' }, () => {}),
224
+ Match.exhaustive,
225
+ )
226
+ }
227
+
228
+ const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
229
+ sink.reset()
230
+ yield* renderLevel([{ comp: root, props: {} }])
231
+ })
232
+
233
+ const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
234
+ sink.captures.filter((capture) => capture.name === name)
235
+
236
+ // A cheap fingerprint of the rendered tree; when two successive renders match,
237
+ // the engine has stopped producing new state and the read is safe.
238
+ const fingerprint = (): string =>
239
+ JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
240
+
241
+ // Re-render until the tree is stable for two consecutive renders, or give up.
242
+ const settleFrom = (
243
+ previous: string,
244
+ remaining: number,
245
+ ): Effect.Effect<void, never, CompositionService> =>
246
+ Effect.gen(function* () {
247
+ if (remaining === 0) return
248
+ yield* settleDrain
249
+ yield* Effect.sleep(SETTLE_STEP)
250
+ yield* renderTree
251
+ const current = fingerprint()
252
+ if (current === previous) return
253
+ yield* settleFrom(current, remaining - 1)
254
+ })
255
+ const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
256
+ yield* settleDrain
257
+ yield* renderTree
258
+ yield* settleFrom(fingerprint(), SETTLE_MAX_RENDERS)
259
+ })
260
+
261
+ const triggerOf = (name: string, index: number, event: string): Trigger<unknown> => {
262
+ const trigger = capturesFor(name)[index]?.events[event]
263
+ if (trigger === undefined) {
264
+ throw new UnknownAction({ composition: name, action: event, rendered: capturesFor(name).length })
265
+ }
266
+ return trigger
267
+ }
268
+
269
+ const propsFor = (name: string, index: number): Effect.Effect<unknown, never, CompositionService> =>
270
+ Effect.map(renderTree, () => capturesFor(name)[index]?.props)
271
+
272
+ const nodeFacade = (name: string, index: number, comp: CompositionClass<unknown>): AnyFacade => ({
273
+ props: propsFor(name, index),
274
+ // Assert the computed props match a typed subset. Reuses the same partial-match
275
+ // logic as `expect(...).toMatchObject`; throws `AssertionFailed` on mismatch.
276
+ expectProps: (partial) =>
277
+ propsFor(name, index).pipe(
278
+ Effect.flatMap((props) => {
279
+ const mismatch = matchProps(props, partial)
280
+ return mismatch === undefined
281
+ ? Effect.void
282
+ : Effect.sync(() => {
283
+ throw new AssertionFailed({ detail: mismatch })
284
+ })
285
+ }),
286
+ ),
287
+ // `keyed` resolves event/slot names at runtime; cast the string-keyed proxy
288
+ // to the contract-typed surface. This is the one reflection boundary, the
289
+ // same seam as `definitionClass` — every name the proxy serves is a real
290
+ // contract member, so the cast is sound. The action resolves to the props
291
+ // this node computed once the dispatch settled (the typed "result" of the event).
292
+ actions: keyed(
293
+ (event): Action =>
294
+ (payload) =>
295
+ renderTree.pipe(
296
+ Effect.flatMap(() =>
297
+ Effect.sync(() => {
298
+ dispatched.add(event)
299
+ triggerOf(name, index, event)(payload)
300
+ }),
301
+ ),
302
+ Effect.flatMap(() => settle),
303
+ Effect.flatMap(() => propsFor(name, index)),
304
+ ),
305
+ ) as AnyFacade['actions'],
306
+ slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
307
+ frame: settle,
308
+ })
309
+
310
+ const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
311
+ const slotClass = parent.manifest.slots?.[slotName]
312
+ const child = slotClass && bindings.get(slotClass)
313
+ if (child === undefined) {
314
+ throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
315
+ }
316
+ const childName = uiNameOf(child)
317
+ const at = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
318
+ Effect.as(renderTree, nodeFacade(childName, index, child))
319
+ const first = nodeFacade(childName, 0, child)
320
+ // The render index of the child whose structure-fill `key` matches (plan 06).
321
+ // Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
322
+ // selection is by stable identity, not render order. Throws `UnknownSlot` (with
323
+ // the missing key in the slot position) when no fill carries it.
324
+ const indexOfKey = (key: string): number => {
325
+ const index = capturesFor(childName).findIndex((capture) => capture.key === key)
326
+ if (index < 0) {
327
+ throw new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` })
328
+ }
329
+ return index
330
+ }
331
+ return {
332
+ first: at(0),
333
+ at,
334
+ all: Effect.map(renderTree, () =>
335
+ capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
336
+ ),
337
+ // Select the one child mounted under `key` (the `each` item key). Re-renders
338
+ // first so the fill keys reflect the latest frame, then resolves its facade.
339
+ byKey: (key) => Effect.map(renderTree, () => nodeFacade(childName, indexOfKey(key), child)),
340
+ // Every child whose computed props satisfy `predicate`. Re-renders, then maps
341
+ // the matching captures back to their facades by render index.
342
+ where: (predicate) =>
343
+ Effect.map(renderTree, () =>
344
+ capturesFor(childName).flatMap((capture, index) =>
345
+ predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
346
+ ),
347
+ ),
348
+ // How many children this slot mounted this frame — the fill length.
349
+ count: Effect.map(renderTree, () => capturesFor(childName).length),
350
+ // Used directly, a slot behaves as its first instance.
351
+ props: first.props,
352
+ expectProps: first.expectProps,
353
+ actions: first.actions,
354
+ slots: first.slots,
355
+ frame: first.frame,
356
+ }
357
+ }
358
+
359
+ return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
360
+ }
361
+
362
+ /** Declared-but-never-dispatched events for a proof's requirement (coverage). */
363
+ const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> => {
364
+ const declared = proof.requirement.manifest.events
365
+ if (declared === undefined) return []
366
+ return declared.map(String).filter((event) => !dispatched.has(event))
367
+ }
368
+
369
+ /**
370
+ * Run one proof against a fresh runtime, returning its pass/fail result. Each
371
+ * proof gets its own isolated environment, so nothing leaks between proofs. Boots
372
+ * the scene as the host would, settles, then runs the proof body to completion.
373
+ */
374
+ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
375
+ const sink = makeSink()
376
+ const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
377
+ const dispatched = new Set<string>()
378
+ try {
379
+ const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
380
+ await runtime.runPromise(
381
+ Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
382
+ Effect.flatMap(() => settle),
383
+ ),
384
+ )
385
+ await runtime.runPromise(Effect.gen(() => proof.body(facade)))
386
+ const missing = missingCoverage(proof, dispatched)
387
+ if (missing.length > 0) {
388
+ return {
389
+ requirement: proof.requirement.manifest.statement,
390
+ ok: false,
391
+ error: `requirement declares events never dispatched: ${missing.join(', ')}`,
392
+ }
393
+ }
394
+ return { requirement: proof.requirement.manifest.statement, ok: true }
395
+ } catch (error) {
396
+ return {
397
+ requirement: proof.requirement.manifest.statement,
398
+ ok: false,
399
+ error: error instanceof Error ? error.message : String(error),
400
+ }
401
+ } finally {
402
+ await runtime.dispose()
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Drive one proof a step at a time, recording a `StepFrame` after each yielded
408
+ * effect settles. Reuses the same makeFacade runtime/facade/settle as
409
+ * `executeProof`, but instead of handing the body to `Effect.gen` (which runs it
410
+ * to completion), it pumps the generator by hand — `gen.next(value)` yields the
411
+ * next effect, we run it on the live runtime, snapshot the sink, and feed the
412
+ * result back. So the editor gets the per-step timeline with no change to the
413
+ * proof authoring API.
414
+ */
415
+ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
416
+ const sink = makeSink()
417
+ const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
418
+ const frames: Array<StepFrame> = []
419
+ const statement = proof.requirement.manifest.statement
420
+ // The driver's per-frame timeline does not enforce coverage; collect into a
421
+ // throwaway set so `makeFacade`'s contract is satisfied.
422
+ const dispatched = new Set<string>()
423
+ try {
424
+ const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
425
+ await runtime.runPromise(
426
+ Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
427
+ Effect.flatMap(() => settle),
428
+ ),
429
+ )
430
+ frames.push({ index: 0, captures: [...sink.captures] })
431
+ const generator = proof.body(facade)
432
+ // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
433
+ const pump = async (input: unknown, index: number): Promise<void> => {
434
+ const step = generator.next(input)
435
+ if (step.done === true) return
436
+ const value = await runtime.runPromise(yieldWrapGet(step.value))
437
+ frames.push({ index, captures: [...sink.captures] })
438
+ await pump(value, index + 1)
439
+ }
440
+ await pump(undefined, 1)
441
+ return { requirement: statement, frames, ok: true }
442
+ } catch (error) {
443
+ return {
444
+ requirement: statement,
445
+ frames,
446
+ ok: false,
447
+ error: error instanceof Error ? error.message : String(error),
448
+ }
449
+ } finally {
450
+ await runtime.dispose()
451
+ }
452
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { type Cause, Data } from 'effect'
2
+
3
+ /**
4
+ * Tagged errors for the proof harness. A failed assertion or a malformed proof
5
+ * navigation throws one of these — tagged and structured, never a bare `Error` —
6
+ * so a runner can distinguish an assertion failure from a harness misuse.
7
+ */
8
+
9
+ /**
10
+ * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
11
+ * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
12
+ * (which forbids an inferred expression in an extends clause).
13
+ */
14
+ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
15
+ args: A,
16
+ ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
17
+
18
+ const AssertionFailedBase: TaggedErrorClass<
19
+ 'reform-proof/AssertionFailed',
20
+ { readonly detail: string }
21
+ > = Data.TaggedError('reform-proof/AssertionFailed')<{ readonly detail: string }>
22
+
23
+ /** An `expect(...)` matcher did not hold. */
24
+ export class AssertionFailed extends AssertionFailedBase {
25
+ override get message(): string {
26
+ return `reform-proof: assertion failed — ${this.detail}`
27
+ }
28
+ }
29
+
30
+ const UnknownActionBase: TaggedErrorClass<
31
+ 'reform-proof/UnknownAction',
32
+ { readonly composition: string; readonly action: string; readonly rendered: number }
33
+ > = Data.TaggedError('reform-proof/UnknownAction')<{
34
+ readonly composition: string
35
+ readonly action: string
36
+ readonly rendered: number
37
+ }>
38
+
39
+ /** A proof referenced an action the rendered composition never exposed. */
40
+ export class UnknownAction extends UnknownActionBase {
41
+ override get message(): string {
42
+ return `reform-proof: '${this.composition}' has no action '${this.action}' (rendered ${this.rendered} instance(s))`
43
+ }
44
+ }
45
+
46
+ const UnknownSlotBase: TaggedErrorClass<
47
+ 'reform-proof/UnknownSlot',
48
+ { readonly parent: string; readonly slot: string }
49
+ > = Data.TaggedError('reform-proof/UnknownSlot')<{ readonly parent: string; readonly slot: string }>
50
+
51
+ /** A proof navigated into a slot the parent composition does not declare. */
52
+ export class UnknownSlot extends UnknownSlotBase {
53
+ override get message(): string {
54
+ return `reform-proof: '${this.parent}' has no slot '${this.slot}'`
55
+ }
56
+ }