@playfast/reform-proof 0.0.10 → 0.1.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/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.10",
4
+ "version": "0.1.0",
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,4 @@
1
- import { Effect, Layer, Match, ManagedRuntime } from 'effect'
1
+ import { Array as Arr, Cause, Effect, Layer, Match, ManagedRuntime, Option, Record as Rec } from 'effect'
2
2
  import { yieldWrapGet } from 'effect/Utils'
3
3
  import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
4
4
  import {
@@ -54,9 +54,13 @@ const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
54
54
  // A keyed view backed by a getter — the only place dynamic keys are needed.
55
55
  const keyed = <V>(get: (key: string) => V): Record<string, V> => {
56
56
  const target: Record<string, V> = Object.create(null)
57
- return new Proxy(target, { get: (_t, key) => get(String(key)) })
57
+ return new Proxy(target, { get: (_target, key) => get(String(key)) })
58
58
  }
59
59
 
60
+ /** Surface an unknown failure/defect as the message string a result DTO carries. */
61
+ const messageOf = (error: unknown): string =>
62
+ error instanceof Error ? error.message : String(error)
63
+
60
64
  const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.manifest.name
61
65
 
62
66
  // The event triggers a `Structure` frame carries (plan 03b). At the erased
@@ -64,8 +68,9 @@ const uiNameOf = (comp: CompositionClass<unknown>): string => comp.manifest.ui.m
64
68
  // each value is `never` — assignable to `Trigger<unknown>` without a cast — and an
65
69
  // omitted `events` defaults to the empty map. This is the structure-path analog of
66
70
  // the view path's `capture.events`.
67
- const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
68
- Object.fromEntries(Object.entries(structure.events ?? {}))
71
+ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
72
+ ...structure.events,
73
+ })
69
74
 
70
75
  // Engine SPI (Sink/makeSink/RuntimeServices/proofLayer/makeFacade) — exported for
71
76
  // @playfast/reform-drive, which builds the no-ceremony scene driver on the same core.
@@ -79,7 +84,11 @@ export const makeSink = (): Sink => {
79
84
  // A const holder whose array we swap on reset (no reassigned binding).
80
85
  const state: { captures: UiCapture[] } = { captures: [] }
81
86
  return {
82
- api: { record: (capture) => state.captures.push(capture) },
87
+ api: {
88
+ record: (capture) => {
89
+ state.captures = [...state.captures, capture]
90
+ },
91
+ },
83
92
  get captures() {
84
93
  return state.captures
85
94
  },
@@ -102,9 +111,12 @@ export type RuntimeServices = CompositionService | SlotChild | Bus
102
111
  * a slot tag with the requirement erased.
103
112
  */
104
113
  export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeServices, never, never> => {
105
- const sceneLayer = scene.provide.reduce((a, b) =>
106
- Layer.merge(a, b),
107
- ) as unknown as Layer.Layer<RuntimeServices, never, never>
114
+ // 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
115
+ const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer)) as unknown as Layer.Layer<
116
+ RuntimeServices,
117
+ never,
118
+ never
119
+ >
108
120
  return Layer.provideMerge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
109
121
  }
110
122
 
@@ -114,7 +126,25 @@ export const proofLayer = (scene: Scene, sink: Sink): Layer.Layer<RuntimeService
114
126
  interface Mounted {
115
127
  readonly comp: CompositionClass<unknown>
116
128
  readonly props: unknown
117
- readonly key?: string
129
+ readonly key: Option.Option<string>
130
+ }
131
+
132
+ /** A rendered node addressed by its composition ui-name and render index. */
133
+ interface NodeRef {
134
+ readonly name: string
135
+ readonly index: number
136
+ }
137
+
138
+ /** Settle progress: the previous fingerprint and how many renders remain. */
139
+ interface SettleProgress {
140
+ readonly previous: string
141
+ readonly remaining: number
142
+ }
143
+
144
+ /** One step of the driver's generator pump: the value to feed in and the frame index. */
145
+ interface PumpStep {
146
+ readonly input: unknown
147
+ readonly index: number
118
148
  }
119
149
 
120
150
  export const makeFacade = (
@@ -137,43 +167,59 @@ export const makeFacade = (
137
167
  // features as `default`, the eagerly-resolvable form.)
138
168
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
139
169
  isFeatureBinding(child) ? child.composition : child
140
- const collect = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
141
- Effect.gen(function* () {
142
- for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
143
- if (bindings.has(slotClass)) continue
144
- const child = childComposition(yield* slotClass.tag)
145
- bindings.set(slotClass, child)
146
- yield* collect(child)
147
- }
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(() => []),
174
+ )
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
+ })
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
+ )
148
189
  })
