@playfast/reform-proof 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/assertions.ts +65 -0
- package/src/driverTypes.ts +19 -0
- package/src/engine.ts +8 -589
- package/src/errors.ts +0 -14
- package/src/execution.ts +100 -0
- package/src/facade.ts +258 -0
- package/src/index.ts +53 -294
- package/src/product.ts +77 -0
- package/src/runner.ts +1 -17
- package/src/runtimeFacade.ts +269 -0
- package/src/runtimeTreeDispose.ts +21 -0
- package/src/runtimeTreeDriver.ts +285 -0
- package/src/runtimeTreeFacade.ts +134 -0
- package/src/runtimeTreeSettle.ts +34 -0
- package/src/runtimeTreeTypes.ts +99 -0
- package/src/sink.ts +73 -0
- package/src/structure-events.test.ts +0 -11
- package/src/structure-locators.test.ts +0 -23
- package/src/treeBindings.ts +121 -0
- package/src/typed-seams.test.ts +0 -11
package/src/index.ts
CHANGED
|
@@ -1,324 +1,110 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Effect, type Schema } from 'effect'
|
|
2
2
|
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
-
import { AssertionFailed } from './errors'
|
|
4
3
|
import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
|
|
4
|
+
import { type ProductClass, type RequirementClass } from './product'
|
|
5
|
+
import type { ProofDriver } from './driverTypes'
|
|
5
6
|
import type {
|
|
6
7
|
CompositionClass,
|
|
7
8
|
CompositionService,
|
|
8
9
|
Scene,
|
|
9
10
|
SlotInstance,
|
|
10
11
|
Trigger,
|
|
11
|
-
UiCapture,
|
|
12
12
|
UiContract,
|
|
13
13
|
} from '@playfast/reform'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// (the implementation), the same split as the rest of reform. A proof drives the
|
|
18
|
-
// real reduce loop through a headless FACADE: no DOM, no text matching. It reads
|
|
19
|
-
// the props each composition computed and calls the contract's events as
|
|
20
|
-
// callbacks — exactly what the production UI receives. HOW a proof runs is the
|
|
21
|
-
// injectable `ProofRunner` layer (./runner); the headless engine (./engine) is its
|
|
22
|
-
// default. `Proof.run`/`Proof.driver` resolve that layer, so the vitest adapter
|
|
23
|
-
// can drive the very same proofs as native tests.
|
|
15
|
+
type AnyValue = Schema.Schema.Type<Schema.Schema.Any>
|
|
16
|
+
type AnyComposition = CompositionClass<AnyValue, AnyValue>
|
|
24
17
|
|
|
25
|
-
// The injectable execution seam is re-exported so adapters can resolve it.
|
|
26
18
|
export { ProofRunner, proofRunnerLayer, withProofRunner }
|
|
27
19
|
export type { ProofRunnerApi }
|
|
28
20
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
59
|
-
new (): {}
|
|
60
|
-
readonly manifest: RequirementManifest<Comp, Statement>
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface ProductManifest<Comp extends AnyComposition> {
|
|
64
|
-
readonly kind: 'Product'
|
|
65
|
-
readonly name: string
|
|
66
|
-
readonly composition: Comp
|
|
67
|
-
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
71
|
-
new (): {}
|
|
72
|
-
readonly manifest: ProductManifest<Comp>
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A named, human-readable behavioural statement bound to the composition it
|
|
77
|
-
* specifies. The composition types the proof facade; the statement's literal
|
|
78
|
-
* type is re-stated (and enforced) at `Proof.implement`.
|
|
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
|
-
|
|
85
|
-
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
86
|
-
composition: Comp,
|
|
87
|
-
statement: Statement,
|
|
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
|
-
}
|
|
101
|
-
|
|
102
|
-
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
103
|
-
make: makeRequirement,
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Group a composition with the full list of requirements that specify it. The
|
|
108
|
-
* shared `Comp` type-checks that every requirement targets this composition.
|
|
109
|
-
*/
|
|
110
|
-
interface MakeProductConfig<Comp extends AnyComposition> {
|
|
111
|
-
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const makeProduct = <Comp extends AnyComposition>(
|
|
115
|
-
composition: Comp,
|
|
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
|
-
}
|
|
128
|
-
|
|
129
|
-
export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Assertions — Effect-returning matchers; a failed match fails the proof.
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
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)
|
|
148
|
-
|
|
149
|
-
const fail = (message: string): Effect.Effect<never> =>
|
|
150
|
-
Effect.dieMessage(new AssertionFailed({ detail: message }).message)
|
|
151
|
-
|
|
152
|
-
export const expect = <A>(actual: A) => ({
|
|
153
|
-
toBe: (expected: A): Effect.Effect<void> =>
|
|
154
|
-
Object.is(actual, expected)
|
|
155
|
-
? Effect.void
|
|
156
|
-
: fail(`expected ${show(actual)} to be ${show(expected)}`),
|
|
157
|
-
toEqual: (expected: A): Effect.Effect<void> =>
|
|
158
|
-
deepEqual({ left: actual, right: expected })
|
|
159
|
-
? Effect.void
|
|
160
|
-
: fail(`expected ${show(actual)} to equal ${show(expected)}`),
|
|
161
|
-
toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
|
|
162
|
-
Array.isArray(actual) && actual.some((element) => deepEqual({ left: element, right: expected }))
|
|
163
|
-
? Effect.void
|
|
164
|
-
: fail(`expected ${show(actual)} to contain ${show(expected)}`),
|
|
165
|
-
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
166
|
-
const mismatch = matchPartial({ actual, expected })
|
|
167
|
-
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
168
|
-
},
|
|
169
|
-
})
|
|
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
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
183
|
-
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
184
|
-
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
185
|
-
*/
|
|
186
|
-
const matchPartial = ({ actual, expected }: MatchInput): string | undefined => {
|
|
187
|
-
if (!isRecord(expected)) {
|
|
188
|
-
return deepEqual({ left: actual, right: expected })
|
|
189
|
-
? undefined
|
|
190
|
-
: `expected ${show(actual)} to match ${show(expected)}`
|
|
191
|
-
}
|
|
192
|
-
if (!isRecord(actual)) {
|
|
193
|
-
return `expected ${show(actual)} to be an object matching ${show(expected)}`
|
|
194
|
-
}
|
|
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
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
207
|
-
export const matchProps: (input: MatchInput) => string | undefined = matchPartial
|
|
208
|
-
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
// Facade — a headless view over the live composition tree
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
// --- Contract projection: derive the facade's exact shape from a UI contract ---
|
|
214
|
-
|
|
215
|
-
/** The event payloads of a contract, keyed by event name. */
|
|
216
|
-
type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
|
|
217
|
-
/** The slot instances of a contract, keyed by slot name. */
|
|
21
|
+
export {
|
|
22
|
+
makeFacade,
|
|
23
|
+
makeRuntimeHandleFacade,
|
|
24
|
+
makeRuntimeTreeFacade,
|
|
25
|
+
makeSink,
|
|
26
|
+
proofLayer,
|
|
27
|
+
} from './engine'
|
|
28
|
+
export type {
|
|
29
|
+
MountedFacade,
|
|
30
|
+
MountedSlotFacade,
|
|
31
|
+
RuntimeServices,
|
|
32
|
+
RuntimeTreeFacade,
|
|
33
|
+
Sink,
|
|
34
|
+
} from './engine'
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
Product,
|
|
38
|
+
type ProductClass,
|
|
39
|
+
type ProductManifest,
|
|
40
|
+
ProductRequirement,
|
|
41
|
+
type RequirementClass,
|
|
42
|
+
type RequirementManifest,
|
|
43
|
+
} from './product'
|
|
44
|
+
|
|
45
|
+
export { expect, matchProps } from './assertions'
|
|
46
|
+
|
|
47
|
+
export type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
|
|
218
48
|
type SlotsOf<C extends UiContract> = C extends { slots: infer S } ? S : Record<never, never>
|
|
219
|
-
/** The payload a trigger accepts. */
|
|
220
49
|
type PayloadOf<T> = T extends Trigger<infer P> ? P : never
|
|
221
|
-
|
|
222
|
-
export type ContractOf<Comp> = Comp extends CompositionClass<any, infer C> ? C : never
|
|
223
|
-
/** The child contract a slot stands for — the contract of the composition it holds. */
|
|
50
|
+
export type ContractOf<Comp> = Comp extends CompositionClass<AnyValue, infer C> ? C : never
|
|
224
51
|
type ContractOfSlot<S> = S extends SlotInstance<infer Comp> ? ContractOf<Comp> : never
|
|
225
52
|
|
|
226
|
-
/**
|
|
227
|
-
* The contract's events as facade actions: payload in, dispatch-and-settle Effect
|
|
228
|
-
* out. The Effect resolves to the props this node computed *after* the dispatch
|
|
229
|
-
* settled — the proof analog of chat-tests' `emitToolCall → ToolOutput`: in a
|
|
230
|
-
* fire-and-forget reduce loop the typed "result" of an event is the next state.
|
|
231
|
-
*/
|
|
232
53
|
export type ActionsOf<C extends UiContract> = {
|
|
233
54
|
readonly [K in keyof EventsOf<C>]: (
|
|
234
55
|
payload: PayloadOf<EventsOf<C>[K]>,
|
|
235
56
|
) => Effect.Effect<C['props'], never, CompositionService>
|
|
236
57
|
}
|
|
237
|
-
/** The contract's slots as child facades, each typed by the child's own contract. */
|
|
238
58
|
export type SlotFacadesOf<C extends UiContract> = {
|
|
239
59
|
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>>
|
|
240
60
|
}
|
|
241
61
|
|
|
242
|
-
/** The Effect a facade action returns: dispatch the event, settle, read props. */
|
|
243
62
|
export type Action = (payload: unknown) => Effect.Effect<unknown, never, CompositionService>
|
|
244
63
|
|
|
245
|
-
/**
|
|
246
|
-
* The handle a proof drives — the same surface the production UI receives, fully
|
|
247
|
-
* typed from the composition's contract `C`: read state (`props`), trigger events
|
|
248
|
-
* (`actions`), reach children (`slots`), and wait for the next frame (`frame`).
|
|
249
|
-
* Each access yields a real Effect/SlotFacade against the running engine.
|
|
250
|
-
*/
|
|
251
64
|
export interface Facade<C extends UiContract> {
|
|
252
|
-
/** Re-render the tree and read the props this node last computed. */
|
|
253
65
|
readonly props: Effect.Effect<C['props'], never, CompositionService>
|
|
254
|
-
/**
|
|
255
|
-
* Re-render and assert the computed props match `partial` (a subset, deep).
|
|
256
|
-
* Typed from the contract, so a mistyped or unknown prop key is a compile
|
|
257
|
-
* error — unlike reading `props` and comparing a free-form object.
|
|
258
|
-
*/
|
|
259
66
|
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, CompositionService>
|
|
260
|
-
/** The contract's events as callables; calling one dispatches and settles. */
|
|
261
67
|
readonly actions: ActionsOf<C>
|
|
262
|
-
/** Child composition facades, keyed by slot name. */
|
|
263
68
|
readonly slots: SlotFacadesOf<C>
|
|
264
|
-
/** Settle the engine and re-render — wait for the next stable frame. */
|
|
265
69
|
readonly frame: Effect.Effect<void, never, CompositionService>
|
|
266
70
|
}
|
|
267
71
|
|
|
268
|
-
/** A slot may hold many instances (a list); it is also usable as its first one. */
|
|
269
72
|
export interface SlotFacade<C extends UiContract> extends Facade<C> {
|
|
270
73
|
readonly first: Effect.Effect<Facade<C>, never, CompositionService>
|
|
271
74
|
readonly at: (index: number) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
272
75
|
readonly all: Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
273
|
-
/**
|
|
274
|
-
* Select the one child mounted under `key` — the `each` item key the structure
|
|
275
|
-
* carried (the SINGLE source of the wire key and the per-item family key, so it
|
|
276
|
-
* can't drift from what the view places). The returned facade is itself typed by
|
|
277
|
-
* the child contract `C`, so `.slots` keeps descending type-safely:
|
|
278
|
-
* `app.slots.List.slots.Item.byKey('todo-1').slots…`. Fails the proof
|
|
279
|
-
* (`UnknownSlot`) when no fill carries the key.
|
|
280
|
-
*/
|
|
281
76
|
readonly byKey: (key: string) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
282
|
-
/** Every child whose computed props satisfy `predicate` — the structure-driven
|
|
283
|
-
* analog of a query, resolved against this frame's fills (props typed by `C`). */
|
|
284
77
|
readonly where: (
|
|
285
78
|
predicate: (props: C['props']) => boolean,
|
|
286
79
|
) => Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
287
|
-
/** How many children this slot mounted this frame (the fill length). */
|
|
288
80
|
readonly count: Effect.Effect<number, never, CompositionService>
|
|
289
81
|
}
|
|
290
82
|
|
|
291
|
-
// ---------------------------------------------------------------------------
|
|
292
|
-
// Proofs + suite
|
|
293
|
-
// ---------------------------------------------------------------------------
|
|
294
|
-
|
|
295
|
-
/** The generator a proof body produces — its yielded effects run on the engine. */
|
|
296
83
|
type ProofGenerator = Generator<
|
|
297
84
|
YieldWrap<Effect.Effect<unknown, unknown, CompositionService>>,
|
|
298
85
|
void,
|
|
299
86
|
unknown
|
|
300
87
|
>
|
|
301
88
|
|
|
302
|
-
/** An erased facade — the runtime shape before the contract type is re-attached. */
|
|
303
89
|
type AnyFacade = Facade<UiContract>
|
|
304
90
|
|
|
305
|
-
/** The erased body stored on a `Proof` value; `implement` types the contract in. */
|
|
306
91
|
export type ProofBody = (app: AnyFacade) => ProofGenerator
|
|
307
92
|
|
|
308
93
|
export interface Proof {
|
|
309
94
|
readonly requirement: RequirementClass<AnyComposition, string>
|
|
310
|
-
/** The scene this proof runs against: its closed wiring + boot events. */
|
|
311
95
|
readonly scene: Scene
|
|
312
96
|
readonly body: ProofBody
|
|
313
97
|
}
|
|
314
98
|
|
|
315
99
|
export interface ProofSuite {
|
|
316
|
-
/** Brand so adapters can duck-type a suite among a module's exports. */
|
|
317
100
|
readonly kind: 'ProofSuite'
|
|
318
101
|
readonly product: ProductClass
|
|
319
102
|
readonly proofs: ReadonlyArray<Proof>
|
|
320
103
|
}
|
|
321
104
|
|
|
105
|
+
const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
106
|
+
typeof candidate === 'object' && candidate !== null
|
|
107
|
+
|
|
322
108
|
export interface ProofResult {
|
|
323
109
|
readonly requirement: string
|
|
324
110
|
readonly ok: boolean
|
|
@@ -332,14 +118,6 @@ export interface SuiteResult {
|
|
|
332
118
|
readonly ok: boolean
|
|
333
119
|
}
|
|
334
120
|
|
|
335
|
-
/**
|
|
336
|
-
* Implement (prove) a requirement by driving the facade against a scene. A proof
|
|
337
|
-
* is a VALUE. The scene supplies the runtime (its closed `provide` + `boot`) and
|
|
338
|
-
* the composition that types the facade; its contract must equal the
|
|
339
|
-
* requirement's composition contract, so a scene for the wrong composition — or
|
|
340
|
-
* one whose contract has drifted — is a compile error. The `app` facade is fully
|
|
341
|
-
* typed from that contract.
|
|
342
|
-
*/
|
|
343
121
|
const implement = <Comp extends AnyComposition>(
|
|
344
122
|
requirement: RequirementClass<Comp, string>,
|
|
345
123
|
scene: Scene<ContractOf<Comp>>,
|
|
@@ -350,27 +128,33 @@ const implement = <Comp extends AnyComposition>(
|
|
|
350
128
|
return { requirement, scene, body: erased }
|
|
351
129
|
}
|
|
352
130
|
|
|
353
|
-
|
|
131
|
+
type SlotsHolding<C extends UiContract, Target> = {
|
|
132
|
+
[K in keyof SlotsOf<C>]: ContractOfSlot<SlotsOf<C>[K]> extends Target ? K : never
|
|
133
|
+
}[keyof SlotsOf<C>]
|
|
134
|
+
|
|
135
|
+
const implementVia = <Comp extends AnyComposition, C extends UiContract>(
|
|
136
|
+
requirement: RequirementClass<Comp, string>,
|
|
137
|
+
scene: Scene<C> & ([SlotsHolding<C, ContractOf<Comp>>] extends [never] ? never : unknown),
|
|
138
|
+
body: (app: Facade<C>) => ProofGenerator,
|
|
139
|
+
): Proof => {
|
|
140
|
+
// oxlint-disable-next-line reform-rules/no-type-assertion -- contract erasure seam: Facade<C> → AnyFacade is contravariant, sound by construction
|
|
141
|
+
const erased = body as ProofBody
|
|
142
|
+
return { requirement, scene, body: erased }
|
|
143
|
+
}
|
|
144
|
+
|
|
354
145
|
interface MakeSuiteConfig {
|
|
355
146
|
readonly proofs: ReadonlyArray<Proof>
|
|
356
147
|
}
|
|
357
148
|
|
|
358
|
-
/** Compose proofs with the product whose requirements they prove. */
|
|
359
149
|
const suite = (product: ProductClass, config: MakeSuiteConfig): ProofSuite => ({
|
|
360
150
|
kind: 'ProofSuite',
|
|
361
151
|
product,
|
|
362
152
|
proofs: config.proofs,
|
|
363
153
|
})
|
|
364
154
|
|
|
365
|
-
/** Duck-type a `ProofSuite` among arbitrary module exports (the adapter's seam). */
|
|
366
155
|
export const isProofSuite = (candidate: unknown): candidate is ProofSuite =>
|
|
367
156
|
isRecord(candidate) && candidate['kind'] === 'ProofSuite' && Array.isArray(candidate['proofs'])
|
|
368
157
|
|
|
369
|
-
/**
|
|
370
|
-
* Run every proof against a fresh runtime, returning per-requirement results.
|
|
371
|
-
* Each proof gets its own environment, so nothing leaks between proofs. Execution
|
|
372
|
-
* goes through the injected `ProofRunner` (the headless engine by default).
|
|
373
|
-
*/
|
|
374
158
|
const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
|
|
375
159
|
withProofRunner(async (runner: ProofRunnerApi) => {
|
|
376
160
|
const reports = await Effect.runPromise(
|
|
@@ -383,33 +167,7 @@ const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
|
|
|
383
167
|
}
|
|
384
168
|
})
|
|
385
169
|
|
|
386
|
-
|
|
387
|
-
// Driver: step a proof for the editor's Test-play timeline
|
|
388
|
-
// ---------------------------------------------------------------------------
|
|
389
|
-
|
|
390
|
-
/** One step of a driven proof: the rendered tree captured right after the
|
|
391
|
-
* proof's i-th yielded effect settled. Index 0 is the booted, pre-drive tree. */
|
|
392
|
-
export interface StepFrame {
|
|
393
|
-
readonly index: number
|
|
394
|
-
readonly captures: ReadonlyArray<UiCapture>
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export interface DriveResult {
|
|
398
|
-
readonly requirement: string
|
|
399
|
-
readonly frames: ReadonlyArray<StepFrame>
|
|
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
|
|
402
|
-
readonly error?: string
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export interface ProofDriver {
|
|
406
|
-
readonly requirement: string
|
|
407
|
-
/** Run the proof, snapshotting the captured tree after each yielded step. */
|
|
408
|
-
readonly run: () => Promise<DriveResult>
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** A driver per proof in the suite — the editor's Test-play entry point. Each
|
|
412
|
-
* `run` resolves the same `ProofRunner` layer and steps that proof. */
|
|
170
|
+
export type { DriveResult, ProofDriver, StepFrame } from './driverTypes'
|
|
413
171
|
const driver = (proofSuite: ProofSuite): ReadonlyArray<ProofDriver> =>
|
|
414
172
|
proofSuite.proofs.map((proof) => ({
|
|
415
173
|
requirement: proof.requirement.manifest.statement,
|
|
@@ -418,7 +176,8 @@ const driver = (proofSuite: ProofSuite): ReadonlyArray<ProofDriver> =>
|
|
|
418
176
|
|
|
419
177
|
export const Proof: {
|
|
420
178
|
readonly implement: typeof implement
|
|
179
|
+
readonly implementVia: typeof implementVia
|
|
421
180
|
readonly suite: typeof suite
|
|
422
181
|
readonly run: typeof run
|
|
423
182
|
readonly driver: typeof driver
|
|
424
|
-
} = { implement, suite, run, driver }
|
|
183
|
+
} = { implement, implementVia, suite, run, driver }
|
package/src/product.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Option, type Schema } from 'effect'
|
|
2
|
+
import type { CompositionClass } from '@playfast/reform'
|
|
3
|
+
import type { ContractOf, EventsOf } from './index'
|
|
4
|
+
|
|
5
|
+
type AnyValue = Schema.Schema.Type<Schema.Schema.Any>
|
|
6
|
+
type AnyComposition = CompositionClass<AnyValue, AnyValue>
|
|
7
|
+
|
|
8
|
+
export interface RequirementManifest<Comp extends AnyComposition, Statement extends string> {
|
|
9
|
+
readonly kind: 'ProductRequirement'
|
|
10
|
+
readonly name: Statement
|
|
11
|
+
readonly statement: Statement
|
|
12
|
+
readonly composition: Comp
|
|
13
|
+
readonly events: Option.Option<ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
17
|
+
new (): {}
|
|
18
|
+
readonly manifest: RequirementManifest<Comp, Statement>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProductManifest<Comp extends AnyComposition> {
|
|
22
|
+
readonly kind: 'Product'
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly composition: Comp
|
|
25
|
+
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
29
|
+
new (): {}
|
|
30
|
+
readonly manifest: ProductManifest<Comp>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface MakeRequirementOptionsExternalApi<Comp extends AnyComposition> {
|
|
34
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
38
|
+
composition: Comp,
|
|
39
|
+
statement: Statement,
|
|
40
|
+
options?: MakeRequirementOptionsExternalApi<Comp>,
|
|
41
|
+
): RequirementClass<Comp, Statement> => {
|
|
42
|
+
const manifest: RequirementManifest<Comp, Statement> = {
|
|
43
|
+
kind: 'ProductRequirement',
|
|
44
|
+
name: statement,
|
|
45
|
+
statement,
|
|
46
|
+
composition,
|
|
47
|
+
events: Option.fromNullable(options?.events),
|
|
48
|
+
}
|
|
49
|
+
return class {
|
|
50
|
+
static readonly manifest = manifest
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
55
|
+
make: makeRequirement,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MakeProductConfig<Comp extends AnyComposition> {
|
|
59
|
+
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const makeProduct = <Comp extends AnyComposition>(
|
|
63
|
+
composition: Comp,
|
|
64
|
+
config: MakeProductConfig<Comp>,
|
|
65
|
+
): ProductClass<Comp> => {
|
|
66
|
+
const manifest: ProductManifest<Comp> = {
|
|
67
|
+
kind: 'Product',
|
|
68
|
+
name: composition.manifest.name,
|
|
69
|
+
composition,
|
|
70
|
+
requirements: config.requirements,
|
|
71
|
+
}
|
|
72
|
+
return class {
|
|
73
|
+
static readonly manifest = manifest
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
|
package/src/runner.ts
CHANGED
|
@@ -1,38 +1,22 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from 'effect'
|
|
2
|
-
import { driveProof, executeProof } from './
|
|
2
|
+
import { driveProof, executeProof } from './execution'
|
|
3
3
|
import type { DriveResult, Proof, ProofResult } from './index'
|
|
4
4
|
|
|
5
|
-
// reform-proof runner — the injectable seam HOW a proof is executed. The proof
|
|
6
|
-
// system depends on this layer rather than calling the engine directly, so the
|
|
7
|
-
// execution strategy can be swapped (the vitest adapter resolves the same layer to
|
|
8
|
-
// run each proof as a native test). The default layer is the headless engine in
|
|
9
|
-
// ./engine; mirrors the Tag + Layer house style of reform's `CaptureSink` and
|
|
10
|
-
// `Notifications` services.
|
|
11
|
-
|
|
12
5
|
export interface ProofRunnerApi {
|
|
13
|
-
/** Run a proof to completion, returning its pass/fail result. */
|
|
14
6
|
readonly executeProof: (proof: Proof) => Promise<ProofResult>
|
|
15
|
-
/** Step a proof a frame at a time (the editor's Test-play timeline). */
|
|
16
7
|
readonly driveProof: (proof: Proof) => Promise<DriveResult>
|
|
17
8
|
}
|
|
18
9
|
|
|
19
10
|
const ProofRunnerBase: Context.TagClass<ProofRunner, 'reform-proof/ProofRunner', ProofRunnerApi> =
|
|
20
11
|
Context.Tag('reform-proof/ProofRunner')<ProofRunner, ProofRunnerApi>()
|
|
21
12
|
|
|
22
|
-
/** The service identity for the proof execution strategy. */
|
|
23
13
|
export class ProofRunner extends ProofRunnerBase {}
|
|
24
14
|
|
|
25
|
-
/** The default runner: the headless engine that drives the real reduce loop. */
|
|
26
15
|
export const proofRunnerLayer: Layer.Layer<ProofRunner> = Layer.succeed(ProofRunner, {
|
|
27
16
|
executeProof,
|
|
28
17
|
driveProof,
|
|
29
18
|
})
|
|
30
19
|
|
|
31
|
-
/**
|
|
32
|
-
* Resolve the `ProofRunner` from `proofRunnerLayer` and use it — the single seam
|
|
33
|
-
* the proof system (`Proof.run`/`Proof.driver`) and the vitest adapter both run
|
|
34
|
-
* through, so every proof executes via the same injectable engine.
|
|
35
|
-
*/
|
|
36
20
|
export const withProofRunner = <A>(use: (runner: ProofRunnerApi) => Promise<A>): Promise<A> =>
|
|
37
21
|
Effect.runPromise(
|
|
38
22
|
ProofRunner.pipe(
|