@playfast/reform-proof 0.0.11 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/engine.ts +289 -220
package/README.md CHANGED
@@ -1,24 +1,24 @@
1
1
  <div align="center">
2
2
 
3
- # `@playfast/proof`
3
+ # `@playfast/reform-proof`
4
4
 
5
5
  **A headless testing toolkit for [reform](https://www.npmjs.com/package/@playfast/reform).**
6
6
  Drive a scene's compositions and assert on what they compute — deterministically, with no DOM, no timers, and no text matching.
7
7
 
8
- [![npm](https://img.shields.io/npm/v/@playfast/proof.svg)](https://www.npmjs.com/package/@playfast/proof)
9
- [![license](https://img.shields.io/npm/l/@playfast/proof.svg)](#license)
8
+ [![npm](https://img.shields.io/npm/v/@playfast/reform-proof.svg)](https://www.npmjs.com/package/@playfast/reform-proof)
9
+ [![license](https://img.shields.io/npm/l/@playfast/reform-proof.svg)](#license)
10
10
  [![built with Effect](https://img.shields.io/badge/built%20with-Effect-5a67d8.svg)](https://effect.website)
11
11
 
12
12
  </div>
13
13
 
14
14
  ---
15
15
 
16
- A reform *scene* is renderer-neutral, so it can be exercised without a host. `@playfast/proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/react` mounts, so there is no seam between a passing test and production behavior.
16
+ A reform *scene* is renderer-neutral, so it can be exercised without a host. `@playfast/reform-proof` runs that same scene against a capturing UI: it dispatches typed events, settles the engine, and lets you assert on the **props a composition computed** — pure data, never rendered markup. The scene you prove is the exact scene `@playfast/reform-react` renders, so there is no seam between a passing test and production behavior.
17
17
 
18
18
  ## Install
19
19
 
20
20
  ```sh
21
- bun add -d @playfast/proof @playfast/reform effect react
21
+ bun add -d @playfast/reform-proof @playfast/reform effect react
22
22
  ```
23
23
 
24
24
  Peers: `effect`, `react` (`^19`), and `@playfast/reform`.
@@ -80,7 +80,7 @@ There is no real clock. `settle` cooperatively drains forked fibers and loops un
80
80
 
81
81
  ## The reform family
82
82
 
83
- Core [`@playfast/reform`](https://www.npmjs.com/package/@playfast/reform) · hosts [`@playfast/react`](https://www.npmjs.com/package/@playfast/react) / [`@playfast/react-native`](https://www.npmjs.com/package/@playfast/react-native) · forms [`@playfast/forms`](https://www.npmjs.com/package/@playfast/forms) + [`@playfast/forms-react`](https://www.npmjs.com/package/@playfast/forms-react)
83
+ Core [`@playfast/reform`](https://www.npmjs.com/package/@playfast/reform) · hosts [`@playfast/reform-react`](https://www.npmjs.com/package/@playfast/reform-react) / [`@playfast/reform-react-native`](https://www.npmjs.com/package/@playfast/reform-react-native) · forms [`@playfast/reform-forms`](https://www.npmjs.com/package/@playfast/reform-forms) + [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react)
84
84
 
85
85
  ## License
86
86
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-proof",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.11",
4
+ "version": "1.0.1",
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": [
package/src/engine.ts CHANGED
@@ -1,4 +1,15 @@
1
- import { Array as Arr, Cause, Effect, Layer, Match, ManagedRuntime, Option, Record as Rec } from 'effect'
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'
2
13
  import { yieldWrapGet } from 'effect/Utils'
3
14
  import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
4
15
  import {
@@ -8,11 +19,14 @@ import {
8
19
  Composition,
9
20
  type CompositionClass,
10
21
  type CompositionService,
22
+ type Instrumentation,
11
23
  isFeatureBinding,
12
24
  isStructure,
25
+ noopInstrumentation,
13
26
  publish,
14
27
  type RenderEnv,
15
28
  type Scene,
29
+ sceneInstrumentation,
16
30
  type SlotChild,
17
31
  type SlotClass,
18
32
  type SlotFill,
@@ -77,23 +91,26 @@ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unkn
77
91
  export interface Sink {
78
92
  readonly api: CaptureSinkApi
79
93
  readonly captures: ReadonlyArray<UiCapture>
94
+ /** The scene's profiling hooks — render spans record here (noop unless `profileScene`d). */
95
+ readonly instrumentation: Instrumentation
80
96
  reset(): void
81
97
  }
82
98
 
83
- export const makeSink = (): Sink => {
84
- // A const holder whose array we swap on reset (no reassigned binding).
85
- const state: { captures: UiCapture[] } = { captures: [] }
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[]>([])
86
102
  return {
87
103
  api: {
88
104
  record: (capture) => {
89
- state.captures = [...state.captures, capture]
105
+ MutableRef.update(captures, (current) => [...current, capture])
90
106
  },
91
107
  },
92
108
  get captures() {
93
- return state.captures
109
+ return MutableRef.get(captures)
94
110
  },
111
+ instrumentation,
95
112
  reset: () => {
96
- state.captures = []
113
+ MutableRef.set(captures, [])
97
114
  },
98
115
  }
99
116
  }
@@ -135,10 +152,11 @@ interface NodeRef {
135
152
  readonly index: number
136
153
  }
137
154
 
138
- /** Settle progress: the previous fingerprint and how many renders remain. */
155
+ /** Settle progress: the previous fingerprint, remaining renders, and early-stop state. */
139
156
  interface SettleProgress {
140
157
  readonly previous: string
141
158
  readonly remaining: number
159
+ readonly stable: boolean
142
160
  }
143
161
 
144
162
  /** One step of the driver's generator pump: the value to feed in and the frame index. */
@@ -147,184 +165,220 @@ interface PumpStep {
147
165
  readonly index: number
148
166
  }
149
167
 
150
- export const makeFacade = (
151
- runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
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* (
152
201
  root: CompositionClass<unknown>,
153
- sink: Sink,
154
- // Records every event name dispatched through the facade, so a proof's
155
- // requirement-declared event coverage can be verified after the body runs.
156
- dispatched: Set<string>,
157
- ): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } => {
158
- // Slot bindings (`provide(slot, composition)`) are static, so resolve the
159
- // whole child tree once, up front — keeping `renderTree` and slot navigation
160
- // synchronous (no nested runtime drive).
202
+ ): Effect.fn.Return<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> {
161
203
  const bindings = new Map<SlotClass, CompositionClass<unknown>>()
162
- // A slot's child is a plain composition or a `FeatureBinding`. A proof drives the
163
- // composition the feature mounts; for a `default` feature that composition's logic
164
- // is already live (its eager `.live` was merged into the scene by `provide`), so
165
- // unwrapping is all that's needed. (Driving a `lazy` feature — load gap +
166
- // placeholders — is the proof host's Part B increment; until then a scene wires
167
- // features as `default`, the eagerly-resolvable form.)
168
- const childComposition = (child: SlotChild): CompositionClass<unknown> =>
169
- isFeatureBinding(child) ? child.composition : child
170
- const slotClassesOf = (comp: CompositionClass<unknown>): ReadonlyArray<SlotClass> =>
171
- Option.fromNullable(comp.manifest.slots).pipe(
172
- Option.map((slots) => Object.values(slots)),
173
- Option.getOrElse(() => []),
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
+ }),
174
216
  )
175
- const bindChild = Effect.fn('bindChild')(function* (
176
- slotClass: SlotClass,
177
- ): Effect.fn.Return<void, never, SlotChild> {
178
- const child = childComposition(yield* slotClass.tag)
179
- bindings.set(slotClass, child)
180
- yield* collect(child)
181
217
  })
182
- const collect: (comp: CompositionClass<unknown>) => Effect.Effect<void, never, SlotChild> =
183
- Effect.fn('collect')(function* (
184
- comp: CompositionClass<unknown>,
185
- ): Effect.fn.Return<void, never, SlotChild> {
186
- yield* Effect.forEach(slotClassesOf(comp), (slotClass) =>
187
- bindings.has(slotClass) ? Effect.void : bindChild(slotClass),
188
- )
189
- })
190
- runtime.runSync(collect(root))
191
-
192
- // Render one mounted composition, returning the children its fills enqueue. Every
193
- // composition returns a `Structure` (no view is ever executed — the proof is
194
- // view-free / React-free): read its computed props straight off the value, record
195
- // them + the events it carries into the sink (so the facade, `triggerOf`, and
196
- // navigation work uniformly), and walk its slot FILLS to enqueue children.
197
- const renderMounted = Effect.fn('renderMounted')(function* (
198
- mounted: Mounted,
199
- ): Effect.fn.Return<ReadonlyArray<Mounted>, never, CompositionService> {
200
- const service = yield* mounted.comp.tag
201
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
202
- const frame = yield* Composition.render(service, env)
203
- if (!isStructure(frame)) {
204
- return yield* Effect.dieMessage(
205
- `reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
206
- )
207
- }
208
- return driveStructure(mounted.comp, frame, mounted.key)
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)
209
265
  })
266
+ }
210
267
 
211
- // One breadth-first render of a whole level. Each mounted node reports its
212
- // `(props, events)` to the sink and yields the next level of children, so a
213
- // single recursive sweep renders everything.
214
- const renderLevel: (frontier: ReadonlyArray<Mounted>) => Effect.Effect<void, never, CompositionService> =
215
- Effect.fn('renderLevel')(function* (
216
- frontier: ReadonlyArray<Mounted>,
217
- ): Effect.fn.Return<void, never, CompositionService> {
218
- if (frontier.length === 0) {
219
- return
220
- }
221
- const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted))
222
- yield* renderLevel(levels.flat())
223
- })
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
+ })
224
288
 
225
- // Consume a `Structure` frame: record the node's computed props (the structure
226
- // value already holds them — the proof never derives them from a view) AND the
227
- // event triggers it carries on `structure.events` (plan 03b), then enqueue one
228
- // child per slot fill with the per-item props the fill carries. Recording the
229
- // events into the capture the same way the view path does means `triggerOf` /
230
- // `app.actions` resolve identically for Structure frames; the index-keyed enqueue
231
- // order matches the legacy node walk.
232
- const driveStructure = (
233
- comp: CompositionClass<unknown>,
234
- structure: Structure<UiContract>,
235
- key: Option.Option<string>,
236
- ): ReadonlyArray<Mounted> => {
237
- sink.api.record({
238
- name: uiNameOf(comp),
239
- props: structure.props,
240
- events: eventsOf(structure),
241
- ...(Option.isSome(key) ? { key: key.value } : {}),
242
- })
243
- const fills: Record<string, SlotFill<unknown>> = structure.slots
244
- return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
245
- const slotClass = comp.manifest.slots?.[slotName]
246
- const child = slotClass && bindings.get(slotClass)
247
- return child === undefined ? [] : enqueueFill(slotName, fill, child)
248
- })
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
249
302
  }
303
+ const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted, bindings, sink))
304
+ yield* renderLevel(levels.flat(), bindings, sink)
305
+ })
250
306
 
251
- // Expand one slot fill into the children to render next. `Each` mounts one
252
- // child per keyed item with that item's props and key; `One` mounts a single
253
- // child under a synthesized singleton key; `Absent` mounts nothing (the
254
- // data-driven `cond && …`). The key rides each `Mounted` so the child's capture
255
- // can record it, letting the facade select by key (`byKey`).
256
- const enqueueFill = (
257
- slotName: string,
258
- fill: SlotFill<unknown>,
259
- child: CompositionClass<unknown>,
260
- ): ReadonlyArray<Mounted> =>
261
- Match.value(fill).pipe(
262
- Match.when({ _tag: 'Each' }, (each) =>
263
- each.items.map((entry) => ({
264
- comp: child,
265
- props: entry.props,
266
- key: Option.some(entry.key),
267
- })),
268
- ),
269
- Match.when({ _tag: 'One' }, (oneFill) => [
270
- { comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
271
- ]),
272
- Match.when({ _tag: 'Absent' }, () => []),
273
- Match.exhaustive,
274
- )
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
+ })
275
315
 
276
- const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
277
- sink.reset()
278
- yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
279
- })
316
+ const capturesFor = (sink: Sink, name: string): ReadonlyArray<UiCapture> =>
317
+ sink.captures.filter((capture) => capture.name === name)
280
318
 
281
- const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
282
- sink.captures.filter((capture) => capture.name === name)
283
-
284
- // A cheap fingerprint of the rendered tree; when two successive renders match,
285
- // the engine has stopped producing new state and the read is safe.
286
- const fingerprint = (): string =>
287
- // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- structural fixpoint fingerprint of the captured tree; not Schema-typed data
288
- JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
289
-
290
- // Re-render until the tree is stable for two consecutive renders, or give up.
291
- const settleFrom: (progress: SettleProgress) => Effect.Effect<void, never, CompositionService> =
292
- Effect.fn('settleFrom')(function* (
293
- progress: SettleProgress,
294
- ): Effect.fn.Return<void, never, CompositionService> {
295
- if (progress.remaining === 0) {
296
- return
297
- }
298
- yield* settleDrain
299
- yield* Effect.sleep(SETTLE_STEP)
300
- yield* renderTree
301
- const current = fingerprint()
302
- if (current === progress.previous) {
303
- return
304
- }
305
- yield* settleFrom({ previous: current, remaining: progress.remaining - 1 })
306
- })
307
- const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
308
- yield* settleDrain
309
- yield* renderTree
310
- yield* settleFrom({ previous: fingerprint(), remaining: SETTLE_MAX_RENDERS })
311
- })
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)
312
366
 
313
367
  const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
314
- Option.match(Option.fromNullable(capturesFor(ref.name)[ref.index]?.events[event]), {
368
+ Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
315
369
  onNone: () =>
316
370
  Effect.dieMessage(
317
371
  new UnknownAction({
318
372
  composition: ref.name,
319
373
  action: event,
320
- rendered: capturesFor(ref.name).length,
374
+ rendered: capturesFor(sink, ref.name).length,
321
375
  }).message,
322
376
  ),
323
377
  onSome: Effect.succeed,
324
378
  })
325
379
 
326
380
  const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
327
- Effect.map(renderTree, () => capturesFor(ref.name)[ref.index]?.props)
381
+ Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
328
382
 
329
383
  const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
330
384
  props: propsFor(ref),
@@ -347,7 +401,7 @@ export const makeFacade = (
347
401
  actions: keyed(
348
402
  (event): Action =>
349
403
  (payload) =>
350
- renderTree.pipe(
404
+ rerender.pipe(
351
405
  Effect.flatMap(() => triggerOf(ref, event)),
352
406
  Effect.flatMap((trigger) =>
353
407
  Effect.sync(() => {
@@ -365,7 +419,7 @@ export const makeFacade = (
365
419
  })
366
420
 
367
421
  const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
368
- const slotClass = parent.manifest.slots?.[slotName]
422
+ const slotClass = slotsOf(parent)[slotName]
369
423
  const child = slotClass && bindings.get(slotClass)
370
424
  if (child === undefined) {
371
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
@@ -373,24 +427,24 @@ export const makeFacade = (
373
427
  }
374
428
  const childName = uiNameOf(child)
375
429
  const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
376
- Effect.as(renderTree, nodeFacade({ name: childName, index }, child))
430
+ Effect.as(rerender, nodeFacade({ name: childName, index }, child))
377
431
  const first = nodeFacade({ name: childName, index: 0 }, child)
378
432
  // The render index of the child whose structure-fill `key` matches (plan 06).
379
433
  // Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
380
434
  // selection is by stable identity, not render order — `none` when no fill carries it.
381
435
  const indexOfKey = (key: string): Option.Option<number> =>
382
- Arr.findFirstIndex(capturesFor(childName), (capture) => capture.key === key)
436
+ Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
383
437
  return {
384
438
  first: atIndex(0),
385
439
  at: atIndex,
386
- all: Effect.map(renderTree, () =>
387
- capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
440
+ all: Effect.map(rerender, () =>
441
+ capturesFor(sink, childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
388
442
  ),
389
443
  // Select the one child mounted under `key` (the `each` item key). Re-renders
390
444
  // first so the fill keys reflect the latest frame, then resolves its facade;
391
445
  // dies with `UnknownSlot` (key in the slot position) when no fill carries it.
392
446
  byKey: (key) =>
393
- Effect.flatMap(renderTree, () =>
447
+ Effect.flatMap(rerender, () =>
394
448
  Option.match(indexOfKey(key), {
395
449
  onNone: () =>
396
450
  Effect.dieMessage(
@@ -402,13 +456,13 @@ export const makeFacade = (
402
456
  // Every child whose computed props satisfy `predicate`. Re-renders, then maps
403
457
  // the matching captures back to their facades by render index.
404
458
  where: (predicate) =>
405
- Effect.map(renderTree, () =>
406
- capturesFor(childName).flatMap((capture, index) =>
459
+ Effect.map(rerender, () =>
460
+ capturesFor(sink, childName).flatMap((capture, index) =>
407
461
  predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
408
462
  ),
409
463
  ),
410
464
  // How many children this slot mounted this frame — the fill length.
411
- count: Effect.map(renderTree, () => capturesFor(childName).length),
465
+ count: Effect.map(rerender, () => capturesFor(sink, childName).length),
412
466
  // Used directly, a slot behaves as its first instance.
413
467
  props: first.props,
414
468
  expectProps: first.expectProps,
@@ -419,7 +473,15 @@ export const makeFacade = (
419
473
  }
420
474
 
421
475
  return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
422
- }
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))
423
485
 
424
486
  /** Declared-but-never-dispatched events for a proof's requirement (coverage). */
425
487
  const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
@@ -428,22 +490,21 @@ const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<s
428
490
  onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
429
491
  })
430
492
 
431
- /**
432
- * Run one proof against a fresh runtime, returning its pass/fail result. Each
433
- * proof gets its own isolated environment, so nothing leaks between proofs. Boots
434
- * the scene as the host would, settles, then runs the proof body to completion.
435
- */
436
- export const executeProof = async (proof: Proof): Promise<ProofResult> => {
437
- const sink = makeSink()
438
- const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
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> {
439
503
  const dispatched = new Set<string>()
440
504
  const statement = proof.requirement.manifest.statement
441
- const program = Effect.gen(function* () {
442
- const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
443
- yield* Effect.forEach(
444
- Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
445
- (event) => publish('High', event),
446
- )
505
+ return yield* Effect.gen(function* () {
506
+ const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
507
+ yield* bootScene(proof.scene)
447
508
  yield* settle
448
509
  yield* Effect.gen(() => proof.body(facade))
449
510
  const missing = missingCoverage(proof, dispatched)
@@ -456,31 +517,38 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
456
517
  Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
457
518
  ),
458
519
  )
459
- const report = await runtime.runPromise(program)
460
- await runtime.dispose()
461
- return report
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))))
462
530
  }
463
531
 
464
532
  /**
465
533
  * Drive one proof a step at a time, recording a `StepFrame` after each yielded
466
- * effect settles. Reuses the same makeFacade runtime/facade/settle as
467
- * `executeProof`, but instead of handing the body to `Effect.gen` (which runs it
468
- * to completion), it pumps the generator by hand — `gen.next(value)` yields the
469
- * next effect, we run it on the live runtime, snapshot the sink, and feed the
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
470
538
  * result back. So the editor gets the per-step timeline with no change to the
471
539
  * proof authoring API.
472
540
  */
473
- export const driveProof = async (proof: Proof): Promise<DriveResult> => {
474
- const sink = makeSink()
475
- const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
476
- const collected: { frames: ReadonlyArray<StepFrame> } = { frames: [] }
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>>([])
477
546
  const statement = proof.requirement.manifest.statement
478
547
  // The driver's per-frame timeline does not enforce coverage; collect into a
479
- // throwaway set so `makeFacade`'s contract is satisfied.
548
+ // throwaway set so `makeFacadeEffect`'s contract is satisfied.
480
549
  const dispatched = new Set<string>()
481
- const record = (index: number): void => {
482
- collected.frames = [...collected.frames, { index, captures: [...sink.captures] }]
483
- }
550
+ const record = (index: number): Effect.Effect<void> =>
551
+ Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
484
552
  // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
485
553
  const pump = Effect.fn('pump')(function* (
486
554
  generator: ReturnType<Proof['body']>,
@@ -491,30 +559,31 @@ export const driveProof = async (proof: Proof): Promise<DriveResult> => {
491
559
  return
492
560
  }
493
561
  const output = yield* yieldWrapGet(next.value)
494
- yield* Effect.sync(() => record(step.index))
562
+ yield* record(step.index)
495
563
  yield* pump(generator, { input: output, index: step.index + 1 })
496
564
  })
497
- const program = Effect.gen(function* () {
498
- const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
499
- yield* Effect.forEach(
500
- Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
501
- (event) => publish('High', event),
502
- )
565
+ return yield* Effect.gen(function* () {
566
+ const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
567
+ yield* bootScene(proof.scene)
503
568
  yield* settle
504
- yield* Effect.sync(() => record(0))
569
+ yield* record(0)
505
570
  yield* pump(proof.body(facade), { input: undefined, index: 1 })
506
- return { requirement: statement, frames: collected.frames, ok: true }
571
+ return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
507
572
  }).pipe(
508
573
  Effect.catchAllCause((cause) =>
509
- Effect.succeed({
510
- requirement: statement,
511
- frames: collected.frames,
512
- ok: false,
513
- error: messageOf(Cause.squash(cause)),
514
- }),
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
+ ),
515
582
  ),
516
583
  )
517
- const timeline = await runtime.runPromise(program)
518
- await runtime.dispose()
519
- return timeline
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))))
520
589
  }