@playfast/reform-proof 1.0.1 → 1.2.0

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,104 +1,206 @@
1
1
  import {
2
2
  Array as Arr,
3
3
  Cause,
4
+ Context,
4
5
  Effect,
5
6
  Layer,
6
7
  ManagedRuntime,
7
8
  Match,
8
9
  MutableRef,
9
10
  Option,
11
+ Order,
10
12
  Record as Rec,
11
13
  Ref,
12
14
  } from 'effect'
13
15
  import { yieldWrapGet } from 'effect/Utils'
14
16
  import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
15
17
  import {
16
- Bus,
17
- CaptureSink,
18
- type CaptureSinkApi,
18
+ type CapturedScene,
19
19
  Composition,
20
20
  type CompositionClass,
21
21
  type CompositionService,
22
- type Instrumentation,
22
+ type FeatureLoadFailed,
23
23
  isFeatureBinding,
24
+ isSlot,
24
25
  isStructure,
25
- noopInstrumentation,
26
+ makeAppRuntime,
26
27
  publish,
27
- type RenderEnv,
28
- type Scene,
28
+ type RuntimeHandle,
29
29
  sceneInstrumentation,
30
- type SlotChild,
31
- type SlotClass,
32
30
  type SlotFill,
33
31
  type Structure,
34
32
  type Trigger,
35
- type UiCapture,
36
33
  type UiContract,
37
34
  } from '@playfast/reform'
35
+ import {
36
+ type AnyComposition,
37
+ type AnyFeatureBinding,
38
+ type AnyScene,
39
+ type AnySlot,
40
+ type AnySlotChild,
41
+ Bus,
42
+ type CapturedRender,
43
+ CaptureSink,
44
+ type CaptureSinkApi,
45
+ type Instrumentation,
46
+ noopInstrumentation,
47
+ } from '@playfast/reform/internal'
38
48
  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.
49
+ import type {
50
+ DriveResult,
51
+ Facade,
52
+ Proof,
53
+ ProofCase,
54
+ ProductComposition,
55
+ ProofResult,
56
+ SlotFacade,
57
+ SlotFacadesOf,
58
+ StepFrame,
59
+ } from './index'
60
+ import { formatFingerprint } from './fingerprint'
61
+
62
+ type RenderRuntime = Pick<RuntimeHandle, 'dispatch' | 'instrumentation' | 'readOption'>
63
+
64
+ type HostRuntime = Pick<
65
+ RuntimeHandle,
66
+ 'dispatch' | 'instrumentation' | 'mountAnyFeature' | 'readOption'
67
+ >
68
+
69
+ type RuntimeRootComposition<
70
+ Identifier,
71
+ P,
72
+ C extends UiContract,
73
+ S extends ReadonlyArray<unknown>,
74
+ N extends string,
75
+ Identity,
76
+ > = CompositionClass<P, C, S, N, Identity> & {
77
+ readonly tag: Context.Tag<Identifier, CompositionService<P, C>>
78
+ }
79
+
80
+ interface MountedFacadeErasure {
81
+ readonly props: Effect.Effect<unknown, never, never>
82
+ readonly expectProps: (partial: unknown) => Effect.Effect<void, never, never>
83
+ readonly actions: Readonly<
84
+ Record<string, (payload: unknown) => Effect.Effect<unknown, never, never>>
85
+ >
86
+ readonly slots: Readonly<Record<string, MountedSlotFacadeErasure>>
87
+ readonly frame: Effect.Effect<void, never, never>
88
+ }
89
+
90
+ interface MountedSlotFacadeErasure extends MountedFacadeErasure {
91
+ readonly first: Effect.Effect<MountedFacadeErasure, never, never>
92
+ readonly at: (index: number) => Effect.Effect<MountedFacadeErasure, never, never>
93
+ readonly all: Effect.Effect<ReadonlyArray<MountedFacadeErasure>, never, never>
94
+ readonly byKey: (key: string) => Effect.Effect<MountedFacadeErasure, never, never>
95
+ readonly where: (
96
+ predicate: (props: unknown) => boolean,
97
+ ) => Effect.Effect<ReadonlyArray<MountedFacadeErasure>, never, never>
98
+ readonly count: Effect.Effect<number, never, never>
99
+ }
100
+
101
+ interface MountedFacadeHandleErasure {
102
+ readonly facade: {
103
+ readonly props: Effect.Effect<unknown, never, never>
104
+ readonly expectProps: (partial: never) => Effect.Effect<void, never, never>
105
+ readonly actions: object
106
+ readonly slots: object
107
+ readonly frame: Effect.Effect<void, never, never>
108
+ }
109
+ readonly settle: Effect.Effect<void, never, never>
110
+ }
111
+
112
+ type Settlement = Effect.Effect<void, never, never>
113
+ type SettleBoundary = (settlement: Settlement) => Settlement
114
+
115
+ const directSettle: SettleBoundary = (settlement) => settlement
116
+
117
+ // Cooperative yields flush synchronous fibers; the real-time sleep prevents a
118
+ // genuinely delayed flow from looking stable between renders.
61
119
  const SETTLE_DRAIN = 30
62
120
  const SETTLE_STEP = '1 milli'
63
121
  const SETTLE_MAX_RENDERS = 100
64
122
 
65
- /** Cooperatively run forked engine fibers to a fixpoint without advancing real time. */
66
123
  const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
67
124
 
68
- // A keyed view backed by a getter — the only place dynamic keys are needed.
69
125
  const keyed = <V>(get: (key: string) => V): Record<string, V> => {
70
126
  const target: Record<string, V> = Object.create(null)
71
127
  return new Proxy(target, { get: (_target, key) => get(String(key)) })
72
128
  }
73
129
 
74
- /** Surface an unknown failure/defect as the message string a result DTO carries. */
75
130
  const messageOf = (error: unknown): string =>
76
131
  error instanceof Error ? error.message : String(error)
77
132
 