149
190
  runtime.runSync(collect(root))
150
191
 
151
- // One breadth-first render of the whole tree. Each view reports its
152
- // `(props, events)` to the sink; each slot it renders enqueues a child (built
153
- // on the next level), so a single sweep renders everything.
154
- const renderLevel = (
155
- frontier: ReadonlyArray<Mounted>,
156
- ): Effect.Effect<void, never, CompositionService> =>
157
- Effect.gen(function* () {
158
- if (frontier.length === 0) return
159
- const next: Array<Mounted> = []
160
- for (const { comp, props, key } of frontier) {
161
- const service = yield* comp.tag
162
- const env: RenderEnv = { props, tracker: { add: () => {} } }
163
- const frame = yield* Composition.render(service, env)
164
- // Every composition returns a `Structure` (no view is ever executed — the
165
- // proof is view-free / React-free): read its computed props straight off the
166
- // value, record them + the events it carries into the sink (so the facade,
167
- // `triggerOf`, and navigation work uniformly), and walk its slot FILLS to
168
- // enqueue children.
169
- if (!isStructure(frame)) {
170
- return yield* Effect.dieMessage(
171
- `reform-proof: composition ${uiNameOf(comp)} did not return a Structure`,
172
- )
173
- }
174
- driveStructure(comp, frame, next, key)
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)
209
+ })
210
+
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
175
220
  }
176
- yield* renderLevel(next)
221
+ const levels = yield* Effect.forEach(frontier, (mounted) => renderMounted(mounted))
222
+ yield* renderLevel(levels.flat())
177
223
  })
178
224
 
179
225
  // Consume a `Structure` frame: record the node's computed props (the structure
@@ -186,22 +232,20 @@ export const makeFacade = (
186
232
  const driveStructure = (
187
233
  comp: CompositionClass<unknown>,
188
234
  structure: Structure<UiContract>,
189
- next: Array<Mounted>,
190
- key: string | undefined,
191
- ): void => {
235
+ key: Option.Option<string>,
236
+ ): ReadonlyArray<Mounted> => {
192
237
  sink.api.record({
193
238
  name: uiNameOf(comp),
194
239
  props: structure.props,
195
240
  events: eventsOf(structure),
196
- ...(key !== undefined ? { key } : {}),
241
+ ...(Option.isSome(key) ? { key: key.value } : {}),
197
242
  })
198
243
  const fills: Record<string, SlotFill<unknown>> = structure.slots
199
- for (const [slotName, fill] of Object.entries(fills)) {
244
+ return Rec.toEntries(fills).flatMap(([slotName, fill]) => {
200
245
  const slotClass = comp.manifest.slots?.[slotName]
201
246
  const child = slotClass && bindings.get(slotClass)
202
- if (child === undefined) continue
203
- enqueueFill(slotName, fill, child, next)
204
- }
247
+ return child === undefined ? [] : enqueueFill(slotName, fill, child)
248
+ })
205
249
  }
206
250
 
207
251
  // Expand one slot fill into the children to render next. `Each` mounts one
@@ -213,23 +257,25 @@ export const makeFacade = (
213
257
  slotName: string,
214
258
  fill: SlotFill<unknown>,
215
259
  child: CompositionClass<unknown>,
216
- next: Array<Mounted>,
217
- ): void => {
260
+ ): ReadonlyArray<Mounted> =>
218
261
  Match.value(fill).pipe(
219
- Match.when({ _tag: 'Each' }, (each) => {
220
- for (const item of each.items) next.push({ comp: child, props: item.props, key: item.key })
221
- }),
222
- Match.when({ _tag: 'One' }, (oneFill) => {
223
- next.push({ comp: child, props: oneFill.props, key: `${slotName}.0` })
224
- }),
225
- Match.when({ _tag: 'Absent' }, () => {}),
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' }, () => []),
226
273
  Match.exhaustive,
227
274
  )
228
- }
229
275
 
230
276
  const renderTree: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
231
277
  sink.reset()
232
- yield* renderLevel([{ comp: root, props: {} }])
278
+ yield* renderLevel([{ comp: root, props: {}, key: Option.none() }])
233
279
  })
234
280
 
235
281
  const capturesFor = (name: string): ReadonlyArray<UiCapture> =>
