@playfast/reform-proof 0.0.7 → 0.0.9
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 +2 -2
- package/src/engine.ts +452 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +373 -0
- package/src/runner.ts +42 -0
- package/src/structure-events.test.ts +74 -0
- package/src/structure-locators.test.ts +137 -0
- package/src/typed-seams.test.ts +166 -0
- package/dist/engine.d.ts +0 -18
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -259
- package/dist/engine.js.map +0 -1
- package/dist/errors.d.ts +0 -40
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -23
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts +0 -176
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -92
- package/dist/index.js.map +0 -1
- package/dist/runner.d.ts +0 -22
- package/dist/runner.d.ts.map +0 -1
- package/dist/runner.js +0 -18
- package/dist/runner.js.map +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
+
import { AssertionFailed } from './errors'
|
|
4
|
+
import { ProofRunner, type ProofRunnerApi, proofRunnerLayer, withProofRunner } from './runner'
|
|
5
|
+
import type {
|
|
6
|
+
CompositionClass,
|
|
7
|
+
CompositionService,
|
|
8
|
+
Scene,
|
|
9
|
+
SlotInstance,
|
|
10
|
+
Trigger,
|
|
11
|
+
UiCapture,
|
|
12
|
+
UiContract,
|
|
13
|
+
} from '@playfast/reform'
|
|
14
|
+
|
|
15
|
+
// reform-proof — the stability story for AI authorship. Product behaviour is
|
|
16
|
+
// declared as human-readable requirements (the definition) and proven by tests
|
|
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.
|
|
24
|
+
|
|
25
|
+
// The injectable execution seam is re-exported so adapters can resolve it.
|
|
26
|
+
export { ProofRunner, proofRunnerLayer, withProofRunner }
|
|
27
|
+
export type { ProofRunnerApi }
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Definitions: ProductRequirement + Product
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Any composition, used where the contract type is irrelevant. */
|
|
34
|
+
type AnyComposition = CompositionClass<any, any>
|
|
35
|
+
|
|
36
|
+
export interface RequirementManifest<Comp extends AnyComposition, Statement extends string> {
|
|
37
|
+
readonly kind: 'ProductRequirement'
|
|
38
|
+
readonly name: Statement
|
|
39
|
+
/** The human-readable behavioural statement; doubles as the manifest name. */
|
|
40
|
+
readonly statement: Statement
|
|
41
|
+
/** The composition this requirement specifies — its contract types the facade. */
|
|
42
|
+
readonly composition: Comp
|
|
43
|
+
/**
|
|
44
|
+
* Optional: the contract events this requirement exercises. Typed to the
|
|
45
|
+
* composition's event names, and verified at run — a proof that never
|
|
46
|
+
* dispatches a declared event fails, so the requirement and its proof can't
|
|
47
|
+
* silently drift (the definition→implementation link past the statement string).
|
|
48
|
+
*/
|
|
49
|
+
readonly events?: ReadonlyArray<keyof EventsOf<ContractOf<Comp>>>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RequirementClass<Comp extends AnyComposition, Statement extends string> {
|
|
53
|
+
new (): {}
|
|
54
|
+
readonly manifest: RequirementManifest<Comp, Statement>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProductManifest<Comp extends AnyComposition> {
|
|
58
|
+
readonly kind: 'Product'
|
|
59
|
+
readonly name: string
|
|
60
|
+
readonly composition: Comp
|
|
61
|
+
readonly requirements: ReadonlyArray<RequirementClass<Comp, string>>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProductClass<Comp extends AnyComposition = AnyComposition> {
|
|
65
|
+
new (): {}
|
|
66
|
+
readonly manifest: ProductManifest<Comp>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A named, human-readable behavioural statement bound to the composition it
|
|
71
|
+
* specifies. The composition types the proof facade; the statement's literal
|
|
72
|
+
* type is re-stated (and enforced) at `Proof.implement`.
|
|
73
|
+
*/
|
|
74
|
+
const makeRequirement = <Comp extends AnyComposition, const Statement extends string>(
|
|
75
|
+
composition: Comp,
|
|
76
|
+
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
|
+
|
|
89
|
+
export const ProductRequirement: { readonly make: typeof makeRequirement } = {
|
|
90
|
+
make: makeRequirement,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Group a composition with the full list of requirements that specify it. The
|
|
95
|
+
* shared `Comp` type-checks that every requirement targets this composition.
|
|
96
|
+
*/
|
|
97
|
+
const makeProduct = <Comp extends AnyComposition>(
|
|
98
|
+
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
|
+
})
|
|
109
|
+
|
|
110
|
+
export const Product: { readonly make: typeof makeProduct } = { make: makeProduct }
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Assertions — Effect-returning matchers; a failed match fails the proof.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const deepEqual = (a: unknown, b: unknown): boolean =>
|
|
117
|
+
Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b)
|
|
118
|
+
|
|
119
|
+
const fail = (message: string): Effect.Effect<never> =>
|
|
120
|
+
Effect.sync(() => {
|
|
121
|
+
throw new AssertionFailed({ detail: message })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export const expect = <A>(actual: A) => ({
|
|
125
|
+
toBe: (expected: A): Effect.Effect<void> =>
|
|
126
|
+
Object.is(actual, expected)
|
|
127
|
+
? Effect.void
|
|
128
|
+
: fail(`expected ${JSON.stringify(actual)} to be ${JSON.stringify(expected)}`),
|
|
129
|
+
toEqual: (expected: A): Effect.Effect<void> =>
|
|
130
|
+
deepEqual(actual, expected)
|
|
131
|
+
? Effect.void
|
|
132
|
+
: fail(`expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}`),
|
|
133
|
+
toContain: (expected: A extends ReadonlyArray<infer E> ? E : unknown): Effect.Effect<void> =>
|
|
134
|
+
Array.isArray(actual) && actual.some((item) => deepEqual(item, expected))
|
|
135
|
+
? Effect.void
|
|
136
|
+
: fail(`expected ${JSON.stringify(actual)} to contain ${JSON.stringify(expected)}`),
|
|
137
|
+
toMatchObject: (expected: Partial<A>): Effect.Effect<void> => {
|
|
138
|
+
const mismatch = matchPartial(actual, expected)
|
|
139
|
+
return mismatch === undefined ? Effect.void : fail(mismatch)
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Deep-match every key of `expected` against `actual`; returns an error message
|
|
145
|
+
* naming the first failing key, or `undefined` on a full match. Shared by
|
|
146
|
+
* `expect(...).toMatchObject` and the facade's `expectProps`.
|
|
147
|
+
*/
|
|
148
|
+
const matchPartial = (actual: unknown, expected: unknown): string | undefined => {
|
|
149
|
+
if (typeof expected !== 'object' || expected === null) {
|
|
150
|
+
return deepEqual(actual, expected)
|
|
151
|
+
? 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)}`
|
|
156
|
+
}
|
|
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
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Internal: the facade's `expectProps` reuses the same partial-match logic. */
|
|
168
|
+
export const matchProps: (actual: unknown, expected: unknown) => string | undefined = matchPartial
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Facade — a headless view over the live composition tree
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
// --- Contract projection: derive the facade's exact shape from a UI contract ---
|
|
175
|
+
|
|
176
|
+
/** The event payloads of a contract, keyed by event name. */
|
|
177
|
+
type EventsOf<C extends UiContract> = C extends { events: infer E } ? E : Record<never, never>
|
|
178
|
+
/** The slot instances of a contract, keyed by slot name. */
|
|
179
|
+
type SlotsOf<C extends UiContract> = C extends { slots: infer S } ? S : Record<never, never>
|
|
180
|
+
/** The payload a trigger accepts. */
|
|
181
|
+
type PayloadOf<T> = T extends Trigger<infer P> ? P : never
|
|
182
|
+
/** The UI contract a composition resolves. */
|
|
183
|
+
export type ContractOf<Comp> = Comp extends CompositionClass<any, infer C> ? C : never
|
|
184
|
+
/** The child contract a slot stands for — the contract of the composition it holds. */
|
|
185
|
+
type ContractOfSlot<S> = S extends SlotInstance<infer Comp> ? ContractOf<Comp> : never
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The contract's events as facade actions: payload in, dispatch-and-settle Effect
|
|
189
|
+
* out. The Effect resolves to the props this node computed *after* the dispatch
|
|
190
|
+
* settled — the proof analog of chat-tests' `emitToolCall → ToolOutput`: in a
|
|
191
|
+
* fire-and-forget reduce loop the typed "result" of an event is the next state.
|
|
192
|
+
*/
|
|
193
|
+
export type ActionsOf<C extends UiContract> = {
|
|
194
|
+
readonly [K in keyof EventsOf<C>]: (
|
|
195
|
+
payload: PayloadOf<EventsOf<C>[K]>,
|
|
196
|
+
) => Effect.Effect<C['props'], never, CompositionService>
|
|
197
|
+
}
|
|
198
|
+
/** The contract's slots as child facades, each typed by the child's own contract. */
|
|
199
|
+
export type SlotFacadesOf<C extends UiContract> = {
|
|
200
|
+
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The Effect a facade action returns: dispatch the event, settle, read props. */
|
|
204
|
+
export type Action = (payload: unknown) => Effect.Effect<unknown, never, CompositionService>
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The handle a proof drives — the same surface the production UI receives, fully
|
|
208
|
+
* typed from the composition's contract `C`: read state (`props`), trigger events
|
|
209
|
+
* (`actions`), reach children (`slots`), and wait for the next frame (`frame`).
|
|
210
|
+
* Each access yields a real Effect/SlotFacade against the running engine.
|
|
211
|
+
*/
|
|
212
|
+
export interface Facade<C extends UiContract> {
|
|
213
|
+
/** Re-render the tree and read the props this node last computed. */
|
|
214
|
+
readonly props: Effect.Effect<C['props'], never, CompositionService>
|
|
215
|
+
/**
|
|
216
|
+
* Re-render and assert the computed props match `partial` (a subset, deep).
|
|
217
|
+
* Typed from the contract, so a mistyped or unknown prop key is a compile
|
|
218
|
+
* error — unlike reading `props` and comparing a free-form object.
|
|
219
|
+
*/
|
|
220
|
+
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, CompositionService>
|
|
221
|
+
/** The contract's events as callables; calling one dispatches and settles. */
|
|
222
|
+
readonly actions: ActionsOf<C>
|
|
223
|
+
/** Child composition facades, keyed by slot name. */
|
|
224
|
+
readonly slots: SlotFacadesOf<C>
|
|
225
|
+
/** Settle the engine and re-render — wait for the next stable frame. */
|
|
226
|
+
readonly frame: Effect.Effect<void, never, CompositionService>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A slot may hold many instances (a list); it is also usable as its first one. */
|
|
230
|
+
export interface SlotFacade<C extends UiContract> extends Facade<C> {
|
|
231
|
+
readonly first: Effect.Effect<Facade<C>, never, CompositionService>
|
|
232
|
+
readonly at: (index: number) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
233
|
+
readonly all: Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
234
|
+
/**
|
|
235
|
+
* Select the one child mounted under `key` — the `each` item key the structure
|
|
236
|
+
* carried (the SINGLE source of the wire key and the per-item family key, so it
|
|
237
|
+
* can't drift from what the view places). The returned facade is itself typed by
|
|
238
|
+
* the child contract `C`, so `.slots` keeps descending type-safely:
|
|
239
|
+
* `app.slots.List.slots.Item.byKey('todo-1').slots…`. Fails the proof
|
|
240
|
+
* (`UnknownSlot`) when no fill carries the key.
|
|
241
|
+
*/
|
|
242
|
+
readonly byKey: (key: string) => Effect.Effect<Facade<C>, never, CompositionService>
|
|
243
|
+
/** Every child whose computed props satisfy `predicate` — the structure-driven
|
|
244
|
+
* analog of a query, resolved against this frame's fills (props typed by `C`). */
|
|
245
|
+
readonly where: (
|
|
246
|
+
predicate: (props: C['props']) => boolean,
|
|
247
|
+
) => Effect.Effect<ReadonlyArray<Facade<C>>, never, CompositionService>
|
|
248
|
+
/** How many children this slot mounted this frame (the fill length). */
|
|
249
|
+
readonly count: Effect.Effect<number, never, CompositionService>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Proofs + suite
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/** The generator a proof body produces — its yielded effects run on the engine. */
|
|
257
|
+
type ProofGenerator = Generator<
|
|
258
|
+
YieldWrap<Effect.Effect<unknown, unknown, CompositionService>>,
|
|
259
|
+
void,
|
|
260
|
+
unknown
|
|
261
|
+
>
|
|
262
|
+
|
|
263
|
+
/** An erased facade — the runtime shape before the contract type is re-attached. */
|
|
264
|
+
type AnyFacade = Facade<UiContract>
|
|
265
|
+
|
|
266
|
+
/** The erased body stored on a `Proof` value; `implement` types the contract in. */
|
|
267
|
+
export type ProofBody = (app: AnyFacade) => ProofGenerator
|
|
268
|
+
|
|
269
|
+
export interface Proof {
|
|
270
|
+
readonly requirement: RequirementClass<AnyComposition, string>
|
|
271
|
+
/** The scene this proof runs against: its closed wiring + boot events. */
|
|
272
|
+
readonly scene: Scene
|
|
273
|
+
readonly body: ProofBody
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ProofSuite {
|
|
277
|
+
/** Brand so adapters can duck-type a suite among a module's exports. */
|
|
278
|
+
readonly kind: 'ProofSuite'
|
|
279
|
+
readonly product: ProductClass
|
|
280
|
+
readonly proofs: ReadonlyArray<Proof>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface ProofResult {
|
|
284
|
+
readonly requirement: string
|
|
285
|
+
readonly ok: boolean
|
|
286
|
+
readonly error?: string
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface SuiteResult {
|
|
290
|
+
readonly product: string
|
|
291
|
+
readonly results: ReadonlyArray<ProofResult>
|
|
292
|
+
readonly ok: boolean
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Implement (prove) a requirement by driving the facade against a scene. A proof
|
|
297
|
+
* is a VALUE. The scene supplies the runtime (its closed `provide` + `boot`) and
|
|
298
|
+
* the composition that types the facade; its contract must equal the
|
|
299
|
+
* requirement's composition contract, so a scene for the wrong composition — or
|
|
300
|
+
* one whose contract has drifted — is a compile error. The `app` facade is fully
|
|
301
|
+
* typed from that contract.
|
|
302
|
+
*/
|
|
303
|
+
const implement = <Comp extends AnyComposition>(
|
|
304
|
+
requirement: RequirementClass<Comp, string>,
|
|
305
|
+
scene: Scene<ContractOf<Comp>>,
|
|
306
|
+
body: (app: Facade<ContractOf<Comp>>) => ProofGenerator,
|
|
307
|
+
): Proof => ({ requirement, scene, body: body as ProofBody })
|
|
308
|
+
|
|
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 })
|
|
314
|
+
|
|
315
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
316
|
+
typeof value === 'object' && value !== null
|
|
317
|
+
|
|
318
|
+
/** 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'])
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run every proof against a fresh runtime, returning per-requirement results.
|
|
324
|
+
* Each proof gets its own environment, so nothing leaks between proofs. Execution
|
|
325
|
+
* goes through the injected `ProofRunner` (the headless engine by default).
|
|
326
|
+
*/
|
|
327
|
+
const run = (proofSuite: ProofSuite): Promise<SuiteResult> =>
|
|
328
|
+
withProofRunner(async (runner: ProofRunnerApi) => {
|
|
329
|
+
const results: ProofResult[] = []
|
|
330
|
+
for (const proof of proofSuite.proofs) {
|
|
331
|
+
results.push(await runner.executeProof(proof))
|
|
332
|
+
}
|
|
333
|
+
return { product: proofSuite.product.manifest.name, results, ok: results.every((r) => r.ok) }
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Driver: step a proof for the editor's Test-play timeline
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
/** One step of a driven proof: the rendered tree captured right after the
|
|
341
|
+
* proof's i-th yielded effect settled. Index 0 is the booted, pre-drive tree. */
|
|
342
|
+
export interface StepFrame {
|
|
343
|
+
readonly index: number
|
|
344
|
+
readonly captures: ReadonlyArray<UiCapture>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface DriveResult {
|
|
348
|
+
readonly requirement: string
|
|
349
|
+
readonly frames: ReadonlyArray<StepFrame>
|
|
350
|
+
readonly ok: boolean
|
|
351
|
+
readonly error?: string
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface ProofDriver {
|
|
355
|
+
readonly requirement: string
|
|
356
|
+
/** Run the proof, snapshotting the captured tree after each yielded step. */
|
|
357
|
+
readonly run: () => Promise<DriveResult>
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** A driver per proof in the suite — the editor's Test-play entry point. Each
|
|
361
|
+
* `run` resolves the same `ProofRunner` layer and steps that proof. */
|
|
362
|
+
const driver = (proofSuite: ProofSuite): ReadonlyArray<ProofDriver> =>
|
|
363
|
+
proofSuite.proofs.map((proof) => ({
|
|
364
|
+
requirement: proof.requirement.manifest.statement,
|
|
365
|
+
run: () => withProofRunner((runner: ProofRunnerApi) => runner.driveProof(proof)),
|
|
366
|
+
}))
|
|
367
|
+
|
|
368
|
+
export const Proof: {
|
|
369
|
+
readonly implement: typeof implement
|
|
370
|
+
readonly suite: typeof suite
|
|
371
|
+
readonly run: typeof run
|
|
372
|
+
readonly driver: typeof driver
|
|
373
|
+
} = { implement, suite, run, driver }
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from 'effect'
|
|
2
|
+
import { driveProof, executeProof } from './engine'
|
|
3
|
+
import type { DriveResult, Proof, ProofResult } from './index'
|
|
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
|
+
export interface ProofRunnerApi {
|
|
13
|
+
/** Run a proof to completion, returning its pass/fail result. */
|
|
14
|
+
readonly executeProof: (proof: Proof) => Promise<ProofResult>
|
|
15
|
+
/** Step a proof a frame at a time (the editor's Test-play timeline). */
|
|
16
|
+
readonly driveProof: (proof: Proof) => Promise<DriveResult>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ProofRunnerBase: Context.TagClass<ProofRunner, 'reform-proof/ProofRunner', ProofRunnerApi> =
|
|
20
|
+
Context.Tag('reform-proof/ProofRunner')<ProofRunner, ProofRunnerApi>()
|
|
21
|
+
|
|
22
|
+
/** The service identity for the proof execution strategy. */
|
|
23
|
+
export class ProofRunner extends ProofRunnerBase {}
|
|
24
|
+
|
|
25
|
+
/** The default runner: the headless engine that drives the real reduce loop. */
|
|
26
|
+
export const proofRunnerLayer: Layer.Layer<ProofRunner> = Layer.succeed(ProofRunner, {
|
|
27
|
+
executeProof,
|
|
28
|
+
driveProof,
|
|
29
|
+
})
|
|
30
|
+
|
|
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
|
+
export const withProofRunner = <A>(use: (runner: ProofRunnerApi) => Promise<A>): Promise<A> =>
|
|
37
|
+
Effect.runPromise(
|
|
38
|
+
ProofRunner.pipe(
|
|
39
|
+
Effect.flatMap((runner) => Effect.promise(() => use(runner))),
|
|
40
|
+
Effect.provide(proofRunnerLayer),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Composition,
|
|
4
|
+
Engine,
|
|
5
|
+
Event,
|
|
6
|
+
mount,
|
|
7
|
+
provide,
|
|
8
|
+
Reducer,
|
|
9
|
+
scene,
|
|
10
|
+
State,
|
|
11
|
+
StateGroup,
|
|
12
|
+
type Trigger,
|
|
13
|
+
Ui,
|
|
14
|
+
ui,
|
|
15
|
+
} from '@playfast/reform'
|
|
16
|
+
import { describe, expect, test } from 'vitest'
|
|
17
|
+
import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
18
|
+
|
|
19
|
+
// Plan 03b — events ride ON the structure. A composition whose `live` body returns
|
|
20
|
+
// `mount({ props, events })` never calls its view, so the event triggers it acquires
|
|
21
|
+
// (`yield* Event.trigger(Bumped)`) are NOT captured by a view's CaptureSink — they
|
|
22
|
+
// travel with the frame on `structure.events`. The proof engine's `driveStructure`
|
|
23
|
+
// records them into the same capture the view path uses, so `app.actions.bump()`
|
|
24
|
+
// resolves and settles identically for a Structure frame. This file is the only
|
|
25
|
+
// runtime exercise of the structure EVENT path; the view path stays covered by
|
|
26
|
+
// typed-seams.test.ts.
|
|
27
|
+
|
|
28
|
+
class CountState extends State.make('scount', S.Number) {}
|
|
29
|
+
class MiniStates extends StateGroup.make(CountState) {}
|
|
30
|
+
class Bumped extends Event.make('SBumped', S.Struct({})) {}
|
|
31
|
+
class BumpReducer extends Reducer.make('SBumpReducer', { states: [CountState], events: [Bumped] }) {}
|
|
32
|
+
const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
|
|
33
|
+
|
|
34
|
+
class CounterUi extends ui('SCounter')<{
|
|
35
|
+
props: { count: number }
|
|
36
|
+
events: { bump: Trigger<Record<string, never>> }
|
|
37
|
+
}>() {}
|
|
38
|
+
class Counter extends Composition.make('SCounter', {
|
|
39
|
+
title: 'SCounter',
|
|
40
|
+
states: [MiniStates],
|
|
41
|
+
events: [Bumped],
|
|
42
|
+
ui: CounterUi,
|
|
43
|
+
}) {}
|
|
44
|
+
// Body returns a STRUCTURE carrying the bump trigger on `events` — the view is never
|
|
45
|
+
// called, so the trigger must ride on the frame for the proof to resolve it.
|
|
46
|
+
const CounterLive = Composition.live(Counter, function* () {
|
|
47
|
+
const count = yield* StateGroup.select(MiniStates, 'scount')
|
|
48
|
+
const bump = yield* Event.trigger(Bumped)
|
|
49
|
+
return mount({ props: { count }, slots: {}, events: { bump } })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
|
|
53
|
+
const Views = CounterLive.pipe(Layer.provideMerge(presentations))
|
|
54
|
+
const Logic = BumpReducerLive.pipe(
|
|
55
|
+
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { scount: 5 }))),
|
|
56
|
+
)
|
|
57
|
+
const MiniApp = Views.pipe(Layer.provideMerge(Logic))
|
|
58
|
+
|
|
59
|
+
const CounterScene = scene(Counter, { provide: [MiniApp] })
|
|
60
|
+
|
|
61
|
+
class Bumps extends ProductRequirement.make(Counter, 'bumps the count', { events: ['bump'] }) {}
|
|
62
|
+
class CounterProduct extends Product.make(Counter, { requirements: [Bumps] }) {}
|
|
63
|
+
|
|
64
|
+
describe('events ride on a Structure frame (plan 03b)', () => {
|
|
65
|
+
test('app.actions.x() dispatches and settles for a mount(...)-returning body', async () => {
|
|
66
|
+
const proof = Proof.implement(Bumps, CounterScene, function* (app) {
|
|
67
|
+
yield* proofExpect((yield* app.props).count).toBe(5)
|
|
68
|
+
const next = yield* app.actions.bump({})
|
|
69
|
+
yield* proofExpect(next.count).toBe(6)
|
|
70
|
+
})
|
|
71
|
+
const result = await Proof.run(Proof.suite(CounterProduct, { proofs: [proof] }))
|
|
72
|
+
expect(result.ok).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Layer, Schema as S } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
Composition,
|
|
4
|
+
Engine,
|
|
5
|
+
each,
|
|
6
|
+
mount,
|
|
7
|
+
Props,
|
|
8
|
+
provide,
|
|
9
|
+
scene,
|
|
10
|
+
slot,
|
|
11
|
+
State,
|
|
12
|
+
StateGroup,
|
|
13
|
+
Ui,
|
|
14
|
+
ui,
|
|
15
|
+
} from '@playfast/reform'
|
|
16
|
+
import { describe, expect, test } from 'vitest'
|
|
17
|
+
import { type Facade, Product, Proof, ProductRequirement, expect as proofExpect } from './index'
|
|
18
|
+
|
|
19
|
+
// Plan 06 — typed recursive proof navigation. A list parent returns a STRUCTURE
|
|
20
|
+
// whose `Item` slot is filled with `each`, so each child rides a stable `key` (the
|
|
21
|
+
// SINGLE source of the wire key + family key). The proof facade selects a child by
|
|
22
|
+
// that key (`byKey`), filters by computed props (`where`), and reads the fill length
|
|
23
|
+
// (`count`) — all against the structure tree, no view evaluated. `byKey` returns a
|
|
24
|
+
// facade still typed by the child contract, so `.expectProps` checks the CHILD's
|
|
25
|
+
// props and `.slots` would keep descending type-safely.
|
|
26
|
+
|
|
27
|
+
// A list of todos lives in state; the parent computes its props + the `each` fill.
|
|
28
|
+
class TodosState extends State.make(
|
|
29
|
+
'locTodos',
|
|
30
|
+
S.Array(S.Struct({ id: S.String, done: S.Boolean })),
|
|
31
|
+
) {}
|
|
32
|
+
class TodoStates extends StateGroup.make(TodosState) {}
|
|
33
|
+
|
|
34
|
+
class ItemUi extends ui('LocItem')<{ props: { id: string; done: boolean } }>() {}
|
|
35
|
+
class Item extends Composition.make('LocItem', {
|
|
36
|
+
title: 'LocItem',
|
|
37
|
+
props: S.Struct({ id: S.String, done: S.Boolean }),
|
|
38
|
+
ui: ItemUi,
|
|
39
|
+
}) {}
|
|
40
|
+
class ItemSlot extends slot('LocItem')<typeof Item>() {}
|
|
41
|
+
|
|
42
|
+
class ListUi extends ui('LocList')<{
|
|
43
|
+
props: { total: number }
|
|
44
|
+
slots: { Item: ItemSlot }
|
|
45
|
+
}>() {}
|
|
46
|
+
class List extends Composition.make('LocList', {
|
|
47
|
+
title: 'LocList',
|
|
48
|
+
states: [TodoStates],
|
|
49
|
+
slots: { Item: ItemSlot },
|
|
50
|
+
ui: ListUi,
|
|
51
|
+
}) {}
|
|
52
|
+
|
|
53
|
+
// The parent body returns pure data: its own props, and the `Item` slot filled with
|
|
54
|
+
// one keyed child per todo. No `.map`, no JSX — `each` owns multiplicity + key.
|
|
55
|
+
const ListLive = Composition.live(List, function* () {
|
|
56
|
+
const todos = yield* StateGroup.select(TodoStates, 'locTodos')
|
|
57
|
+
return mount({
|
|
58
|
+
props: { total: todos.length },
|
|
59
|
+
slots: {
|
|
60
|
+
Item: each(todos, { key: (t) => t.id, props: (t) => ({ id: t.id, done: t.done }) }),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
// A leaf child in structure-as-data echoes the props its parent's `each` fed it
|
|
65
|
+
// (read via the `Props` service), so the recorded capture carries the per-item
|
|
66
|
+
// props `byKey`/`where`/`expectProps` assert against. No view, hook-free.
|
|
67
|
+
const ItemLive = Composition.live(Item, function* () {
|
|
68
|
+
const props: { id: string; done: boolean } = yield* Props
|
|
69
|
+
return mount({ props, slots: {} })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const presentations = Layer.mergeAll(
|
|
73
|
+
provide(ListUi, Ui.make(ListUi, () => null)),
|
|
74
|
+
provide(ItemUi, Ui.make(ItemUi, () => null)),
|
|
75
|
+
)
|
|
76
|
+
const wiring = provide(ItemSlot, Item)
|
|
77
|
+
const Views = Layer.mergeAll(ListLive, ItemLive, wiring).pipe(Layer.provideMerge(presentations))
|
|
78
|
+
const Logic = Layer.mergeAll(
|
|
79
|
+
Engine,
|
|
80
|
+
StateGroup.live(TodoStates, {
|
|
81
|
+
locTodos: [
|
|
82
|
+
{ id: 'todo-1', done: false },
|
|
83
|
+
{ id: 'todo-2', done: true },
|
|
84
|
+
{ id: 'todo-3', done: false },
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
const ListApp = Views.pipe(Layer.provideMerge(Logic))
|
|
89
|
+
const ListScene = scene(List, { provide: [ListApp] })
|
|
90
|
+
|
|
91
|
+
class Locates extends ProductRequirement.make(List, 'locates list children by key/props') {}
|
|
92
|
+
class ListProduct extends Product.make(List, { requirements: [Locates] }) {}
|
|
93
|
+
|
|
94
|
+
describe('typed recursive proof navigation (plan 06)', () => {
|
|
95
|
+
test('byKey / where / count resolve children from the structure tree', async () => {
|
|
96
|
+
const proof = Proof.implement(Locates, ListScene, function* (app) {
|
|
97
|
+
// The parent's computed props.
|
|
98
|
+
yield* proofExpect((yield* app.props).total).toBe(3)
|
|
99
|
+
// count = the fill length.
|
|
100
|
+
yield* proofExpect(yield* app.slots.Item.count).toBe(3)
|
|
101
|
+
// byKey selects the one child mounted under that `each` key; the facade is
|
|
102
|
+
// typed by the CHILD contract, so `expectProps` checks the child's props.
|
|
103
|
+
const second = yield* app.slots.Item.byKey('todo-2')
|
|
104
|
+
yield* second.expectProps({ id: 'todo-2', done: true })
|
|
105
|
+
const first = yield* app.slots.Item.byKey('todo-1')
|
|
106
|
+
yield* first.expectProps({ id: 'todo-1', done: false })
|
|
107
|
+
// where filters children by their computed props (predicate typed by the
|
|
108
|
+
// CHILD contract — `p.done` is known).
|
|
109
|
+
const doneItems = yield* app.slots.Item.where((p) => p.done)
|
|
110
|
+
yield* proofExpect(doneItems.length).toBe(1)
|
|
111
|
+
yield* doneItems[0]!.expectProps({ id: 'todo-2' })
|
|
112
|
+
})
|
|
113
|
+
const result = await Proof.run(Proof.suite(ListProduct, { proofs: [proof] }))
|
|
114
|
+
expect(result.ok).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('byKey on a missing key fails the proof', async () => {
|
|
118
|
+
const proof = Proof.implement(Locates, ListScene, function* (app) {
|
|
119
|
+
yield* app.slots.Item.byKey('nope')
|
|
120
|
+
})
|
|
121
|
+
const result = await Proof.run(Proof.suite(ListProduct, { proofs: [proof] }))
|
|
122
|
+
expect(result.ok).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Type-only: the `byKey`-selected facade is typed by the Item CHILD contract
|
|
127
|
+
// (`{ id, done }`) — recursive typed selection. A wrong prop key in `expectProps`
|
|
128
|
+
// is a compile error. Non-exported, never called; exists only to be typechecked.
|
|
129
|
+
const _negativeTypeCheck = (app: Facade<Ui.Contract<typeof ListUi>>): void => {
|
|
130
|
+
Proof.implement(Locates, ListScene, function* () {
|
|
131
|
+
const child = yield* app.slots.Item.byKey('todo-1')
|
|
132
|
+
yield* child.expectProps({ id: 'x', done: true })
|
|
133
|
+
// @ts-expect-error 'nope' is not a key of the Item child contract props
|
|
134
|
+
yield* child.expectProps({ nope: 1 })
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
void _negativeTypeCheck
|