@playfast/reform-proof 1.0.1 → 1.1.1

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/src/engine.ts CHANGED
@@ -1,589 +1,8 @@
1
- import {
2
- Array as Arr,
3
- Cause,
4
- Effect,
5
- Layer,
6
- ManagedRuntime,
7
- Match,
8
- MutableRef,
9
- Option,
10
- Record as Rec,
11
- Ref,
12
- } from 'effect'
13
- import { yieldWrapGet } from 'effect/Utils'
14
- import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
15
- import {
16
- Bus,
17
- CaptureSink,
18
- type CaptureSinkApi,
19
- Composition,
20
- type CompositionClass,
21
- type CompositionService,
22
- type Instrumentation,
23
- isFeatureBinding,
24
- isStructure,
25
- noopInstrumentation,
26
- publish,
27
- type RenderEnv,
28
- type Scene,
29
- sceneInstrumentation,
30
- type SlotChild,
31
- type SlotClass,
32
- type SlotFill,
33
- type Structure,
34
- type Trigger,
35
- type UiCapture,
36
- type UiContract,
37
- } from '@playfast/reform'
38
- import { matchProps } from './index'
39
- import type { Action, DriveResult, Facade, Proof, ProofResult, SlotFacade, StepFrame } from './index'
40
-
41
- // reform-proof engine — the headless renderer the proof system runs on. It drives the
42
- // real reduce loop through a capturing FACADE: no DOM, no React, no view eval — each
43
- // composition returns a `Structure`, and the engine reads props/events/fills off the
44
- // data. `executeProof`/`driveProof` are the two seams the `ProofRunner` layer exposes
45
- // (see ./runner): run a proof to completion, or step it a frame at a time.
46
-
47
- /** An erased facade — the runtime shape before the contract type is re-attached. */
48
- type AnyFacade = Facade<UiContract>
49
- type AnySlotFacade = SlotFacade<UiContract>
50
-
51
- // The drain loop, channels, RPC, and reducers run on forked fibers, so a
52
- // dispatched event takes an unknown number of ticks to flow through. Each poll
53
- // first drains COOPERATIVELY — a burst of `yieldNow` lets those forked fibers
54
- // cascade (event → channel → procedure → in-memory RPC → fact → reducer) with no
55
- // real time — so a synchronous flow reaches its final state within one poll. A
56
- // real-time `sleep` is still paid each poll to remain the correctness authority
57
- // for a genuinely time-delayed client (the cooperative drain alone could read a
58
- // flow waiting on a real timer as falsely "stable" and settle early). The render
59
- // then re-runs until the captured tree stops changing, bounded so a stuck flow
60
- // fails fast instead of hanging.
61
- const SETTLE_DRAIN = 30
62
- const SETTLE_STEP = '1 milli'
63
- const SETTLE_MAX_RENDERS = 100
64
-
65
- /** Cooperatively run forked engine fibers to a fixpoint without advancing real time. */
66
- const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
67
-
68
- // A keyed view backed by a getter — the only place dynamic keys are needed.
69
- const keyed = <V>(get: (key: string) => V): Record<string, V> => {
70
- const target: Record<string, V> = Object.create(null)
71
- return new Proxy(target, { get: (_target, key) => get(String(key)) })
72
- }
73
-
74
- /** Surface an unknown failure/defect as the message string a result DTO carries. */
75
- const messageOf = (error: unknown): string =>
76
- error instanceof Error ? error.message : String(error)
77
-
78
- const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
79
-
80
- // The event triggers a `Structure` frame carries (plan 03b). At the erased
81
- // `UiContract` boundary the contract's event map is `Record<never, never>`, so
82
- // each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
83
- // omitted `events` defaults to the empty map. This is the structure-path analog of
84
- // the view path's `capture.events`.
85
- const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
86
- ...structure.events,
87
- })
88
-
89
- // Engine SPI (Sink/makeSink/RuntimeServices/proofLayer/makeFacade) — exported for
90
- // @playfast/reform-drive, which builds the no-ceremony scene driver on the same core.
91
- export interface Sink {
92
- readonly api: CaptureSinkApi
93
- readonly captures: ReadonlyArray<UiCapture>
94
- /** The scene's profiling hooks — render spans record here (noop unless `profileScene`d). */
95
- readonly instrumentation: Instrumentation
96
- reset(): void
97
- }
98
-
99
- export const makeSink = (instrumentation: Instrumentation = noopInstrumentation): Sink => {
100
- // CaptureSink.record is synchronous, so this uses MutableRef rather than Ref.
101
- const captures = MutableRef.make<UiCapture[]>([])
102
- return {
103
- api: {
104
- record: (capture) => {
105
- MutableRef.update(captures, (current) => [...current, capture])
106
- },
107
- },
108
- get captures() {
109
- return MutableRef.get(captures)
110
- },
111
- instrumentation,
112
- reset: () => {
113
- MutableRef.set(captures, [])
114
- },
115
- }
116
- }
117
-
118
- export type RuntimeServices = CompositionService | SlotChild | Bus
119
-
120
- /**
121
- * Build a proof's runtime layer: the scene's closed wiring with the capture sink
122
- * merged in, so `Ui`'s `serviceOption(CaptureSink)` finds it and records each
123
- * render (absent in production, the same layer renders to React). The scene's
124
- * `provide` is typed to the host-read subset (`MountedServices`); a proof also
125
- * resolves slot-binding tags (`CompositionClass`), which the same closed layer
126
- * supplies at runtime — restate that broader `RuntimeServices` surface here. This
127
- * is the one erasure boundary, the same seam the react host crosses when it reads
128
- * a slot tag with the requirement erased.
129
- */
130
- export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
131
- // oxlint-disable-next-line reform-rules/no-type-assertion -- the one erasure boundary: the scene's host-read `MountedServices` layer widens to the broader `RuntimeServices` a proof resolves
132
- const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer)) as unknown as Layer.Layer<
133
- RuntimeServices,
134
- never,
135
- never
136
- >
137
- return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
138
- }
139
-
140
- /** A composition queued to render, with the props its parent handed it. On the
141
- * structure path it also carries the slot-fill `key` it was mounted under, so the
142
- * facade can select a child by key (`byKey`); the legacy Node path omits it. */
143
- interface Mounted {
144
- readonly comp: CompositionClass<unknown>
145
- readonly props: unknown
146
- readonly key: Option.Option<string>
147
- }
148
-
149
- /** A rendered node addressed by its composition ui-name and render index. */
150
- interface NodeRef {
151
- readonly name: string
152
- readonly index: number
153
- }
154
-
155
- /** Settle progress: the previous fingerprint, remaining renders, and early-stop state. */
156
- interface SettleProgress {
157
- readonly previous: string
158
- readonly remaining: number
159
- readonly stable: boolean
160
- }
161
-
162
- /** One step of the driver's generator pump: the value to feed in and the frame index. */
163
- interface PumpStep {
164
- readonly input: unknown
165
- readonly index: number
166
- }
167
-
168
- // A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
169
- // composition the feature mounts; for a `default` feature that composition's logic
170
- // is already live (its eager `.live` was merged into the scene by `provide`), so
171
- // unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
172
- // placeholders — is the proof host's Part B increment; until then a scene wires
173
- // features as `default`, the eagerly-resolvable form.)
174
- const childComposition = (child: SlotChild): CompositionClass<unknown> =>
175
- isFeatureBinding(child) ? child.composition : child
176
-
177
- /** A composition's declared slot map, defaulting to an empty record when it declares none. */
178
- const slotsOf = (comp: CompositionClass<unknown>): Record<string, SlotClass> =>
179
- Option.getOrElse(Option.fromNullable(comp.manifest.slots), () => ({}))
180
-
181
- // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
182
- // UiContract>` erases its per-slot fill types at this boundary, so recover them
183
- // structurally before traversing.
184
- const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
185
- typeof candidate === 'object' &&
186
- candidate !== null &&
187
- '_tag' in candidate &&
188
- (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
189
-
190
- const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
191
- Rec.fromEntries(
192
- Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
193
- isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
194
- ),
195
- )
196
-
197
- // Slot bindings (`provide(slot, composition)`) are static, so resolve the whole
198
- // child tree once, up front — keeping `renderTree` and slot navigation
199
- // synchronous (no nested runtime drive).
200
- const collectBindings = Effect.fn('collectBindings')(function* (
201
- root: CompositionClass<unknown>,
202
- ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
203
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
204
- const walk: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> = Effect.fn(
205
- 'collectBindings.walk',
206
- )(function* (comp: CompositionClass<unknown>): Effect.fn.Return<void, never, SlotChild> {
207
- yield* Effect.forEach(Rec.values(slotsOf(comp)), (slotClass) =>
208
- Effect.gen(function* () {
209
- if (bindings.has(slotClass)) {
210
- return
211
- }
212
- const child = childComposition(yield* slotClass.tag)
213
- bindings.set(slotClass, child)
214
- yield* walk(child)
215
- }),
216
- )
217
- })
218
- yield* walk(root)
219
- return bindings
220
- })
221
-
222
- // Expand one slot fill into the children to render next. `Each` mounts one child
223
- // per keyed item with that item's props and key; `One` mounts a single child
224
- // under a synthesized singleton key; `Absent` mounts nothing.
225
- const enqueueFill = (
226
- slotName: string,
227
- fill: SlotFill<unknown>,
228
- child: CompositionClass<unknown>,
229
- ): ReadonlyArray<Mounted> =>
230
- Match.value(fill).pipe(
231
- Match.when({ _tag: 'Each' }, (each) =>
232
- each.items.map((entry) => ({
233
- comp: child,
234
- props: entry.props,
235
- key: Option.some(entry.key),
236
- })),
237
- ),
238
- Match.when({ _tag: 'One' }, (oneFill) => [
239
- { comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
240
- ]),
241
- Match.when({ _tag: 'Absent' }, () => []),
242
- Match.exhaustive,
243
- )
244
-
245
- // Consume a `Structure` frame: record computed props and event triggers, then
246
- // enqueue one child per slot fill with the per-item props the fill carries.
247
- const driveStructure = (
248
- comp: CompositionClass<unknown>,
249
- structure: Structure<UiContract>,
250
- key: Option.Option<string>,
251
- sink: Sink,
252
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
253
- ): ReadonlyArray<Mounted> => {
254
- sink.api.record({
255
- name: uiNameOf(comp),
256
- props: structure.props,
257
- events: eventsOf(structure),
258
- ...(Option.isSome(key) ? { key: key.value } : {}),
259
- })
260
- const fills = structureFills(structure)
261
- return Rec.toEntries(slotsOf(comp)).flatMap(([slotName, slotClass]) => {
262
- const child = bindings.get(slotClass)
263
- const fill = fills[slotName]
264
- return child === undefined || fill === undefined ? [] : enqueueFill(slotName, fill, child)
265
- })
266
- }
267
-
268
- // Render one mounted composition, returning the children its fills enqueue. Every
269
- // composition returns a `Structure` (no view is ever executed — the proof is
270
- // view-free / React-free).
271
- const renderMounted = Effect.fn('renderMounted')(function* (
272
- mounted: Mounted,
273
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
274
- sink: Sink,
275
- ): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
276
- const service = yield* mounted.comp.tag
277
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
278
- const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
279
- const frame = yield* Composition.render(service, env)
280
- endRenderSpan()
281
- if (!isStructure(frame)) {
282
- return yield* Effect.dieMessage(
283
- `reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
284
- )
285
- }
286
- return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
287
- })
288
-
289
- // One breadth-first render of a whole level. Each mounted node reports its
290
- // `(props, events)` to the sink and yields the next level of children.
291
- const renderLevel: (
292
- frontier: ReadonlyArray<Mounted>,
293
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
294
- sink: Sink,
295
- ) => Effect.Effect<void, never, CompositionService> = Effect.fn('renderLevel')(function* (
296
- frontier: ReadonlyArray<Mounted>,
297
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
298
- sink: Sink,
299
- ): Effect.fn.Return<void, never, CompositionService> {
300
- if (frontier.length === 0) {
301
- return
302
- }
303
- const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted, bindings, sink))
304
- yield* renderLevel(levels.flat(), bindings, sink)
305
- })
306
-
307
- const renderTree = Effect.fn('renderTree')(function* (
308
- root: CompositionClass<unknown>,
309
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
310
- sink: Sink,
311
- ): Effect.fn.Return<void, never, CompositionService> {
312
- sink.reset()
313
- yield* renderLevel([{ comp: root, props: {}, key: Option.none() }], bindings, sink)
314
- })
315
-
316
- const capturesFor = (sink: Sink, name: string): ReadonlyArray<UiCapture> =>
317
- sink.captures.filter((capture) => capture.name === name)
318
-
319
- // A cheap fingerprint of the rendered tree; when two successive renders match,
320
- // the engine has stopped producing new state and the read is safe.
321
- const fingerprint = (sink: Sink): string =>
322
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- structural fixpoint fingerprint of the captured tree; not Schema-typed data
323
- JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
324
-
325
- const settleTree = Effect.fn('settleTree')(function* (
326
- root: CompositionClass<unknown>,
327
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
328
- sink: Sink,
329
- ): Effect.fn.Return<void, never, CompositionService> {
330
- yield* settleDrain
331
- yield* renderTree(root, bindings, sink)
332
- yield* Effect.iterate(
333
- { previous: fingerprint(sink), remaining: SETTLE_MAX_RENDERS, stable: false },
334
- {
335
- while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
336
- body: (progress) =>
337
- Effect.gen(function* () {
338
- yield* settleDrain
339
- yield* Effect.sleep(SETTLE_STEP)
340
- yield* renderTree(root, bindings, sink)
341
- const current = fingerprint(sink)
342
- return {
343
- previous: current,
344
- remaining: progress.remaining - 1,
345
- stable: current === progress.previous,
346
- }
347
- }),
348
- },
349
- )
350
- })
351
-
352
- const makeFacadeEffect = Effect.fn('makeFacadeEffect')(function* (
353
- root: CompositionClass<unknown>,
354
- sink: Sink,
355
- // Records every event name dispatched through the facade, so a proof's
356
- // requirement-declared event coverage can be verified after the body runs.
357
- dispatched: Set<string>,
358
- ): Effect.fn.Return<
359
- { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> },
360
- never,
361
- SlotChild
362
- > {
363
- const bindings = yield* collectBindings(root)
364
- const settle = settleTree(root, bindings, sink)
365
- const rerender = renderTree(root, bindings, sink)
366
-
367
- const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
368
- Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
369
- onNone: () =>
370
- Effect.dieMessage(
371
- new UnknownAction({
372
- composition: ref.name,
373
- action: event,
374
- rendered: capturesFor(sink, ref.name).length,
375
- }).message,
376
- ),
377
- onSome: Effect.succeed,
378
- })
379
-
380
- const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
381
- Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
382
-
383
- const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
384
- props: propsFor(ref),
385
- // Assert the computed props match a typed subset. Reuses the same partial-match
386
- // logic as `expect(...).toMatchObject`; dies with `AssertionFailed` on mismatch.
387
- expectProps: (partial) =>
388
- propsFor(ref).pipe(
389
- Effect.flatMap((props) => {
390
- const mismatch = matchProps({ actual: props, expected: partial })
391
- return mismatch === undefined
392
- ? Effect.void
393
- : Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
394
- }),
395
- ),
396
- // `keyed` resolves event/slot names at runtime; the string-keyed proxy is the one
397
- // reflection boundary — every name it serves is a real contract member, so reading
398
- // it as the contract-typed surface is sound. The action resolves to the props this
399
- // node computed once the dispatch settled (the typed "result" of the event).
400
- // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract event names
401
- actions: keyed(
402
- (event): Action =>
403
- (payload) =>
404
- rerender.pipe(
405
- Effect.flatMap(() => triggerOf(ref, event)),
406
- Effect.flatMap((trigger) =>
407
- Effect.sync(() => {
408
- dispatched.add(event)
409
- trigger(payload)
410
- }),
411
- ),
412
- Effect.flatMap(() => settle),
413
- Effect.flatMap(() => propsFor(ref)),
414
- ),
415
- ) as AnyFacade['actions'],
416
- // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract slot names
417
- slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
418
- frame: settle,
419
- })
420
-
421
- const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
422
- const slotClass = slotsOf(parent)[slotName]
423
- const child = slotClass && bindings.get(slotClass)
424
- if (child === undefined) {
425
- // oxlint-disable-next-line reform-rules/no-throw -- proof navigated to an undeclared slot: programmer error surfaced as a defect during generator-time proxy access
426
- throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
427
- }
428
- const childName = uiNameOf(child)
429
- const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
430
- Effect.as(rerender, nodeFacade({ name: childName, index }, child))
431
- const first = nodeFacade({ name: childName, index: 0 }, child)
432
- // The render index of the child whose structure-fill `key` matches (plan 06).
433
- // Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
434
- // selection is by stable identity, not render order — `none` when no fill carries it.
435
- const indexOfKey = (key: string): Option.Option<number> =>
436
- Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
437
- return {
438
- first: atIndex(0),
439
- at: atIndex,
440
- all: Effect.map(rerender, () =>
441
- capturesFor(sink, childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
442
- ),
443
- // Select the one child mounted under `key` (the `each` item key). Re-renders
444
- // first so the fill keys reflect the latest frame, then resolves its facade;
445
- // dies with `UnknownSlot` (key in the slot position) when no fill carries it.
446
- byKey: (key) =>
447
- Effect.flatMap(rerender, () =>
448
- Option.match(indexOfKey(key), {
449
- onNone: () =>
450
- Effect.dieMessage(
451
- new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` }).message,
452
- ),
453
- onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
454
- }),
455
- ),
456
- // Every child whose computed props satisfy `predicate`. Re-renders, then maps
457
- // the matching captures back to their facades by render index.
458
- where: (predicate) =>
459
- Effect.map(rerender, () =>
460
- capturesFor(sink, childName).flatMap((capture, index) =>
461
- predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
462
- ),
463
- ),
464
- // How many children this slot mounted this frame — the fill length.
465
- count: Effect.map(rerender, () => capturesFor(sink, childName).length),
466
- // Used directly, a slot behaves as its first instance.
467
- props: first.props,
468
- expectProps: first.expectProps,
469
- actions: first.actions,
470
- slots: first.slots,
471
- frame: first.frame,
472
- }
473
- }
474
-
475
- return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
476
- })
477
-
478
- export const makeFacade = (
479
- runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
480
- root: CompositionClass<unknown>,
481
- sink: Sink,
482
- dispatched: Set<string>,
483
- ): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } =>
484
- runtime.runSync(makeFacadeEffect(root, sink, dispatched))
485
-
486
- /** Declared-but-never-dispatched events for a proof's requirement (coverage). */
487
- const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
488
- Option.match(proof.requirement.manifest.events, {
489
- onNone: () => [],
490
- onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
491
- })
492
-
493
- const bootScene = (scene: Scene): Effect.Effect<void, never, Bus> =>
494
- Effect.forEach(
495
- Option.getOrElse(Option.fromNullable(scene.boot), () => []),
496
- (event) => publish('High', event),
497
- ).pipe(Effect.asVoid)
498
-
499
- const executeProofEffect = Effect.fn('executeProofEffect')(function* (
500
- proof: Proof,
501
- sink: Sink,
502
- ): Effect.fn.Return<ProofResult, never, RuntimeServices> {
503
- const dispatched = new Set<string>()
504
- const statement = proof.requirement.manifest.statement
505
- return yield* Effect.gen(function* () {
506
- const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
507
- yield* bootScene(proof.scene)
508
- yield* settle
509
- yield* Effect.gen(() => proof.body(facade))
510
- const missing = missingCoverage(proof, dispatched)
511
- const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
512
- return missing.length > 0
513
- ? { requirement: statement, ok: false, error: coverageError }
514
- : { requirement: statement, ok: true }
515
- }).pipe(
516
- Effect.catchAllCause((cause) =>
517
- Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
518
- ),
519
- )
520
- })
521
-
522
- /**
523
- * Run one proof against a fresh runtime, returning its pass/fail result. Each
524
- * proof gets its own isolated environment, so nothing leaks between proofs. Boots
525
- * the scene as the host would, settles, then runs the proof body to completion.
526
- */
527
- export const executeProof = async (proof: Proof): Promise<ProofResult> => {
528
- const sink = makeSink(sceneInstrumentation(proof.scene))
529
- return Effect.runPromise(executeProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
530
- }
531
-
532
- /**
533
- * Drive one proof a step at a time, recording a `StepFrame` after each yielded
534
- * effect settles. Reuses the same facade/settle engine as `executeProof`, but
535
- * instead of handing the body to `Effect.gen` (which runs it to completion), it
536
- * pumps the generator by hand — `gen.next(value)` yields the next effect, the
537
- * active proof layer runs it, then the driver snapshots the sink and feeds the
538
- * result back. So the editor gets the per-step timeline with no change to the
539
- * proof authoring API.
540
- */
541
- const driveProofEffect = Effect.fn('driveProofEffect')(function* (
542
- proof: Proof,
543
- sink: Sink,
544
- ): Effect.fn.Return<DriveResult, never, RuntimeServices> {
545
- const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
546
- const statement = proof.requirement.manifest.statement
547
- // The driver's per-frame timeline does not enforce coverage; collect into a
548
- // throwaway set so `makeFacadeEffect`'s contract is satisfied.
549
- const dispatched = new Set<string>()
550
- const record = (index: number): Effect.Effect<void> =>
551
- Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
552
- // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
553
- const pump = Effect.fn('pump')(function* (
554
- generator: ReturnType<Proof['body']>,
555
- step: PumpStep,
556
- ): Effect.fn.Return<void, unknown, CompositionService> {
557
- const next = generator.next(step.input)
558
- if (next.done === true) {
559
- return
560
- }
561
- const output = yield* yieldWrapGet(next.value)
562
- yield* record(step.index)
563
- yield* pump(generator, { input: output, index: step.index + 1 })
564
- })
565
- return yield* Effect.gen(function* () {
566
- const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
567
- yield* bootScene(proof.scene)
568
- yield* settle
569
- yield* record(0)
570
- yield* pump(proof.body(facade), { input: undefined, index: 1 })
571
- return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
572
- }).pipe(
573
- Effect.catchAllCause((cause) =>
574
- Ref.get(frames).pipe(
575
- Effect.map((collected) => ({
576
- requirement: statement,
577
- frames: collected,
578
- ok: false,
579
- error: messageOf(Cause.squash(cause)),
580
- })),
581
- ),
582
- ),
583
- )
584
- })
585
-
586
- export const driveProof = async (proof: Proof): Promise<DriveResult> => {
587
- const sink = makeSink(sceneInstrumentation(proof.scene))
588
- return Effect.runPromise(driveProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
589
- }
1
+ export { makeFacade, makeFacadeEffect } from './facade'
2
+ export { makeRuntimeHandleFacade } from './runtimeFacade'
3
+ export type { MountedFacade, MountedSlotFacade } from './runtimeFacade'
4
+ export { makeRuntimeTreeFacade } from './runtimeTreeFacade'
5
+ export type { RuntimeTreeFacade } from './runtimeTreeTypes'
6
+ export { makeSink, proofLayer } from './sink'
7
+ export type { RuntimeServices, Sink } from './sink'
8
+ export { driveProof, executeProof } from './execution'
package/src/errors.ts CHANGED
@@ -1,16 +1,5 @@
1
1
  import { type Cause, Data } from 'effect'
2
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
3
  type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
15
4
  args: A,
16
5
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
@@ -20,7 +9,6 @@ const AssertionFailedBase: TaggedErrorClass<
20
9
  { readonly detail: string }
21
10
  > = Data.TaggedError('reform-proof/AssertionFailed')<{ readonly detail: string }>
22
11
 
23
- /** An `expect(...)` matcher did not hold. */
24
12
  export class AssertionFailed extends AssertionFailedBase {
25
13
  override get message(): string {
26
14
  return `reform-proof: assertion failed — ${this.detail}`
@@ -36,7 +24,6 @@ const UnknownActionBase: TaggedErrorClass<
36
24
  readonly rendered: number
37
25
  }>
38
26
 
39
- /** A proof referenced an action the rendered composition never exposed. */
40
27
  export class UnknownAction extends UnknownActionBase {
41
28
  override get message(): string {
42
29
  return `reform-proof: '${this.composition}' has no action '${this.action}' (rendered ${this.rendered} instance(s))`
@@ -48,7 +35,6 @@ const UnknownSlotBase: TaggedErrorClass<
48
35
  { readonly parent: string; readonly slot: string }
49
36
  > = Data.TaggedError('reform-proof/UnknownSlot')<{ readonly parent: string; readonly slot: string }>
50
37
 
51
- /** A proof navigated into a slot the parent composition does not declare. */
52
38
  export class UnknownSlot extends UnknownSlotBase {
53
39
  override get message(): string {
54
40
  return `reform-proof: '${this.parent}' has no slot '${this.slot}'`