@@ -238,73 +284,82 @@ export const makeFacade = (
238
284
  // A cheap fingerprint of the rendered tree; when two successive renders match,
239
285
  // the engine has stopped producing new state and the read is safe.
240
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
241
288
  JSON.stringify(sink.captures.map((capture) => [capture.name, capture.props]))
242
289
 
243
290
  // Re-render until the tree is stable for two consecutive renders, or give up.
244
- const settleFrom = (
245
- previous: string,
246
- remaining: number,
247
- ): Effect.Effect<void, never, CompositionService> =>
248
- Effect.gen(function* () {
249
- if (remaining === 0) return
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
+ }
250
298
  yield* settleDrain
251
299
  yield* Effect.sleep(SETTLE_STEP)
252
300
  yield* renderTree
253
301
  const current = fingerprint()
254
- if (current === previous) return
255
- yield* settleFrom(current, remaining - 1)
302
+ if (current === progress.previous) {
303
+ return
304
+ }
305
+ yield* settleFrom({ previous: current, remaining: progress.remaining - 1 })
256
306
  })
257
307
  const settle: Effect.Effect<void, never, CompositionService> = Effect.gen(function* () {
258
308
  yield* settleDrain
259
309
  yield* renderTree
260
- yield* settleFrom(fingerprint(), SETTLE_MAX_RENDERS)
310
+ yield* settleFrom({ previous: fingerprint(), remaining: SETTLE_MAX_RENDERS })
261
311
  })
262
312
 
263
- const triggerOf = (name: string, index: number, event: string): Trigger<unknown> => {
264
- const trigger = capturesFor(name)[index]?.events[event]
265
- if (trigger === undefined) {
266
- throw new UnknownAction({ composition: name, action: event, rendered: capturesFor(name).length })
267
- }
268
- return trigger
269
- }
313
+ const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>> =>
314
+ Option.match(Option.fromNullable(capturesFor(ref.name)[ref.index]?.events[event]), {
315
+ onNone: () =>
316
+ Effect.dieMessage(
317
+ new UnknownAction({
318
+ composition: ref.name,
319
+ action: event,
320
+ rendered: capturesFor(ref.name).length,
321
+ }).message,
322
+ ),
323
+ onSome: Effect.succeed,
324
+ })
270
325
 
271
- const propsFor = (name: string, index: number): Effect.Effect<unknown, never, CompositionService> =>
272
- Effect.map(renderTree, () => capturesFor(name)[index]?.props)
326
+ const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, CompositionService> =>
327
+ Effect.map(renderTree, () => capturesFor(ref.name)[ref.index]?.props)
273
328
 