78
- const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
133
+ const uiNameOf = (comp: AnyComposition): string => comp.manifest.ui.manifest.name
79
134
 
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`.
135
+ // UiContract erases event values to never, so this boundary needs no assertion.
85
136
  const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
86
137
  ...structure.events,
87
138
  })
88
139
 
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.
140
+ interface ExpectedProps {
141
+ readonly actual: unknown
142
+ readonly expected: unknown
143
+ }
144
+
145
+ const expectMatchingProps = ({
146
+ actual,
147
+ expected,
148
+ }: ExpectedProps): Effect.Effect<void, never, never> =>
149
+ Option.match(Option.fromNullable(matchProps({ actual, expected })), {
150
+ onNone: () => Effect.void,
151
+ onSome: (detail) => Effect.dieMessage(new AssertionFailed({ detail }).message),
152
+ })
153
+
154
+ const invalidCompositionProps = (composition: AnyComposition): string =>
155
+ `reform-proof: invalid props for composition ${composition.manifest.name}`
156
+
157
+ const renderCompositionRuntime = (
158
+ runtime: RenderRuntime,
159
+ composition: AnyComposition,
160
+ props: unknown,
161
+ ): Structure<UiContract> =>
162
+ composition.capture(
163
+ <P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
164
+ exact: CompositionClass<P, C, S, N, Identity>,
165
+ ): Structure<UiContract> => {
166
+ const resolved = Option.flatMap(exact.parseProps(props), (parsed) =>
167
+ Option.map(runtime.readOption(exact.tag), (service) => ({
168
+ parsed,
169
+ service,
170
+ })),
171
+ )
172
+ return Option.match(resolved, {
173
+ onNone: () => Effect.runSync(Effect.dieMessage(invalidCompositionProps(composition))),
174
+ onSome: ({ parsed, service }) => {
175
+ const frame = Effect.runSync(
176
+ Composition.render(service, {
177
+ props: parsed,
178
+ tracker: { add: () => {} },
179
+ }),
180
+ )
181
+ if (isStructure(frame)) {
182
+ return frame
183
+ }
184
+ return Effect.runSync(
185
+ Effect.dieMessage(
186
+ `reform-proof: composition ${composition.manifest.name} did not return a Structure`,
187
+ ),
188
+ )
189
+ },
190
+ })
191
+ },
192
+ )
193
+
91
194
  export interface Sink {
92
195
  readonly api: CaptureSinkApi
93
- readonly captures: ReadonlyArray<UiCapture>
94
- /** The scene's profiling hooks — render spans record here (noop unless `profileScene`d). */
196
+ readonly captures: ReadonlyArray<CapturedRender>
95
197
  readonly instrumentation: Instrumentation
96
198
  reset(): void
97
199
  }
98
200
 
99
201
  export const makeSink = (instrumentation: Instrumentation = noopInstrumentation): Sink => {
100
202
  // CaptureSink.record is synchronous, so this uses MutableRef rather than Ref.
101
- const captures = MutableRef.make<UiCapture[]>([])
203
+ const captures = MutableRef.make<CapturedRender[]>([])
102
204
  return {
103
205
  api: {
104
206
  record: (capture) => {
@@ -115,72 +217,196 @@ export const makeSink = (instrumentation: Instrumentation = noopInstrumentation)
115
217
  }
116
218
  }
117
219
 
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))
220
+ export type MountedSlotFacadesOf<C extends UiContract> = SlotFacadesOf<C, never>
221
+
222
+ export type MountedFacade<C extends UiContract> = Facade<C, never>
223
+
224
+ export type MountedSlotFacade<C extends UiContract> = SlotFacade<C, never>
225
+
226
+ export const proofLayer = <
227
+ C extends UiContract,
228
+ S extends ReadonlyArray<unknown>,
229
+ Services,
230
+ P,
231
+ N extends string,
232
+ Identity,
233
+ >(
234
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
235
+ sink: Sink,
236
+ ): Layer.Layer<Services | CaptureSink, never, never> => {
237
+ const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer))
238
+ return Layer.merge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
239
+ }
240
+
241
+ const missingRuntimeBus = (): never =>
242
+ Effect.runSync(Effect.dieMessage('reform-proof: scene does not provide Bus'))
243
+
244
+ const renderRuntimeFromContext = <Services>(
245
+ context: Context.Context<Services>,
246
+ instrumentation: Instrumentation,
247
+ ): RenderRuntime => {
248
+ const bus = Context.getOption(context, Bus)
249
+ return {
250
+ dispatch: (priority, event) =>
251
+ Option.match(bus, {
252
+ onNone: missingRuntimeBus,
253
+ onSome: (service) =>
254
+ Effect.runSync(publish(priority, event).pipe(Effect.provideService(Bus, service))),
255
+ }),
256
+ instrumentation,
257
+ readOption: (tag) => Context.getOption(context, tag),
258
+ }
138
259
  }
139
260
 
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
261
  interface Mounted {
144
- readonly comp: CompositionClass<unknown>
262
+ readonly comp: AnyComposition
145
263
  readonly props: unknown
264
+ readonly ref: NodeRef
265
+ }
266
+
267
+ interface SlotInstanceRef {
268
+ readonly name: string
269
+ readonly index: number
146
270
  readonly key: Option.Option<string>
271
+ readonly keyOccurrence: number
147
272
  }
148
273
 
149
- /** A rendered node addressed by its composition ui-name and render index. */
150
274
  interface NodeRef {
151
275
  readonly name: string
152
- readonly index: number
276
+ readonly parent: Option.Option<NodeRef>
277
+ readonly slot: Option.Option<SlotInstanceRef>
153
278
  }
154
279
 
155
- /** Settle progress: the previous fingerprint, remaining renders, and early-stop state. */
280
+ interface NodeCapture {
281
+ readonly ref: NodeRef
282
+ readonly capture: CapturedRender
283
+ }
284
+
285
+ interface CaptureLookup {
286
+ readonly captures: ReadonlyArray<NodeCapture>
287
+ record(ref: NodeRef, capture: CapturedRender): void
288
+ reset(): void
289
+ }
290
+
291
+ const makeCaptureLookup = (): CaptureLookup => {
292
+ const captures = MutableRef.make<ReadonlyArray<NodeCapture>>([])
293
+ return {
294
+ get captures() {
295
+ return MutableRef.get(captures)
296
+ },
297
+ record: (ref, capture) => {
298
+ MutableRef.update(captures, (current) => [...current, { ref, capture }])
299
+ },
300
+ reset: () => {
301
+ MutableRef.set(captures, [])
302
+ },
303
+ }
304
+ }
305
+
306
+ const sameSlotInstance = (expected: SlotInstanceRef, actual: SlotInstanceRef): boolean =>
307
+ expected.name === actual.name &&
308
+ Option.match(expected.key, {
309
+ onNone: () => expected.index === actual.index,
310
+ onSome: (key) =>
311
+ expected.keyOccurrence === actual.keyOccurrence && Option.contains(actual.key, key),
312
+ })
313
+
314
+ const sameNodeRef = (expected: NodeRef, actual: NodeRef): boolean =>
315
+ expected.name === actual.name &&
316
+ Option.match(expected.parent, {
317
+ onNone: () => Option.isNone(actual.parent) && Option.isNone(actual.slot),
318
+ onSome: (expectedParent) =>
319
+ Option.isSome(actual.parent) &&
320
+ Option.isSome(expected.slot) &&
321
+ Option.isSome(actual.slot) &&
322
+ sameNodeRef(expectedParent, actual.parent.value) &&
323
+ sameSlotInstance(expected.slot.value, actual.slot.value),
324
+ })
325
+
326
+ const rootRef = (root: AnyComposition): NodeRef => ({
327
+ name: uiNameOf(root),
328
+ parent: Option.none(),
329
+ slot: Option.none(),
330
+ })
331
+
332
+ const nodeCaptureFor = (lookup: CaptureLookup, ref: NodeRef): Option.Option<CapturedRender> =>
333
+ Option.map(
334
+ Arr.findFirst(lookup.captures, (entry) => sameNodeRef(ref, entry.ref)),
335
+ (entry) => entry.capture,
336
+ )
337
+
338
+ const slotCapturesFor = (
339
+ lookup: CaptureLookup,
340
+ parent: NodeRef,
341
+ slotName: string,
342
+ ): ReadonlyArray<NodeCapture> =>
343
+ lookup.captures.filter(
344
+ (entry) =>
345
+ Option.isSome(entry.ref.parent) &&
346
+ Option.isSome(entry.ref.slot) &&
347
+ sameNodeRef(parent, entry.ref.parent.value) &&
348
+ entry.ref.slot.value.name === slotName,
349
+ )
350
+
156
351
  interface SettleProgress {
157
352
  readonly previous: string
158
353
  readonly remaining: number
159
354
  readonly stable: boolean
160
355
  }
161
356
 
162
- /** One step of the driver's generator pump: the value to feed in and the frame index. */
357
+ const ensureSettled = (progress: SettleProgress): Effect.Effect<void, never, never> => {
358
+ if (progress.stable) {
359
+ return Effect.void
360
+ }
361
+ return Effect.dieMessage(`reform-proof: tree did not settle after ${SETTLE_MAX_RENDERS} renders`)
362
+ }
363
+
163
364
  interface PumpStep {
164
365
  readonly input: unknown
165
366
  readonly index: number
166
367
  }
167
368
 
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
369
+ const childComposition = (child: AnySlotChild): AnyComposition =>
370
+ isFeatureBinding(child) ? child.capture<AnyComposition>((binding) => binding.composition) : child
371
+
372
+ const isUnknownRecord = (candidate: unknown): candidate is Record<string, unknown> =>
373
+ typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
374
+
375
+ const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
376
+ comp.capture<Record<string, AnySlot>>((exact) => {
377
+ if (!('slots' in exact.manifest)) {
378
+ return {}
379
+ }
380
+ const candidate: unknown = exact.manifest.slots
381
+ if (!isUnknownRecord(candidate)) {
382
+ return {}
383
+ }
384
+ return Rec.fromEntries(
385
+ Rec.toEntries(candidate).flatMap(
386
+ ([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
387
+ isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
388
+ ),
389
+ )
390
+ })
176
391
 
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), () => ({}))
392
+ interface RuntimeSlotRead {
393
+ readonly runtime: RenderRuntime
394
+ readonly slotDefinition: AnySlot
395
+ readonly parent: AnyComposition
396
+ }
180
397
 
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.
398
+ const readRuntimeSlotChild = ({ runtime, slotDefinition, parent }: RuntimeSlotRead): AnySlotChild =>
399
+ slotDefinition.capture<AnySlotChild>((exact) =>
400
+ Option.match(runtime.readOption(exact.provider), {
401
+ onNone: () =>
402
+ Effect.runSync(
403
+ Effect.dieMessage(`reform-proof: slot provider missing in ${uiNameOf(parent)}`),
404
+ ),
405
+ onSome: (child) => child,
406
+ }),
407
+ )
408
+
409
+ // UiContract erases per-slot fill types here, so recover them by discriminant.
184
410
  const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
185
411
  typeof candidate === 'object' &&
186
412
  candidate !== null &&
@@ -189,156 +415,207 @@ const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
189
415
 
190
416
  const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
191
417
  Rec.fromEntries(
192
- Rec.toEntries(structure.slots).flatMap(([slotName, fillValue]) =>
193
- isSlotFill(fillValue) ? [[slotName, fillValue] as const] : [],
418
+ Rec.toEntries(structure.slots).flatMap(
419
+ ([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
420
+ isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
194
421
  ),
195
422
  )
196
423
 
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
424
  const enqueueFill = (
425
+ parent: NodeRef,
226
426
  slotName: string,
227
427
  fill: SlotFill<unknown>,
228
- child: CompositionClass<unknown>,
428
+ child: AnyComposition,
229
429
  ): ReadonlyArray<Mounted> =>
230
430
  Match.value(fill).pipe(
231
431
  Match.when({ _tag: 'Each' }, (each) =>
232
- each.items.map((entry) => ({
432
+ each.items.map((entry, index) => ({
233
433
  comp: child,
234
434
  props: entry.props,
235
- key: Option.some(entry.key),
435
+ ref: {
436
+ name: uiNameOf(child),
437
+ parent: Option.some(parent),
438
+ slot: Option.some({
439
+ name: slotName,
440
+ index,
441
+ key: Option.some(entry.key),
442
+ keyOccurrence: each.items
443
+ .slice(0, index)
444
+ .filter((candidate) => candidate.key === entry.key).length,
445
+ }),
446
+ },
236
447
  })),
237
448
  ),
238
449
  Match.when({ _tag: 'One' }, (oneFill) => [
239
- { comp: child, props: oneFill.props, key: Option.some(`${slotName}.0`) },
450
+ {
451
+ comp: child,
452
+ props: oneFill.props,
453
+ ref: {
454
+ name: uiNameOf(child),
455
+ parent: Option.some(parent),
456
+ slot: Option.some({
457
+ name: slotName,
458
+ index: 0,
459
+ key: Option.some(`${slotName}.0`),
460
+ keyOccurrence: 0,
461
+ }),
462
+ },
463
+ },
240
464
  ]),
241
465
  Match.when({ _tag: 'Absent' }, () => []),
242
466
  Match.exhaustive,
243
467
  )
244
468
 
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
469
  const driveStructure = (
248
- comp: CompositionClass<unknown>,
470
+ comp: AnyComposition,
249
471
  structure: Structure<UiContract>,
250
- key: Option.Option<string>,
472
+ ref: NodeRef,
251
473
  sink: Sink,
252
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
474
+ lookup: CaptureLookup,
475
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
253
476
  ): ReadonlyArray<Mounted> => {
254
- sink.api.record({
477
+ const key = Option.flatMap(ref.slot, (instance) => instance.key)
478
+ const capture: CapturedRender = {
255
479
  name: uiNameOf(comp),
256
480
  props: structure.props,
257
481
  events: eventsOf(structure),
258
482
  ...(Option.isSome(key) ? { key: key.value } : {}),
259
- })
483
+ }
484
+ sink.api.record(capture)
485
+ lookup.record(ref, capture)
260
486
  const fills = structureFills(structure)
261
487
  return Rec.toEntries(slotsOf(comp)).flatMap(([slotName, slotClass]) => {
262
488
  const child = bindings.get(slotClass)
263
489
  const fill = fills[slotName]
264
- return child === undefined || fill === undefined ? [] : enqueueFill(slotName, fill, child)
490
+ return child === undefined || fill === undefined ? [] : enqueueFill(ref, slotName, fill, child)
265
491
  })
266
492
  }
267
493
 
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* (
494
+ const fingerprint = (lookup: CaptureLookup): string =>
495
+ formatFingerprint(lookup.captures.map(({ ref, capture }) => [ref, capture.props]))
496
+
497
+ export function makeFacade<
498
+ C extends UiContract,
499
+ S extends ReadonlyArray<unknown>,
500
+ Services,
501
+ P,
502
+ N extends string,
503
+ Identity,
504
+ >(
505
+ runtime: ManagedRuntime.ManagedRuntime<Services | CaptureSink, never>,
506
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
507
+ sink: Sink,
508
+ dispatched: Set<string>,
509
+ ): {
510
+ readonly facade: Facade<C, never>
511
+ readonly settle: Effect.Effect<void, never, never>
512
+ } {
513
+ const context = runtime.runSync(Effect.context<Services | CaptureSink>())
514
+ return makeMountedFacade(
515
+ renderRuntimeFromContext(context, sink.instrumentation),
516
+ scene.composition,
517
+ sink,
518
+ dispatched,
519
+ (settlement) => Effect.promise(() => runtime.runPromise(settlement)),
520
+ )
521
+ }
522
+
523
+ const collectRuntimeBindings = (
524
+ root: AnyComposition,
525
+ runtime: RenderRuntime,
526
+ ): Map<AnySlot, AnyComposition> => {
527
+ const bindings = new Map<AnySlot, AnyComposition>()
528
+ const walk = (comp: AnyComposition): void => {
529
+ Rec.values(slotsOf(comp)).forEach((slotClass) => {
530
+ if (bindings.has(slotClass)) {
531
+ return
532
+ }
533
+ const child = childComposition(
534
+ readRuntimeSlotChild({
535
+ runtime,
536
+ slotDefinition: slotClass,
537
+ parent: comp,
538
+ }),
539
+ )
540
+ bindings.set(slotClass, child)
541
+ walk(child)
542
+ })
543
+ }
544
+ walk(root)
545
+ return bindings
546
+ }
547
+
548
+ const renderMountedRuntime = (
549
+ runtime: RenderRuntime,
272
550
  mounted: Mounted,
273
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
551
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
274
552
  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: () => {} } }
553
+ lookup: CaptureLookup,
554
+ ): ReadonlyArray<Mounted> => {
278
555
  const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
279
- const frame = yield* Composition.render(service, env)
556
+ const frame = renderCompositionRuntime(runtime, mounted.comp, mounted.props)
280
557
  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
- })
558
+ return driveStructure(mounted.comp, frame, mounted.ref, sink, lookup, bindings)
559
+ }
288
560
 
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: (
561
+ const renderLevelRuntime = (
562
+ runtime: RenderRuntime,
292
563
  frontier: ReadonlyArray<Mounted>,
293
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
564
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
294
565
  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> {
566
+ lookup: CaptureLookup,
567
+ ): void => {
300
568
  if (frontier.length === 0) {
301
569
  return
302
570
  }
303
- const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted, bindings, sink))
304
- yield* renderLevel(levels.flat(), bindings, sink)
305
- })
571
+ const levels = frontier.map((mounted) =>
572
+ renderMountedRuntime(runtime, mounted, bindings, sink, lookup),
573
+ )
574
+ renderLevelRuntime(runtime, levels.flat(), bindings, sink, lookup)
575
+ }
306
576
 
307
- const renderTree = Effect.fn('renderTree')(function* (
308
- root: CompositionClass<unknown>,
309
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
577
+ const renderTreeRuntime = (
578
+ runtime: RenderRuntime,
579
+ root: AnyComposition,
580
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
310
581
  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]))
582
+ lookup: CaptureLookup,
583
+ ): Effect.Effect<void, never, never> =>
584
+ Effect.sync(() => {
585
+ sink.reset()
586
+ lookup.reset()
587
+ renderLevelRuntime(
588
+ runtime,
589
+ [{ comp: root, props: {}, ref: rootRef(root) }],
590
+ bindings,
591
+ sink,
592
+ lookup,
593
+ )
594
+ })
324
595
 
325
- const settleTree = Effect.fn('settleTree')(function* (
326
- root: CompositionClass<unknown>,
327
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
596
+ const settleTreeRuntime = Effect.fn('settleTreeRuntime')(function* (
597
+ runtime: RenderRuntime,
598
+ root: AnyComposition,
599
+ bindings: ReadonlyMap<AnySlot, AnyComposition>,
328
600
  sink: Sink,
329
- ): Effect.fn.Return<void, never, CompositionService> {
601
+ lookup: CaptureLookup,
602
+ ): Effect.fn.Return<void, never, never> {
330
603
  yield* settleDrain
331
- yield* renderTree(root, bindings, sink)
332
- yield* Effect.iterate(
333
- { previous: fingerprint(sink), remaining: SETTLE_MAX_RENDERS, stable: false },
604
+ yield* renderTreeRuntime(runtime, root, bindings, sink, lookup)
605
+ const progress = yield* Effect.iterate(
606
+ {
607
+ previous: fingerprint(lookup),
608
+ remaining: SETTLE_MAX_RENDERS,
609
+ stable: false,
610
+ },
334
611
  {
335
612
  while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
336
613
  body: (progress) =>
337
614
  Effect.gen(function* () {
338
615
  yield* settleDrain
339
616
  yield* Effect.sleep(SETTLE_STEP)
340
- yield* renderTree(root, bindings, sink)
341
- const current = fingerprint(sink)
617
+ yield* renderTreeRuntime(runtime, root, bindings, sink, lookup)
618
+ const current = fingerprint(lookup)
342
619
  return {
343
620
  previous: current,
344
621
  remaining: progress.remaining - 1,
@@ -347,123 +624,153 @@ const settleTree = Effect.fn('settleTree')(function* (
347
624
  }),
348
625
  },
349
626
  )
627
+ yield* ensureSettled(progress)
350
628
  })
351
629
 
352
- const makeFacadeEffect = Effect.fn('makeFacadeEffect')(function* (
353
- root: CompositionClass<unknown>,
630
+ function makeMountedFacade<
631
+ P,
632
+ C extends UiContract,
633
+ S extends ReadonlyArray<unknown>,
634
+ N extends string,
635
+ Identity,
636
+ >(
637
+ runtime: RenderRuntime,
638
+ root: CompositionClass<P, C, S, N, Identity>,
354
639
  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
640
  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
- })
641
+ settleBoundary: SettleBoundary,
642
+ ): {
643
+ readonly facade: MountedFacade<C>
644
+ readonly settle: Effect.Effect<void, never, never>
645
+ }
646
+ function makeMountedFacade(
647
+ runtime: RenderRuntime,
648
+ root: AnyComposition,
649
+ sink: Sink,
650
+ dispatched: Set<string>,
651
+ settleBoundary: SettleBoundary,
652
+ ): MountedFacadeHandleErasure
653
+ function makeMountedFacade(
654
+ runtime: RenderRuntime,
655
+ root: AnyComposition,
656
+ sink: Sink,
657
+ dispatched: Set<string>,
658
+ settleBoundary: SettleBoundary,
659
+ ): MountedFacadeHandleErasure {
660
+ const bindings = collectRuntimeBindings(root, runtime)
661
+ const lookup = makeCaptureLookup()
662
+ const settle = settleBoundary(settleTreeRuntime(runtime, root, bindings, sink, lookup))
663
+ const rerender = renderTreeRuntime(runtime, root, bindings, sink, lookup)
379
664
 
380
- const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
381
- Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
665
+ const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>, never, never> =>
666
+ Option.match(
667
+ Option.flatMap(nodeCaptureFor(lookup, ref), (capture) =>
668
+ Option.fromNullable(capture.events[event]),
669
+ ),
670
+ {
671
+ onNone: () =>
672
+ Effect.dieMessage(
673
+ new UnknownAction({
674
+ composition: ref.name,
675
+ action: event,
676
+ rendered: lookup.captures.filter((entry) => entry.ref.name === ref.name).length,
677
+ }).message,
678
+ ),
679
+ onSome: Effect.succeed,
680
+ },
681
+ )
682
+
683
+ const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, never> =>
684
+ Effect.map(rerender, () =>
685
+ Option.match(nodeCaptureFor(lookup, ref), {
686
+ onNone: () => undefined,
687
+ onSome: (capture) => capture.props,
688
+ }),
689
+ )
382
690
 
383
- const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
691
+ const nodeFacade = (ref: NodeRef, comp: AnyComposition): MountedFacadeErasure => ({
384
692
  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
693
  expectProps: (partial) =>
388
694
  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
- }),
695
+ Effect.flatMap((props) => expectMatchingProps({ actual: props, expected: partial })),
395
696
  ),
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
697
  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)),
698
+ (event) => (payload: unknown) =>
699
+ rerender.pipe(
700
+ Effect.flatMap(() => triggerOf(ref, event)),
701
+ Effect.flatMap((trigger) =>
702
+ Effect.sync(() => {
703
+ dispatched.add(event)
704
+ trigger(payload)
705
+ }),
414
706
  ),
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'],
707
+ Effect.flatMap(() => settle),
708
+ Effect.flatMap(() => propsFor(ref)),
709
+ ),
710
+ ),
711
+ slots: keyed((slotName) => slotFacade(ref, comp, slotName)),
418
712
  frame: settle,
419
713
  })
420
714
 
421
- const slotFacade = (parent: CompositionClass<unknown>, slotName: string): AnySlotFacade => {
715
+ const slotFacade = (
716
+ parentRef: NodeRef,
717
+ parent: AnyComposition,
718
+ slotName: string,
719
+ ): MountedSlotFacadeErasure => {
422
720
  const slotClass = slotsOf(parent)[slotName]
423
721
  const child = slotClass && bindings.get(slotClass)
424
722
  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 })
723
+ return Effect.runSync(
724
+ Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
725
+ )
427
726
  }
428
727
  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)
728
+ const childRefAt = (index: number): NodeRef =>
729
+ slotCapturesFor(lookup, parentRef, slotName)[index]?.ref ?? {
730
+ name: childName,
731
+ parent: Option.some(parentRef),
732
+ slot: Option.some({
733
+ name: slotName,
734
+ index,
735
+ key: Option.none(),
736
+ keyOccurrence: 0,
737
+ }),
738
+ }
739
+ const atIndex = (index: number): Effect.Effect<MountedFacadeErasure, never, never> =>
740
+ Effect.as(rerender, nodeFacade(childRefAt(index), child))
741
+ const first = nodeFacade(childRefAt(0), child)
437
742
  return {
438
743
  first: atIndex(0),
439
744
  at: atIndex,
440
745
  all: Effect.map(rerender, () =>
441
- capturesFor(sink, childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
746
+ slotCapturesFor(lookup, parentRef, slotName).map((entry) => nodeFacade(entry.ref, child)),
442
747
  ),
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
748
  byKey: (key) =>
447
749
  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
- }),
750
+ Option.match(
751
+ Arr.findFirst(
752
+ slotCapturesFor(lookup, parentRef, slotName),
753
+ (entry) => entry.capture.key === key,
754
+ ),
755
+ {
756
+ onNone: () =>
757
+ Effect.dieMessage(
758
+ new UnknownSlot({
759
+ parent: uiNameOf(parent),
760
+ slot: `${slotName}[key=${key}]`,
761
+ }).message,
762
+ ),
763
+ onSome: (entry) => Effect.succeed(nodeFacade(entry.ref, child)),
764
+ },
765
+ ),
455
766
  ),
456
- // Every child whose computed props satisfy `predicate`. Re-renders, then maps
457
- // the matching captures back to their facades by render index.
458
767
  where: (predicate) =>
459
768
  Effect.map(rerender, () =>
460
- capturesFor(sink, childName).flatMap((capture, index) =>
461
- predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
769
+ slotCapturesFor(lookup, parentRef, slotName).flatMap((entry) =>
770
+ predicate(entry.capture.props) ? [nodeFacade(entry.ref, child)] : [],
462
771
  ),
463
772
  ),
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.
773
+ count: Effect.map(rerender, () => slotCapturesFor(lookup, parentRef, slotName).length),
467
774
  props: first.props,
468
775
  expectProps: first.expectProps,
469
776
  actions: first.actions,
@@ -472,104 +779,849 @@ const makeFacadeEffect = Effect.fn('makeFacadeEffect')(function* (
472
779
  }
473
780
  }
474
781
 
475
- return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
476
- })
782
+ return {
783
+ facade: nodeFacade(rootRef(root), root),
784
+ settle,
785
+ }
786
+ }
787
+
788
+ export const makeProofFacade = <
789
+ C extends UiContract,
790
+ S extends ReadonlyArray<unknown>,
791
+ Services,
792
+ P,
793
+ N extends string,
794
+ Identity,
795
+ >(
796
+ runtime: ManagedRuntime.ManagedRuntime<Services | CaptureSink, never>,
797
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
798
+ sink: Sink,
799
+ dispatched: Set<string>,
800
+ ): {
801
+ readonly facade: Facade<C, never>
802
+ readonly settle: Effect.Effect<void, never, never>
803
+ } => {
804
+ return makeFacade(runtime, scene, sink, dispatched)
805
+ }
477
806
 
478
- export const makeFacade = (
479
- runtime: ManagedRuntime.ManagedRuntime<RuntimeServices, never>,
480
- root: CompositionClass<unknown>,
807
+ export const makeRuntimeHandleFacade = <
808
+ Services,
809
+ RootIdentifier extends NoInfer<Services>,
810
+ P,
811
+ C extends UiContract,
812
+ S extends ReadonlyArray<unknown>,
813
+ N extends string,
814
+ Identity,
815
+ >(
816
+ runtime: RuntimeHandle<Services>,
817
+ root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
481
818
  sink: Sink,
482
819
  dispatched: Set<string>,
483
- ): { readonly facade: AnyFacade; readonly settle: Effect.Effect<void, never, CompositionService> } =>
484
- runtime.runSync(makeFacadeEffect(root, sink, dispatched))
820
+ ): {
821
+ readonly facade: MountedFacade<C>
822
+ readonly settle: Effect.Effect<void, never, never>
823
+ } => makeMountedFacade(runtime, root, sink, dispatched, directSettle)
485
824
 
486
- /** Declared-but-never-dispatched events for a proof's requirement (coverage). */
487
- const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
825
+ type FeatureMountState =
826
+ | { readonly _tag: 'Loading' }
827
+ | { readonly _tag: 'Live'; readonly runtime: HostRuntime }
828
+ | { readonly _tag: 'Failed'; readonly error: FeatureLoadFailed }
829
+
830
+ interface TreeFeatureMount {
831
+ readonly id: string
832
+ readonly binding: AnyFeatureBinding
833
+ readonly parent: HostRuntime
834
+ readonly depth: number
835
+ readonly props: unknown
836
+ attempt: number
837
+ state: FeatureMountState
838
+ dispose: () => void
839
+ }
840
+
841
+ interface TreeNode {
842
+ readonly path: string
843
+ readonly depth: number
844
+ readonly key: Option.Option<string>
845
+ readonly comp: AnyComposition
846
+ readonly props: unknown
847
+ readonly events: Record<string, Trigger<unknown>>
848
+ readonly slots: Readonly<Record<string, ReadonlyArray<TreeNode>>>
849
+ }
850
+
851
+ interface TreeRenderState {
852
+ readonly root: TreeNode
853
+ readonly fingerprint: string
854
+ }
855
+
856
+ export interface RuntimeTreeFacade<C extends UiContract> {
857
+ readonly facade: MountedFacade<C>
858
+ readonly settle: Effect.Effect<void, never, never>
859
+ dispose(): void
860
+ }
861
+
862
+ interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
863
+ dispose(): void
864
+ }
865
+
866
+ interface TreeDriver {
867
+ readonly render: () => TreeRenderState
868
+ readonly settle: Effect.Effect<void, never, never>
869
+ readonly dispose: () => void
870
+ }
871
+
872
+ interface MountIdentityInput {
873
+ readonly parent: HostRuntime
874
+ readonly parentPath: string
875
+ readonly slotName: string
876
+ readonly key: string
877
+ readonly binding: AnyFeatureBinding
878
+ }
879
+
880
+ interface FeatureRecordInput {
881
+ readonly id: string
882
+ readonly parent: HostRuntime
883
+ readonly binding: AnyFeatureBinding
884
+ readonly depth: number
885
+ readonly props: unknown
886
+ }
887
+
888
+ interface RenderCompositionInput {
889
+ readonly runtime: HostRuntime
890
+ readonly comp: AnyComposition
891
+ readonly props: unknown
892
+ readonly key: Option.Option<string>
893
+ readonly path: string
894
+ readonly depth: number
895
+ readonly seen: Set<string>
896
+ readonly mountMissing: boolean
897
+ }
898
+
899
+ const encodeIdentityPart = (part: string): string => `${part.length}:${part}`
900
+
901
+ const fillEntries = (
902
+ slotName: string,
903
+ fill: SlotFill<unknown>,
904
+ ): ReadonlyArray<{ readonly key: string; readonly props: unknown }> =>
905
+ Match.value(fill).pipe(
906
+ Match.when({ _tag: 'Each' }, (each) => each.items),
907
+ Match.when({ _tag: 'One' }, (oneFill) => [{ key: `${slotName}.0`, props: oneFill.props }]),
908
+ Match.when({ _tag: 'Absent' }, () => []),
909
+ Match.exhaustive,
910
+ )
911
+
912
+ const treeFingerprint = (node: TreeNode): unknown => [
913
+ uiNameOf(node.comp),
914
+ node.key,
915
+ node.props,
916
+ Rec.map(node.slots, (children) => children.map(treeFingerprint)),
917
+ ]
918
+
919
+ const makeTreeDriver = (
920
+ runtime: HostRuntime,
921
+ root: AnyComposition,
922
+ rootProps: unknown,
923
+ sink: Sink,
924
+ ): TreeDriver => {
925
+ const mounts = new Map<string, TreeFeatureMount>()
926
+ const runtimeIds = new WeakMap<HostRuntime, number>()
927
+ const bindingIds = new WeakMap<object, number>()
928
+ const sequence = { runtime: 0, binding: 0 }
929
+ const status = { disposed: false }
930
+
931
+ const identity = <Subject extends object>(
932
+ identities: WeakMap<Subject, number>,
933
+ subject: Subject,
934
+ next: () => number,
935
+ ): number => {
936
+ const current = identities.get(subject)
937
+ if (current !== undefined) {
938
+ return current
939
+ }
940
+ const created = next()
941
+ identities.set(subject, created)
942
+ return created
943
+ }
944
+
945
+ const mountId = ({ parent, parentPath, slotName, key, binding }: MountIdentityInput): string => {
946
+ const runtimeId = identity(runtimeIds, parent, () => ++sequence.runtime)
947
+ const bindingId = identity(bindingIds, binding, () => ++sequence.binding)
948
+ return [String(runtimeId), parentPath, slotName, String(bindingId), key]
949
+ .map(encodeIdentityPart)
950
+ .join('')
951
+ }
952
+
953
+ const startMount = (record: TreeFeatureMount): void => {
954
+ record.attempt += 1
955
+ const attempt = record.attempt
956
+ record.binding.capture<void>((binding) => {
957
+ if (binding.strategy === 'default') {
958
+ record.state = { _tag: 'Live', runtime: record.parent }
959
+ binding.boot.forEach((event) => record.parent.dispatch('High', event))
960
+ record.dispose = () => {}
961
+ return
962
+ }
963
+ record.state = { _tag: 'Loading' }
964
+ record.dispose = record.parent.mountAnyFeature(binding, {
965
+ props: record.props,
966
+ onLive: (childRuntime) => {
967
+ if (status.disposed || record.attempt !== attempt || mounts.get(record.id) !== record) {
968
+ return
969
+ }
970
+ record.state = { _tag: 'Live', runtime: childRuntime }
971
+ },
972
+ onFailed: (error) => {
973
+ if (status.disposed || record.attempt !== attempt || mounts.get(record.id) !== record) {
974
+ return
975
+ }
976
+ record.state = { _tag: 'Failed', error }
977
+ },
978
+ })
979
+ })
980
+ }
981
+
982
+ const retryMount = (record: TreeFeatureMount): void => {
983
+ if (status.disposed || mounts.get(record.id) !== record || record.state._tag !== 'Failed') {
984
+ return
985
+ }
986
+ record.dispose()
987
+ startMount(record)
988
+ }
989
+
990
+ const featureRecord = ({
991
+ id,
992
+ parent,
993
+ binding,
994
+ depth,
995
+ props,
996
+ }: FeatureRecordInput): TreeFeatureMount => {
997
+ const current = mounts.get(id)
998
+ if (current !== undefined && current.binding === binding && current.parent === parent) {
999
+ return current
1000
+ }
1001
+ if (current !== undefined) {
1002
+ current.attempt += 1
1003
+ current.dispose()
1004
+ }
1005
+ const created: TreeFeatureMount = {
1006
+ id,
1007
+ binding,
1008
+ parent,
1009
+ depth,
1010
+ props,
1011
+ attempt: 0,
1012
+ state: { _tag: 'Loading' },
1013
+ dispose: () => {},
1014
+ }
1015
+ mounts.set(id, created)
1016
+ startMount(created)
1017
+ return created
1018
+ }
1019
+ const featureRecordForRender = (
1020
+ input: FeatureRecordInput,
1021
+ mountMissing: boolean,
1022
+ ): TreeFeatureMount | undefined => {
1023
+ const current = mounts.get(input.id)
1024
+ if (current !== undefined || !mountMissing) {
1025
+ return current
1026
+ }
1027
+ return featureRecord(input)
1028
+ }
1029
+
1030
+ const renderComposition = ({
1031
+ runtime: currentRuntime,
1032
+ comp,
1033
+ props,
1034
+ key,
1035
+ path,
1036
+ depth,
1037
+ seen,
1038
+ mountMissing,
1039
+ }: RenderCompositionInput): TreeNode => {
1040
+ const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(comp))
1041
+ const frame = renderCompositionRuntime(currentRuntime, comp, props)
1042
+ endRenderSpan()
1043
+ sink.api.record({
1044
+ name: uiNameOf(comp),
1045
+ props: frame.props,
1046
+ events: eventsOf(frame),
1047
+ ...(Option.isSome(key) ? { key: key.value } : {}),
1048
+ })
1049
+
1050
+ const fills = structureFills(frame)
1051
+ const renderedSlots = Rec.map(slotsOf(comp), (slotClass, slotName) => {
1052
+ const fill = fills[slotName]
1053
+ if (fill === undefined) {
1054
+ return []
1055
+ }
1056
+ const child = readRuntimeSlotChild({
1057
+ runtime: currentRuntime,
1058
+ slotDefinition: slotClass,
1059
+ parent: comp,
1060
+ })
1061
+ return fillEntries(slotName, fill).flatMap((entry) => {
1062
+ const childPath = `${path}${encodeIdentityPart(slotName)}${encodeIdentityPart(entry.key)}`
1063
+ if (!isFeatureBinding(child)) {
1064
+ return [
1065
+ renderComposition({
1066
+ runtime: currentRuntime,
1067
+ comp: child,
1068
+ props: entry.props,
1069
+ key: Option.some(entry.key),
1070
+ path: childPath,
1071
+ depth: depth + 1,
1072
+ seen,
1073
+ mountMissing,
1074
+ }),
1075
+ ]
1076
+ }
1077
+ const id = mountId({
1078
+ parent: currentRuntime,
1079
+ parentPath: path,
1080
+ slotName,
1081
+ key: entry.key,
1082
+ binding: child,
1083
+ })
1084
+ seen.add(id)
1085
+ const record = featureRecordForRender(
1086
+ {
1087
+ id,
1088
+ parent: currentRuntime,
1089
+ binding: child,
1090
+ depth: depth + 1,
1091
+ props: entry.props,
1092
+ },
1093
+ mountMissing,
1094
+ )
1095
+ if (record === undefined) {
1096
+ return []
1097
+ }
1098
+ return child.capture<ReadonlyArray<TreeNode>>((binding) =>
1099
+ Match.value(record.state).pipe(
1100
+ Match.when({ _tag: 'Live' }, (live) => [
1101
+ renderComposition({
1102
+ runtime: live.runtime,
1103
+ comp: binding.composition,
1104
+ props: entry.props,
1105
+ key: Option.some(entry.key),
1106
+ path: childPath,
1107
+ depth: depth + 1,
1108
+ seen,
1109
+ mountMissing,
1110
+ }),
1111
+ ]),
1112
+ Match.when({ _tag: 'Failed' }, (failed) =>
1113
+ Option.match(binding.placeholder, {
1114
+ onNone: () => [],
1115
+ onSome: (placeholder) => [
1116
+ renderComposition({
1117
+ runtime: currentRuntime,
1118
+ comp: placeholder.failed,
1119
+ props: {
1120
+ error: failed.error,
1121
+ retry: () => retryMount(record),
1122
+ },
1123
+ key: Option.some(entry.key),
1124
+ path: `${childPath}/failed`,
1125
+ depth: depth + 1,
1126
+ seen,
1127
+ mountMissing,
1128
+ }),
1129
+ ],
1130
+ }),
1131
+ ),
1132
+ Match.when({ _tag: 'Loading' }, () =>
1133
+ Option.match(binding.placeholder, {
1134
+ onNone: () => [],
1135
+ onSome: (placeholder) => [
1136
+ renderComposition({
1137
+ runtime: currentRuntime,
1138
+ comp: placeholder.loading,
1139
+ props: entry.props,
1140
+ key: Option.some(entry.key),
1141
+ path: `${childPath}/loading`,
1142
+ depth: depth + 1,
1143
+ seen,
1144
+ mountMissing,
1145
+ }),
1146
+ ],
1147
+ }),
1148
+ ),
1149
+ Match.exhaustive,
1150
+ ),
1151
+ )
1152
+ })
1153
+ })
1154
+
1155
+ return {
1156
+ path,
1157
+ depth,
1158
+ key,
1159
+ comp,
1160
+ props: frame.props,
1161
+ events: eventsOf(frame),
1162
+ slots: renderedSlots,
1163
+ }
1164
+ }
1165
+
1166
+ const disposeStale = (seen: ReadonlySet<string>): void => {
1167
+ Arr.sortWith(
1168
+ Array.from(mounts.values()).filter((record) => !seen.has(record.id)),
1169
+ (record) => -record.depth,
1170
+ Order.number,
1171
+ ).forEach((record) => {
1172
+ mounts.delete(record.id)
1173
+ record.attempt += 1
1174
+ record.dispose()
1175
+ })
1176
+ }
1177
+
1178
+ const render = (): TreeRenderState => {
1179
+ // Discover the desired graph without acquiring missing Features, then release
1180
+ // removed scopes deepest-first before the live pass mounts replacements.
1181
+ sink.reset()
1182
+ const retained = new Set<string>()
1183
+ renderComposition({
1184
+ runtime,
1185
+ comp: root,
1186
+ props: rootProps,
1187
+ key: Option.none(),
1188
+ path: 'root',
1189
+ depth: 0,
1190
+ seen: retained,
1191
+ mountMissing: false,
1192
+ })
1193
+ disposeStale(retained)
1194
+ sink.reset()
1195
+ const seen = new Set<string>()
1196
+ const rootNode = renderComposition({
1197
+ runtime,
1198
+ comp: root,
1199
+ props: rootProps,
1200
+ key: Option.none(),
1201
+ path: 'root',
1202
+ depth: 0,
1203
+ seen,
1204
+ mountMissing: true,
1205
+ })
1206
+ return {
1207
+ root: rootNode,
1208
+ fingerprint: formatFingerprint(treeFingerprint(rootNode)),
1209
+ }
1210
+ }
1211
+
1212
+ const settle = Effect.gen(function* () {
1213
+ yield* settleDrain
1214
+ const first = render()
1215
+ const progress = yield* Effect.iterate(
1216
+ {
1217
+ previous: first.fingerprint,
1218
+ remaining: SETTLE_MAX_RENDERS,
1219
+ stable: false,
1220
+ },
1221
+ {
1222
+ while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
1223
+ body: (progress) =>
1224
+ Effect.gen(function* () {
1225
+ yield* settleDrain
1226
+ yield* Effect.sleep(SETTLE_STEP)
1227
+ const current = render()
1228
+ return {
1229
+ previous: current.fingerprint,
1230
+ remaining: progress.remaining - 1,
1231
+ stable: current.fingerprint === progress.previous,
1232
+ }
1233
+ }),
1234
+ },
1235
+ )
1236
+ yield* ensureSettled(progress)
1237
+ })
1238
+
1239
+ const dispose = (): void => {
1240
+ if (status.disposed) {
1241
+ return
1242
+ }
1243
+ status.disposed = true
1244
+ Arr.sortWith(Array.from(mounts.values()), (record) => -record.depth, Order.number).forEach(
1245
+ (record) => {
1246
+ record.attempt += 1
1247
+ record.dispose()
1248
+ },
1249
+ )
1250
+ mounts.clear()
1251
+ }
1252
+
1253
+ return { render, settle, dispose }
1254
+ }
1255
+
1256
+ export function makeRuntimeTreeFacade<
1257
+ Services,
1258
+ RootIdentifier extends NoInfer<Services>,
1259
+ P,
1260
+ C extends UiContract,
1261
+ S extends ReadonlyArray<unknown>,
1262
+ N extends string,
1263
+ Identity,
1264
+ >(
1265
+ runtime: RuntimeHandle<Services>,
1266
+ root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
1267
+ rootProps: P,
1268
+ sink: Sink,
1269
+ dispatched: Set<string>,
1270
+ ): RuntimeTreeFacade<C>
1271
+ export function makeRuntimeTreeFacade(
1272
+ runtime: HostRuntime,
1273
+ root: AnyComposition,
1274
+ rootProps: unknown,
1275
+ sink: Sink,
1276
+ dispatched: Set<string>,
1277
+ ): RuntimeTreeFacadeErasure {
1278
+ const driver = makeTreeDriver(runtime, root, rootProps, sink)
1279
+ const currentRoot = (): TreeNode => driver.render().root
1280
+
1281
+ // `peek` is the non-dying half of `resolve`: None once the node has left the
1282
+ // tree, so an action that removed its own node can still report a result.
1283
+ const nodeFacade = (
1284
+ resolve: () => TreeNode,
1285
+ peek: () => Option.Option<TreeNode>,
1286
+ ): MountedFacadeErasure => ({
1287
+ props: Effect.sync(() => resolve().props),
1288
+ expectProps: (partial) =>
1289
+ Effect.sync(() => {
1290
+ const mismatch = matchProps({
1291
+ actual: resolve().props,
1292
+ expected: partial,
1293
+ })
1294
+ if (mismatch !== undefined) {
1295
+ return Effect.runSync(
1296
+ Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message),
1297
+ )
1298
+ }
1299
+ }),
1300
+ actions: keyed(
1301
+ (event) => (payload: unknown) =>
1302
+ Effect.gen(function* () {
1303
+ const node = resolve()
1304
+ const trigger = node.events[event]
1305
+ if (trigger === undefined) {
1306
+ return yield* Effect.dieMessage(
1307
+ new UnknownAction({
1308
+ composition: uiNameOf(node.comp),
1309
+ action: event,
1310
+ rendered: 1,
1311
+ }).message,
1312
+ )
1313
+ }
1314
+ dispatched.add(event)
1315
+ trigger(payload)
1316
+ yield* driver.settle
1317
+ // A self-removing action leaves nothing to re-read; report what the
1318
+ // node last rendered instead of dying on the vanished child.
1319
+ return Option.match(peek(), {
1320
+ onNone: () => node.props,
1321
+ onSome: (settled) => settled.props,
1322
+ })
1323
+ }),
1324
+ ),
1325
+ slots: keyed((slotName) => slotFacade(resolve, slotName)),
1326
+ frame: driver.settle,
1327
+ })
1328
+
1329
+ const slotFacade = (
1330
+ resolveParent: () => TreeNode,
1331
+ slotName: string,
1332
+ ): MountedSlotFacadeErasure => {
1333
+ const children = (): ReadonlyArray<TreeNode> => {
1334
+ const parent = resolveParent()
1335
+ const resolved = parent.slots[slotName]
1336
+ if (resolved === undefined) {
1337
+ return Effect.runSync(
1338
+ Effect.dieMessage(
1339
+ new UnknownSlot({ parent: uiNameOf(parent.comp), slot: slotName }).message,
1340
+ ),
1341
+ )
1342
+ }
1343
+ return resolved
1344
+ }
1345
+ const peekAt = (index: number): Option.Option<TreeNode> =>
1346
+ Option.fromNullable(children()[index])
1347
+ const peekByKey = (key: string): Option.Option<TreeNode> =>
1348
+ Arr.findFirst(children(), (candidate) => Option.contains(candidate.key, key))
1349
+ const childAt = (index: number): MountedFacadeErasure =>
1350
+ nodeFacade(
1351
+ () => children()[index] ?? missingChild(index),
1352
+ () => peekAt(index),
1353
+ )
1354
+ const childByKey = (key: string): MountedFacadeErasure =>
1355
+ nodeFacade(
1356
+ () => Option.getOrElse(peekByKey(key), () => missingKey(key)),
1357
+ () => peekByKey(key),
1358
+ )
1359
+ const missingChild = (index: number): never => {
1360
+ const parent = resolveParent()
1361
+ return Effect.runSync(
1362
+ Effect.dieMessage(
1363
+ new UnknownSlot({
1364
+ parent: uiNameOf(parent.comp),
1365
+ slot: `${slotName}[${index}]`,
1366
+ }).message,
1367
+ ),
1368
+ )
1369
+ }
1370
+ const missingKey = (key: string): never => {
1371
+ const parent = resolveParent()
1372
+ return Effect.runSync(
1373
+ Effect.dieMessage(
1374
+ new UnknownSlot({
1375
+ parent: uiNameOf(parent.comp),
1376
+ slot: `${slotName}[key=${key}]`,
1377
+ }).message,
1378
+ ),
1379
+ )
1380
+ }
1381
+ const first = childAt(0)
1382
+ return {
1383
+ first: Effect.sync(() => childAt(0)),
1384
+ at: (index) => Effect.sync(() => childAt(index)),
1385
+ all: Effect.sync(() => children().map((_child, index) => childAt(index))),
1386
+ byKey: (key) =>
1387
+ Effect.sync(() => {
1388
+ const child = Arr.findFirst(children(), (candidate) =>
1389
+ Option.contains(candidate.key, key),
1390
+ )
1391
+ return Option.isNone(child) ? missingKey(key) : childByKey(key)
1392
+ }),
1393
+ where: (predicate) =>
1394
+ Effect.sync(() =>
1395
+ children().flatMap((child, index) => (predicate(child.props) ? [childAt(index)] : [])),
1396
+ ),
1397
+ count: Effect.sync(() => children().length),
1398
+ props: first.props,
1399
+ expectProps: first.expectProps,
1400
+ actions: first.actions,
1401
+ slots: first.slots,
1402
+ frame: driver.settle,
1403
+ }
1404
+ }
1405
+
1406
+ return {
1407
+ facade: nodeFacade(currentRoot, () => Option.some(currentRoot())),
1408
+ settle: driver.settle,
1409
+ dispose: driver.dispose,
1410
+ }
1411
+ }
1412
+
1413
+ const proofRootProps = <
1414
+ P,
1415
+ C extends UiContract,
1416
+ S extends ReadonlyArray<unknown>,
1417
+ N extends string,
1418
+ Identity,
1419
+ >(
1420
+ composition: CompositionClass<P, C, S, N, Identity>,
1421
+ ): Effect.Effect<P, never, never> =>
1422
+ Option.match(composition.parseProps({}), {
1423
+ onNone: () => Effect.dieMessage(invalidCompositionProps(composition)),
1424
+ onSome: Effect.succeed,
1425
+ })
1426
+
1427
+ const withProofRuntimeTree = <
1428
+ A,
1429
+ E,
1430
+ C extends UiContract,
1431
+ S extends ReadonlyArray<unknown>,
1432
+ Services,
1433
+ P,
1434
+ N extends string,
1435
+ Identity,
1436
+ >(
1437
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
1438
+ sink: Sink,
1439
+ dispatched: Set<string>,
1440
+ use: (runtime: HostRuntime, tree: RuntimeTreeFacade<C>) => Effect.Effect<A, E, never>,
1441
+ ): Effect.Effect<A, E, never> =>
1442
+ Effect.acquireUseRelease(
1443
+ Effect.sync(() => makeAppRuntime(scene)),
1444
+ (runtime) =>
1445
+ Effect.acquireUseRelease(
1446
+ Effect.gen(function* () {
1447
+ const rootProps = yield* proofRootProps(scene.composition)
1448
+ yield* Effect.sync(() => runtime.boot())
1449
+ return makeRuntimeTreeFacade(runtime, scene.composition, rootProps, sink, dispatched)
1450
+ }),
1451
+ (tree) => use(runtime, tree),
1452
+ (tree) => Effect.sync(tree.dispose),
1453
+ ),
1454
+ (runtime) => Effect.sync(runtime.dispose),
1455
+ )
1456
+
1457
+ const missingCoverage = (
1458
+ proof: Pick<Proof, 'requirement'>,
1459
+ dispatched: Set<string>,
1460
+ ): ReadonlyArray<string> =>
488
1461
  Option.match(proof.requirement.manifest.events, {
489
1462
  onNone: () => [],
490
1463
  onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
491
1464
  })
492
1465
 
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)
1466
+ interface ProofPlacement {
1467
+ readonly requirement: Proof['requirement']
1468
+ readonly placement: 'Root' | 'Slot'
1469
+ }
1470
+
1471
+ const compositionIdentity = (composition: AnyComposition): symbol =>
1472
+ composition.capture<symbol>((exact) => exact.identity)
1473
+
1474
+ const verifyProofPlacement = (
1475
+ proof: ProofPlacement,
1476
+ scene: AnyScene,
1477
+ runtime: RenderRuntime,
1478
+ ): Effect.Effect<void, never, never> => {
1479
+ const expected = proof.requirement.manifest.composition
1480
+ if (
1481
+ proof.placement === 'Root' &&
1482
+ Object.is(compositionIdentity(scene.composition), compositionIdentity(expected))
1483
+ ) {
1484
+ return Effect.void
1485
+ }
1486
+ if (proof.placement === 'Slot') {
1487
+ const matches = Rec.values(slotsOf(scene.composition)).some((slotClass) => {
1488
+ const child = childComposition(
1489
+ readRuntimeSlotChild({
1490
+ runtime,
1491
+ slotDefinition: slotClass,
1492
+ parent: scene.composition,
1493
+ }),
1494
+ )
1495
+ return Object.is(compositionIdentity(child), compositionIdentity(expected))
1496
+ })
1497
+ if (matches) {
1498
+ return Effect.void
1499
+ }
1500
+ }
1501
+ const location = proof.placement === 'Root' ? 'scene root' : 'direct scene slot'
1502
+ return Effect.dieMessage(
1503
+ `reform-proof: requirement composition ${expected.manifest.name} is not the ${location} by definition identity`,
1504
+ )
1505
+ }
498
1506
 
499
- const executeProofEffect = Effect.fn('executeProofEffect')(function* (
500
- proof: Proof,
1507
+ const executeProofWithSink = Effect.fn('executeProofWithSink')(function* <
1508
+ Comp extends ProductComposition,
1509
+ C extends UiContract,
1510
+ S extends ReadonlyArray<unknown>,
1511
+ Services,
1512
+ P,
1513
+ N extends string,
1514
+ Identity,
1515
+ >(
1516
+ proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
1517
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
501
1518
  sink: Sink,
502
- ): Effect.fn.Return<ProofResult, never, RuntimeServices> {
1519
+ ): Effect.fn.Return<ProofResult, never, never> {
503
1520
  const dispatched = new Set<string>()
504
1521
  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(
1522
+ const runGenerator = Effect.fn('runProofGenerator')(function* (
1523
+ generator: ReturnType<typeof proof.body>,
1524
+ input: unknown,
1525
+ settleEffect: Effect.Effect<void, never, never>,
1526
+ ): Effect.fn.Return<void, unknown, never> {
1527
+ const next = generator.next(input)
1528
+ if (next.done === true) {
1529
+ return
1530
+ }
1531
+ const output = yield* yieldWrapGet(next.value)
1532
+ yield* settleEffect
1533
+ yield* runGenerator(generator, output, settleEffect)
1534
+ })
1535
+ return yield* withProofRuntimeTree(scene, sink, dispatched, (runtime, tree) =>
1536
+ Effect.gen(function* () {
1537
+ yield* verifyProofPlacement(proof, scene, runtime)
1538
+ yield* tree.settle
1539
+ yield* runGenerator(proof.body(tree.facade), undefined, tree.settle)
1540
+ const missing = missingCoverage(proof, dispatched)
1541
+ const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
1542
+ return missing.length > 0
1543
+ ? { requirement: statement, ok: false, error: coverageError }
1544
+ : { requirement: statement, ok: true, error: undefined }
1545
+ }),
1546
+ ).pipe(
516
1547
  Effect.catchAllCause((cause) =>
517
- Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
1548
+ Effect.succeed({
1549
+ requirement: statement,
1550
+ ok: false,
1551
+ error: messageOf(Cause.squash(cause)),
1552
+ }),
518
1553
  ),
519
1554
  )
520
1555
  })
521
1556
 
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,
1557
+ export const executeProofEffect = (proof: Proof): Effect.Effect<ProofResult, never, never> =>
1558
+ proof.capture(
1559
+ <
1560
+ Comp extends ProductComposition,
1561
+ C extends UiContract,
1562
+ S extends ReadonlyArray<unknown>,
1563
+ Services,
1564
+ P,
1565
+ N extends string,
1566
+ Identity,
1567
+ >(
1568
+ exact: ProofCase<Comp, C, S, Services, P, N, Identity>,
1569
+ ): Effect.Effect<ProofResult, never, never> => {
1570
+ const sink = makeSink(sceneInstrumentation(exact.scene))
1571
+ return executeProofWithSink<Comp, C, S, Services, P, N, Identity>(exact, exact.scene, sink)
1572
+ },
1573
+ )
1574
+
1575
+ export const executeProof = (proof: Proof): Promise<ProofResult> =>
1576
+ Effect.runPromise(executeProofEffect(proof))
1577
+
1578
+ const driveProofWithSink = Effect.fn('driveProofWithSink')(function* <
1579
+ Comp extends ProductComposition,
1580
+ C extends UiContract,
1581
+ S extends ReadonlyArray<unknown>,
1582
+ Services,
1583
+ P,
1584
+ N extends string,
1585
+ Identity,
1586
+ >(
1587
+ proof: ProofCase<Comp, C, S, Services, P, N, Identity>,
1588
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
543
1589
  sink: Sink,
544
- ): Effect.fn.Return<DriveResult, never, RuntimeServices> {
1590
+ ): Effect.fn.Return<DriveResult, never, never> {
545
1591
  const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
546
1592
  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.
1593
+ // Driving tracks dispatches for the facade without enforcing coverage.
549
1594
  const dispatched = new Set<string>()
550
1595
  const record = (index: number): Effect.Effect<void> =>
551
1596
  Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
552
- // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
553
1597
  const pump = Effect.fn('pump')(function* (
554
- generator: ReturnType<Proof['body']>,
1598
+ generator: ReturnType<typeof proof.body>,
555
1599
  step: PumpStep,
556
- ): Effect.fn.Return<void, unknown, CompositionService> {
1600
+ settleEffect: Effect.Effect<void, never, never>,
1601
+ ): Effect.fn.Return<void, unknown, never> {
557
1602
  const next = generator.next(step.input)
558
1603
  if (next.done === true) {
559
1604
  return
560
1605
  }
561
1606
  const output = yield* yieldWrapGet(next.value)
1607
+ yield* settleEffect
562
1608
  yield* record(step.index)
563
- yield* pump(generator, { input: output, index: step.index + 1 })
1609
+ yield* pump(generator, { input: output, index: step.index + 1 }, settleEffect)
564
1610
  })
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(
1611
+ return yield* withProofRuntimeTree(scene, sink, dispatched, (runtime, tree) =>
1612
+ Effect.gen(function* () {
1613
+ yield* verifyProofPlacement(proof, scene, runtime)
1614
+ yield* tree.settle
1615
+ yield* record(0)
1616
+ yield* pump(proof.body(tree.facade), { input: undefined, index: 1 }, tree.settle)
1617
+ return {
1618
+ requirement: statement,
1619
+ frames: yield* Ref.get(frames),
1620
+ ok: true,
1621
+ error: undefined,
1622
+ }
1623
+ }),
1624
+ ).pipe(
573
1625
  Effect.catchAllCause((cause) =>
574
1626
  Ref.get(frames).pipe(
575
1627
  Effect.map((collected) => ({
@@ -583,7 +1635,23 @@ const driveProofEffect = Effect.fn('driveProofEffect')(function* (
583
1635
  )
584
1636
  })
585
1637
 
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
- }
1638
+ export const driveProofEffect = (proof: Proof): Effect.Effect<DriveResult, never, never> =>
1639
+ proof.capture(
1640
+ <
1641
+ Comp extends ProductComposition,
1642
+ C extends UiContract,
1643
+ S extends ReadonlyArray<unknown>,
1644
+ Services,
1645
+ P,
1646
+ N extends string,
1647
+ Identity,
1648
+ >(
1649
+ exact: ProofCase<Comp, C, S, Services, P, N, Identity>,
1650
+ ): Effect.Effect<DriveResult, never, never> => {
1651
+ const sink = makeSink(sceneInstrumentation(exact.scene))
1652
+ return driveProofWithSink<Comp, C, S, Services, P, N, Identity>(exact, exact.scene, sink)
1653
+ },
1654
+ )
1655
+
1656
+ export const driveProof = (proof: Proof): Promise<DriveResult> =>
1657
+ Effect.runPromise(driveProofEffect(proof))