@playfast/reform-proof 0.0.9 → 0.0.11

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