274
- const nodeFacade = (name: string, index: number, comp: CompositionClass<unknown>): AnyFacade => ({
275
- props: propsFor(name, index),
329
+ const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): AnyFacade => ({
330
+ props: propsFor(ref),
276
331
  // Assert the computed props match a typed subset. Reuses the same partial-match
277
- // logic as `expect(...).toMatchObject`; throws `AssertionFailed` on mismatch.
332
+ // logic as `expect(...).toMatchObject`; dies with `AssertionFailed` on mismatch.
278
333
  expectProps: (partial) =>
279
- propsFor(name, index).pipe(
334
+ propsFor(ref).pipe(
280
335
  Effect.flatMap((props) => {
281
- const mismatch = matchProps(props, partial)
336
+ const mismatch = matchProps({ actual: props, expected: partial })
282
337
  return mismatch === undefined
283
338
  ? Effect.void
284
- : Effect.sync(() => {
285
- throw new AssertionFailed({ detail: mismatch })
286
- })
339
+ : Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
287
340
  }),
288
341
  ),
289
- // `keyed` resolves event/slot names at runtime; cast the string-keyed proxy
290
- // to the contract-typed surface. This is the one reflection boundary, the
291
- // same seam as `definitionClass` every name the proxy serves is a real
292
- // contract member, so the cast is sound. The action resolves to the props
293
- // this node computed once the dispatch settled (the typed "result" of the event).
342
+ // `keyed` resolves event/slot names at runtime; the string-keyed proxy is the one
343
+ // reflection boundary every name it serves is a real contract member, so reading
344
+ // it as the contract-typed surface is sound. The action resolves to the props this
345
+ // node computed once the dispatch settled (the typed "result" of the event).
346
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract event names
294
347
  actions: keyed(
295
348
  (event): Action =>
296
349
  (payload) =>
297
350
  renderTree.pipe(
298
- Effect.flatMap(() =>
351
+ Effect.flatMap(() => triggerOf(ref, event)),
352
+ Effect.flatMap((trigger) =>
299
353
  Effect.sync(() => {
300
354
  dispatched.add(event)
301
- triggerOf(name, index, event)(payload)
355
+ trigger(payload)
302
356
  }),
303
357
  ),
304
358
  Effect.flatMap(() => settle),
305
- Effect.flatMap(() => propsFor(name, index)),
359
+ Effect.flatMap(() => propsFor(ref)),
306
360
  ),
307
361
  ) as AnyFacade['actions'],
362
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- reflection boundary: the string-keyed proxy serves only real contract slot names
308
363
  slots: keyed((slotName) => slotFacade(comp, slotName)) as AnyFacade['slots'],
309
364
  frame: settle,
310
365
  })
@@ -313,38 +368,43 @@ export const makeFacade = (
313
368
  const slotClass = parent.manifest.slots?.[slotName]
314
369
  const child = slotClass && bindings.get(slotClass)
315
370
  if (child === undefined) {
371
+ // 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
316
372
  throw new UnknownSlot({ parent: uiNameOf(parent), slot: slotName })
317
373
  }
318
374
  const childName = uiNameOf(child)
319
- const at = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
320
- Effect.as(renderTree, nodeFacade(childName, index, child))
321
- const first = nodeFacade(childName, 0, child)
375
+ const atIndex = (index: number): Effect.Effect<AnyFacade, never, CompositionService> =>
376
+ Effect.as(renderTree, nodeFacade({ name: childName, index }, child))
377
+ const first = nodeFacade({ name: childName, index: 0 }, child)
322
378
  // The render index of the child whose structure-fill `key` matches (plan 06).
323
379
  // Keys ride on the capture from `each`'s `key` (or a singleton `one` key), so
324
- // selection is by stable identity, not render order. Throws `UnknownSlot` (with
325
- // the missing key in the slot position) when no fill carries it.
326
- const indexOfKey = (key: string): number => {
327
- const index = capturesFor(childName).findIndex((capture) => capture.key === key)
328
- if (index < 0) {
329
- throw new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` })
330
- }
331
- return index
332
- }
380
+ // selection is by stable identity, not render order `none` when no fill carries it.
381
+ const indexOfKey = (key: string): Option.Option<number> =>
382
+ Arr.findFirstIndex(capturesFor(childName), (capture) => capture.key === key)
333
383
  return {
334
- first: at(0),
335
- at,
384
+ first: atIndex(0),
385
+ at: atIndex,
336
386
  all: Effect.map(renderTree, () =>
337
- capturesFor(childName).map((_capture, index) => nodeFacade(childName, index, child)),
387
+ capturesFor(childName).map((_capture, index) => nodeFacade({ name: childName, index }, child)),
338
388
  ),
339
389
  // Select the one child mounted under `key` (the `each` item key). Re-renders
340
- // first so the fill keys reflect the latest frame, then resolves its facade.
341
- byKey: (key) => Effect.map(renderTree, () => nodeFacade(childName, indexOfKey(key), child)),
390
+ // first so the fill keys reflect the latest frame, then resolves its facade;
391
+ // dies with `UnknownSlot` (key in the slot position) when no fill carries it.
392
+ byKey: (key) =>
393
+ Effect.flatMap(renderTree, () =>
394
+ Option.match(indexOfKey(key), {
395
+ onNone: () =>
396
+ Effect.dieMessage(
397
+ new UnknownSlot({ parent: uiNameOf(parent), slot: `${slotName}[key=${key}]` }).message,
398
+ ),
399
+ onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
400
+ }),
401
+ ),
342
402
  // Every child whose computed props satisfy `predicate`. Re-renders, then maps
343
403
  // the matching captures back to their facades by render index.
344
404
  where: (predicate) =>
345
405
  Effect.map(renderTree, () =>
346
406
  capturesFor(childName).flatMap((capture, index) =>
347
- predicate(capture.props) ? [nodeFacade(childName, index, child)] : [],
407
+ predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
348
408
  ),
349
409
  ),
350
410
  // How many children this slot mounted this frame — the fill length.
@@ -358,15 +418,15 @@ export const makeFacade = (
358
418
  }
359
419
  }
360
420
 
361
- return { facade: nodeFacade(uiNameOf(root), 0, root), settle }
421
+ return { facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root), settle }
362
422
  }
363
423
 
364
424
  /** Declared-but-never-dispatched events for a proof's requirement (coverage). */
365
- const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> => {
366
- const declared = proof.requirement.manifest.events
367
- if (declared === undefined) return []
368
- return declared.map(String).filter((event) => !dispatched.has(event))
369
- }
425
+ const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
426
+ Option.match(proof.requirement.manifest.events, {
427
+ onNone: () => [],
428
+ onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
429
+ })
370
430
 
371
431
  /**
372
432
  * Run one proof against a fresh runtime, returning its pass/fail result. Each
@@ -377,32 +437,28 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
377
437
  const sink = makeSink()
378
438
  const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
379
439
  const dispatched = new Set<string>()
380
- try {
440
+ const statement = proof.requirement.manifest.statement
441
+ const program = Effect.gen(function* () {
381
442
  const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
382
- await runtime.runPromise(
383
- Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
384
- Effect.flatMap(() => settle),
385
- ),
443
+ yield* Effect.forEach(
444
+ Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
445
+ (event) => publish('High', event),
386
446
  )
387
- await runtime.runPromise(Effect.gen(() => proof.body(facade)))
447
+ yield* settle
448
+ yield* Effect.gen(() => proof.body(facade))
388
449
  const missing = missingCoverage(proof, dispatched)
389
- if (missing.length > 0) {
390
- return {
391
- requirement: proof.requirement.manifest.statement,
392
- ok: false,
393
- error: `requirement declares events never dispatched: ${missing.join(', ')}`,
394
- }
395
- }
396
- return { requirement: proof.requirement.manifest.statement, ok: true }
397
- } catch (error) {
398
- return {
399
- requirement: proof.requirement.manifest.statement,
400
- ok: false,
401
- error: error instanceof Error ? error.message : String(error),
402
- }
403
- } finally {
404
- await runtime.dispose()
405
- }
450
+ const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
451
+ return missing.length > 0
452
+ ? { requirement: statement, ok: false, error: coverageError }
453
+ : { requirement: statement, ok: true }
454
+ }).pipe(
455
+ Effect.catchAllCause((cause) =>
456
+ Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
457
+ ),
458
+ )
459
+ const report = await runtime.runPromise(program)
460
+ await runtime.dispose()
461
+ return report
406
462
  }
407
463
 
408
464
  /**
@@ -417,38 +473,48 @@ export const executeProof = async (proof: Proof): Promise<ProofResult> => {
417
473
  export const driveProof = async (proof: Proof): Promise<DriveResult> => {
418
474
  const sink = makeSink()
419
475
  const runtime = ManagedRuntime.make(proofLayer(proof.scene, sink))
420
- const frames: Array<StepFrame> = []
476
+ const collected: { frames: ReadonlyArray<StepFrame> } = { frames: [] }
421
477
  const statement = proof.requirement.manifest.statement
422
478
  // The driver's per-frame timeline does not enforce coverage; collect into a
423
479
  // throwaway set so `makeFacade`'s contract is satisfied.
424
480
  const dispatched = new Set<string>()
425
- try {
481
+ const record = (index: number): void => {
482
+ collected.frames = [...collected.frames, { index, captures: [...sink.captures] }]
483
+ }
484
+ // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
485
+ const pump = Effect.fn('pump')(function* (
486
+ generator: ReturnType<Proof['body']>,
487
+ step: PumpStep,
488
+ ): Effect.fn.Return<void, unknown, CompositionService> {
489
+ const next = generator.next(step.input)
490
+ if (next.done === true) {
491
+ return
492
+ }
493
+ const output = yield* yieldWrapGet(next.value)
494
+ yield* Effect.sync(() => record(step.index))
495
+ yield* pump(generator, { input: output, index: step.index + 1 })
496
+ })
497
+ const program = Effect.gen(function* () {
426
498
  const { facade, settle } = makeFacade(runtime, proof.scene.composition, sink, dispatched)
427
- await runtime.runPromise(
428
- Effect.forEach(proof.scene.boot ?? [], (event) => publish('High', event)).pipe(
429
- Effect.flatMap(() => settle),
430
- ),
499
+ yield* Effect.forEach(
500
+ Option.getOrElse(Option.fromNullable(proof.scene.boot), () => []),
501
+ (event) => publish('High', event),
431
502
  )
432
- frames.push({ index: 0, captures: [...sink.captures] })
433
- const generator = proof.body(facade)
434
- // Pump the generator: run each yielded effect on the runtime, snapshot, recur.
435
- const pump = async (input: unknown, index: number): Promise<void> => {
436
- const step = generator.next(input)
437
- if (step.done === true) return
438
- const value = await runtime.runPromise(yieldWrapGet(step.value))
439
- frames.push({ index, captures: [...sink.captures] })
440
- await pump(value, index + 1)
441
- }
442
- await pump(undefined, 1)
443
- return { requirement: statement, frames, ok: true }
444
- } catch (error) {
445
- return {
446
- requirement: statement,
447
- frames,
448
- ok: false,
449
- error: error instanceof Error ? error.message : String(error),
450
- }
451
- } finally {
452
- await runtime.dispose()
453
- }
503
+ yield* settle
504
+ yield* Effect.sync(() => record(0))
505
+ yield* pump(proof.body(facade), { input: undefined, index: 1 })
506
+ return { requirement: statement, frames: collected.frames, ok: true }
507
+ }).pipe(
508
+ Effect.catchAllCause((cause) =>
509
+ Effect.succeed({
510
+ requirement: statement,
511
+ frames: collected.frames,
512
+ ok: false,
513
+ error: messageOf(Cause.squash(cause)),
514
+ }),
515
+ ),
516
+ )
517
+ const timeline = await runtime.runPromise(program)
518
+ await runtime.dispose()
519
+ return timeline
454
520
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Effect } from 'effect'
1
+ import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
2
2
  import type { YieldWrap } from 'effect/Utils'
3
3
  import { AssertionFailed } from './errors'
4
4
  import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
@@ -52,7 +52,7 @@ export interface RequirementManifest<Comp extends AnyComposition, Statement exte
52
52
  * dispatches a declared event fails, so the requirement and its proof can't
53
53
  * silently drift (the definition→implementation link past the statement string).
54
54
  */
55
- readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
55
+ readonly events: Option.Option<ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>>
56
56
  }
57
57
 
58
58
  export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
@@ -77,20 +77,27 @@ export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
77
77
  * specifies. The composition types the proof facade; the statement's literal
78
78
  * type is re-stated (and enforced) at `Proof.implement`.
79
79
  */
80
+ /** Author-facing config for `ProductRequirement.make` — plain JSON the proof author passes. */
81
+ interface MakeRequirementOptionsExternalApi<Comp extends AnyComposition> {
82
+ readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
83
+ }
84
+
80
85
  const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
81
86
  composition: Comp,
82
87
  statement: Statement,
83
- options?: { readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>> },
84
- ): RequirementClass<Comp, Statement> =>
85
- Object.assign(class {}, {
86
- manifest: {
87
- kind: 'ProductRequirement' as const,
88
- name: statement,
89
- statement,
90
- composition,
91
- ...(options?.events !== undefined ? { events: options.events } : {}),
92
- },
93
- })
88
+ options?: MakeRequirementOptionsExternalApi<Comp>,
89
+ ): RequirementClass<Comp, Statement> => {
90
+ const manifest: RequirementManifest<Comp, Statement> = {
91
+ kind: 'ProductRequirement',
92
+ name: statement,
93
+ statement,
94
+ composition,
95
+ events: Option.fromNullable(options?.events),
96
+ }
97
+ return class {
98
+ static readonly manifest = manifest
99
+ }
100
+ }
94
101
 
95
102
  export const ProductRequirement: { readonly make: typeof makeRequirement } = {
96
103
  make: makeRequirement,
@@ -100,18 +107,24 @@ export const ProductRequirement: { readonly make: typeof makeRequirement } = {
100
107
  * Group a composition with the full list of requirements that specify it. The
101
108
  * shared `Comp` type-checks that every requirement targets this composition.
102
109
  */
110
+ interface MakeProductConfig<Comp extends AnyComposition> {
111
+ readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
112
+ }
113
+
103
114
  const makeProduct = <Comp extends AnyComposition>(
104
115
  composition: Comp,
105
- config: { readonly requirements: ReadonlyArray<RequirementClass<Comp, string>> },
106
- ): ProductClass<Comp> =>
107
- Object.assign(class {}, {
108
- manifest: {
109
- kind: 'Product' as const,
110
- name: composition.manifest.name,
111
- composition,
112
- requirements: config.requirements,
113
- },
114
- })
116
+ config: MakeProductConfig<Comp>,
117
+ ): ProductClass<Comp> => {
118
+ const manifest: ProductManifest<Comp> = {
119
+ kind: 'Product',
120
+ name: composition.manifest.name,
121
+ composition,
122
+ requirements: config.requirements,
123
+ }
124
+ return class {
125
+ static readonly manifest = manifest
126
+ }
127
+ }
115
128
 
116
129
  export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
117
130
 
@@ -119,59 +132,79 @@ export const Product: { readonly make: typeof makeProduct } = { make: makeProduc
119
132
  // Assertions — Effect-returning matchers; a failed match fails the proof.
120
133
  // ---------------------------------------------------------------------------
121
134
 
122
- const deepEqual = (a: unknown, b: unknown): boolean =>
123
- Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b)
135
+ /** Two values to deep-compare — one options object, not positional primitives. */
136
+ interface ComparePair {
137
+ readonly left: unknown
138
+ readonly right: unknown
139
+ }
140
+
141
+ // Display + structural-compare seam: assertion messages and deep-equality operate on
142
+ // arbitrary `unknown` values, which Schema cannot encode — JSON is the right tool here.
143
+ // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- arbitrary unknown assertion values, not Schema-typed data
144
+ const show = (subject: unknown): string => JSON.stringify(subject)
145
+
146
+ const deepEqual = ({ left, right }: ComparePair): boolean =>
147
+ Object.is(left, right) || show(left) === show(right)
124
148
 
125
149
  const fail = (message: string): Effect.Effect<never> =>
126
- Effect.sync(() => {
127
- throw new AssertionFailed({ detail: message })
128
- })
150
+ Effect.dieMessage(new AssertionFailed({ detail: message }).message)
129
151
 
130
152
  export const expect = <A>(actual: A) => ({
131
153
  toBe: (expected: A): Effect.Effect<void> =>
132
154
  Object.is(actual, expected)
133
155
  ? Effect.void
134
- : fail(`expected ${JSON.stringify(actual)} to be ${JSON.stringify(expected)}`),
156
+ : fail(`expected ${show(actual)} to be ${show(expected)}`),
135
157
  toEqual: (expected: A): Effect.Effect<void> =>
136
- deepEqual(actual, expected)
158
+ deepEqual({ left: actual, right: expected })
137
159
  ? Effect.void
138
- : fail(`expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}`),
160
+ : fail(`expected ${show(actual)} to equal ${show(expected)}`),
139
161
  toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
140
- Array.isArray(actual) && actual.some((item) => deepEqual(item, expected))
162
+ Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
141
163
  ? Effect.void
142
- : fail(`expected ${JSON.stringify(actual)} to contain ${JSON.stringify(expected)}`),
164
+ : fail(`expected ${show(actual)} to contain ${show(expected)}`),
143
165
  toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
144
- const mismatch = matchPartial(actual, expected)
166
+ const mismatch = matchPartial({ actual, expected })
145
167
  return mismatch === undefined ? Effect.void : fail(mismatch)
146
168
  },
147
169
  })
148
170
 
171
+ /** A non-null object as an indexable record — the narrowing `unknown` doesn't give. */
172
+ const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
173
+ typeof candidate === 'object' && candidate !== null
174
+
175
+ /** The pair `matchPartial`/`matchProps` deep-matches: `actual` against the `expected` subset. */
176
+ interface MatchInput {
177
+ readonly actual: unknown
178
+ readonly expected: unknown
179
+ }
180
+
149
181
  /**
150
182
  * Deep-match every key of `expected` against `actual`; returns an error message
151
183
  * naming the first failing key, or `undefined` on a full match. Shared by
152
184
  * `expect(...).toMatchObject` and the facade's `expectProps`.
153
185
  */
154
- const matchPartial = (actual: unknown, expected: unknown): string | undefined => {
155
- if (typeof expected !== 'object' || expected === null) {
156
- return deepEqual(actual, expected)
186
+ const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
187
+ if (!isRecord(expected)) {
188
+ return deepEqual({ left: actual, right: expected })
157
189
  ? undefined
158
- : `expected ${JSON.stringify(actual)} to match ${JSON.stringify(expected)}`
190
+ : `expected ${show(actual)} to match ${show(expected)}`
159
191
  }
160
- if (typeof actual !== 'object' || actual === null) {
161
- return `expected ${JSON.stringify(actual)} to be an object matching ${JSON.stringify(expected)}`
162
- }
163
- const actualRecord = actual as Record<string, unknown>
164
- const expectedRecord = expected as Record<string, unknown>
165
- for (const key of Object.keys(expectedRecord)) {
166
- if (!deepEqual(actualRecord[key], expectedRecord[key])) {
167
- return `expected key ${JSON.stringify(key)} to match ${JSON.stringify(expectedRecord[key])}, got ${JSON.stringify(actualRecord[key])}`
168
- }
192
+ if (!isRecord(actual)) {
193
+ return `expected ${show(actual)} to be an object matching ${show(expected)}`
169
194
  }
170
- return undefined
195
+ const mismatch = Arr.findFirst(
196
+ Rec.keys(expected),
197
+ (key) => !deepEqual({ left: actual[key], right: expected[key] }),
198
+ )
199
+ return Option.match(mismatch, {
200
+ onNone: () => undefined,
201
+ onSome: (key) =>
202
+ `expected key ${show(key)} to match ${show(expected[key])}, got ${show(actual[key])}`,
203
+ })
171
204
  }
172
205
 
173
206
  /** Internal: the facade's `expectProps` reuses the same partial-match logic. */
174
- export const matchProps: (actual: unknown, expected: unknown) => string | undefined = matchPartial
207
+ export const matchProps: (input: MatchInput) => string | undefined = matchPartial
175
208
 
176
209
  // ---------------------------------------------------------------------------
177
210
  // Facade — a headless view over the live composition tree
@@ -289,6 +322,7 @@ export interface ProofSuite {
289
322
  export interface ProofResult {
290
323
  readonly requirement: string
291
324
  readonly ok: boolean
325
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the vitest adapter (external); optional kept for wire compat
292
326
  readonly error?: string
293
327
  }
294
328
 
@@ -310,20 +344,27 @@ const implement = <Comp extends AnyComposition>(
310
344
  requirement: RequirementClass<Comp, string>,
311
345
  scene: Scene<ContractOf<Comp>>,
312
346
  body: (app: Facade<ContractOf<Comp>>) => ProofGenerator,
313
- ): Proof => ({ requirement, scene, body: body as ProofBody })
347
+ ): Proof => {
348
+ // oxlint-disable-next-line reform-rules/no-type-assertion -- contract erasure seam: Facade<C> → AnyFacade is contravariant, sound by construction
349
+ const erased = body as ProofBody
350
+ return { requirement, scene, body: erased }
351
+ }
314
352
 
315
- /** Compose proofs with the product whose requirements they prove. */
316
- const suite = (
317
- product: ProductClass,
318
- config: { readonly proofs: ReadonlyArray<Proof> },
319
- ): ProofSuite => ({ kind: 'ProofSuite', product, proofs: config.proofs })
353
+ /** The proofs a `Proof.suite` binds to its product. */
354
+ interface MakeSuiteConfig {
355
+ readonly proofs: ReadonlyArray<Proof>
356
+ }
320
357
 
321
- const isRecord = (value: unknown): value is Record<string, unknown> =>
322
- typeof value === 'object' && value !== null
358
+ /** Compose proofs with the product whose requirements they prove. */
359
+ const suite = (product: ProductClass, config: MakeSuiteConfig): ProofSuite => ({
360
+ kind: 'ProofSuite',
361
+ product,
362
+ proofs: config.proofs,
363
+ })
323
364
 
324
365
  /** Duck-type a `ProofSuite` among arbitrary module exports (the adapter's seam). */
325
- export const isProofSuite = (value: unknown): value is ProofSuite =>
326
- isRecord(value) && value['kind'] === 'ProofSuite' && Array.isArray(value['proofs'])
366
+ export const isProofSuite = (candidate: unknown): candidate is ProofSuite =>
367
+ isRecord(candidate) && candidate['kind'] === 'ProofSuite' && Array.isArray(candidate['proofs'])
327
368
 
328
369
  /**
329
370
  * Run every proof against a fresh runtime, returning per-requirement results.
@@ -332,11 +373,14 @@ export const isProofSuite = (value: unknown): value is ProofSuite =>
332
373
  */
333
374
  const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
334
375
  withProofRunner(async (runner: ProofRunnerApi) => {
335
- const results: ProofResult[] = []
336
- for (const proof of proofSuite.proofs) {
337
- results.push(await runner.executeProof(proof))
376
+ const reports = await Effect.runPromise(
377
+ Effect.forEach(proofSuite.proofs, (proof) => Effect.promise(() => runner.executeProof(proof))),
378
+ )
379
+ return {
380
+ product: proofSuite.product.manifest.name,
381
+ results: reports,
382
+ ok: reports.every((report) => report.ok),
338
383
  }
339
- return { product: proofSuite.product.manifest.name, results, ok: results.every((r) => r.ok) }
340
384
  })
341
385
 
342
386
  // ---------------------------------------------------------------------------
@@ -354,6 +398,7 @@ export interface DriveResult {
354
398
  readonly requirement: string
355
399
  readonly frames: ReadonlyArray<StepFrame>
356
400
  readonly ok: boolean
401
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the editor timeline (external); optional kept for wire compat
357
402
  readonly error?: string
358
403
  }
359
404