@reventlessdev/reventless-spec 3.0.0-alpha.130 → 3.0.0-alpha.131

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.
@@ -0,0 +1,1337 @@
1
+ /**
2
+ The lifecycle model each example's own scenarios describe, and what it says about
3
+ the edges each command declares beside them.
4
+
5
+ `Moves([Placed], Shipped)` is authored, and until now nothing compared it to
6
+ behaviour. The compiler resolves the state constructors and the platform checks
7
+ they belong to the linked view — both much weaker properties than agreement: a
8
+ command can declare `Moves([Deactivated], Active)` while its `decide` accepts an
9
+ active row, or emit an event the view folds into something else, and every static
10
+ check still passes.
11
+
12
+ The scenarios already answer this. Each `@@reventless.gwt` file is a corpus of
13
+ `given / when / then`, and the PPX writes it out as a `<Stem>.gwt.json` sidecar
14
+ under `REVENTLESS_EMIT_SIDECAR=1`. This script drives that build, folds the
15
+ scenarios into a `(fromState, command, outcome, toState)` relation, and reports
16
+ each declared edge as **confirmed**, **contradicted** or **unverified**.
17
+
18
+ Two properties are worth stating because they are what makes the result
19
+ trustworthy:
20
+
21
+ - **It reads a compile-time artifact, not a test run.** The sidecar is written
22
+ while the file is parsed, so a scenario counts whether or not it passes, and
23
+ whether or not anyone ran it. Metadata that depended on a test *run* would mean
24
+ a deleted test file silently changing a command menu.
25
+
26
+ - **It keys on effect, not on acceptance.** A command is in a state's from-set
27
+ when a scenario shows it *emitting* there. The repository's `Ok([])`-on-no-change
28
+ convention means `decide` accepts commands a menu should not offer, so keying
29
+ on acceptance would derive a from-set that disagrees with every declaration.
30
+
31
+ Contradictions fail the run; unverified edges are warnings, counted so a corpus
32
+ getting thinner is visible.
33
+
34
+ Two artifacts come out of the same derivation. `schema/lifecycle-model.json` is
35
+ the reviewable golden, one per app. `src/LifecycleModel.res` is the value
36
+ structure assembly reads, one per plugin — written here rather than by
37
+ `generate-plugin`, which runs in `prebuild` and would therefore always be one
38
+ build behind the sidecars it would have to read.
39
+
40
+ An **app root** is a directory whose immediate subdirectories are plugins, a
41
+ plugin being a directory with both a composition root (`src/Plugin.res`) and a
42
+ corpus (`tests/`). `examples/online-shop-hybrid` is one; so is the root of an app
43
+ `create-app` generated. With no `--root`, every subdirectory of `<cwd>/examples`
44
+ is taken as one, which is what this repository's own gate wants and was for a
45
+ while the only thing this could do.
46
+
47
+ Usage, in this repository:
48
+
49
+ ```
50
+ pnpm run check:lifecycle # fail on contradictions or artifact drift
51
+ pnpm run check:lifecycle:update # rewrite the goldens and the models
52
+ pnpm run check:lifecycle -- --reuse-sidecars # read what a prior build wrote
53
+ pnpm run check:lifecycle -- --reuse-sidecars --json # the same run, machine-readable
54
+ ```
55
+
56
+ and against an app, through the `check-lifecycle` binary this package ships:
57
+
58
+ ```
59
+ check-lifecycle --root . --reuse-sidecars --json
60
+ check-lifecycle --root . --update # write this app's models and golden
61
+ ```
62
+ */
63
+
64
+ // The model this script writes is folded into `pluginStructure`, so by the time
65
+ // it runs again the "declared" side it reads back would be its own last answer —
66
+ // every edge confirmed, and a real disagreement between an annotation and the
67
+ // corpus invisible. This asks the structure for the declarations alone. Set
68
+ // before anything imports a plugin, because `Plugin_Structure` reads it once.
69
+ NodeProcess.env->Dict.set("REVENTLESS_DECLARED_TRANSITIONS_ONLY", "1")
70
+
71
+ // ── Where things live ───────────────────────────────────────────────────────
72
+
73
+ let repoRoot = NodeProcess.cwd()
74
+ let examplesDir = NodePath.join([repoRoot, "examples"])
75
+ let update = NodeProcess.argv->Array.includes("--update")
76
+
77
+ /** Every `<flag> <value>` pair on the command line, so a flag can be repeated.
78
+
79
+ A value that looks like another flag is not consumed, so `--root --json`
80
+ reports no roots rather than silently checking a directory named `--json`. */
81
+ let flagValues = (flag: string): array<string> => {
82
+ let argv = NodeProcess.argv
83
+ let out = []
84
+ for i in 0 to Array.length(argv) - 1 {
85
+ if argv->Array.get(i) == Some(flag) {
86
+ switch argv->Array.get(i + 1) {
87
+ | Some(value) if !(value->String.startsWith("--")) => out->Array.push(value)->ignore
88
+ | _ => ()
89
+ }
90
+ }
91
+ }
92
+ out
93
+ }
94
+
95
+ /** An app whose plugins are checked together, and the name it is reported under.
96
+
97
+ The golden is per app because a command's edge is only meaningful beside the
98
+ other plugins it shares an event log with. */
99
+ type appRoot = {label: string, dir: string}
100
+
101
+ /** Read the sidecars a prior build already wrote instead of driving one. CI's
102
+ build step sets `REVENTLESS_EMIT_SIDECAR=1`, so by the time this runs the
103
+ corpus is on disk; a second pass over a warm tree buys nothing and costs the
104
+ multi-root build chain's habit of cleaning artifacts outside the root it is
105
+ building, which lands intermittently on a stale `.cmi`. */
106
+ let reuseSidecars = NodeProcess.argv->Array.includes("--reuse-sidecars")
107
+
108
+ /** Report the run as one JSON document on stdout instead of the grouped prose.
109
+
110
+ For a consumer that has to place a finding somewhere — an editor putting a
111
+ squiggle on the arm that made the claim — rather than read it. The prose
112
+ bakes component, command and state into a sentence; this keeps them as
113
+ fields, so the consumer does not parse English back into a range.
114
+
115
+ Artifacts are neither written nor compared under `--json`: a reader asking
116
+ what the corpus says now must not, as a side effect, rewrite what the
117
+ repository says it said — nor fail because the two have drifted, which is a
118
+ fact about the repository rather than about the corpus it was asked to
119
+ report. A contradiction still exits non-zero, so this composes with a gate. */
120
+ let json = NodeProcess.argv->Array.includes("--json")
121
+
122
+ /** The label a state carries when no row exists yet. Not a lifecycle case — no
123
+ enum declares it — so it is spelled in a way no constructor can name, and a
124
+ command whose successful scenarios all start here is creating rather than
125
+ guarding. */
126
+ let noRow = "(none)"
127
+
128
+ // ── Small JSON readers ──────────────────────────────────────────────────────
129
+
130
+ // Read field by field rather than parsed through the published schema. A
131
+ // `pluginStructure` has two legitimate representations — an absent optional is
132
+ // `undefined` in memory and an explicit `null` on the wire — and the schema
133
+ // describes the wire form, so a whole-structure parse fails on every optional
134
+ // that happens to be empty.
135
+
136
+ let asObj = (j: JSON.t): option<dict<JSON.t>> => j->JSON.Decode.object
137
+ let getStr = (d: dict<JSON.t>, k: string): option<string> =>
138
+ d->Dict.get(k)->Option.flatMap(JSON.Decode.string)
139
+ let getArr = (d: dict<JSON.t>, k: string): array<JSON.t> =>
140
+ d->Dict.get(k)->Option.flatMap(JSON.Decode.array)->Option.getOr([])
141
+ let getObjs = (d: dict<JSON.t>, k: string): array<dict<JSON.t>> =>
142
+ getArr(d, k)->Array.filterMap(asObj)
143
+ let getStrs = (d: dict<JSON.t>, k: string): array<string> =>
144
+ getArr(d, k)->Array.filterMap(JSON.Decode.string)
145
+
146
+ /** `Some` only for an explicit array. `allowedStates` is three-valued —
147
+ unannotated, annotated with a set, annotated with the empty set — and
148
+ flattening the first two into `[]` erases the distinction the whole report
149
+ rests on. */
150
+ let getStrsOpt = (d: dict<JSON.t>, k: string): option<array<string>> =>
151
+ switch d->Dict.get(k) {
152
+ | None => None
153
+ | Some(v) => v->JSON.Decode.array->Option.map(a => a->Array.filterMap(JSON.Decode.string))
154
+ }
155
+
156
+ /** A constructor is written qualified as often as not — `Customer.Registered`,
157
+ `Customers_Projections.OrderEvents.OrderPlaced` — and the qualification is
158
+ about where the test could see the type from, not about which event it is. */
159
+ let last = (xs: array<'a>): option<'a> => xs->Array.get(Array.length(xs) - 1)
160
+
161
+ let lastSegment = (name: string): string => {
162
+ let parts = name->String.split(".")
163
+ parts->last->Option.getOr(name)
164
+ }
165
+
166
+ let sortedUnique = (xs: array<string>): array<string> => {
167
+ let out = []
168
+ xs->Array.forEach(x =>
169
+ if !(out->Array.includes(x)) {
170
+ out->Array.push(x)
171
+ }
172
+ )
173
+ out->Array.toSorted(String.compare)
174
+ }
175
+
176
+ // ── The corpus ──────────────────────────────────────────────────────────────
177
+
178
+ /** A named thing a step mentions, with the literal values the scenario wrote for
179
+ it. The values are what let a history be read as belonging to one row: a
180
+ slice's setup routinely names other entities, and folding those into this
181
+ row's state is how a scenario about a second order gets read as a scenario
182
+ about the first one's. */
183
+ type element = {name: string, values: array<(string, JSON.t)>}
184
+
185
+ /** One scenario, reduced to what the harvest reads. `then` carries at most one
186
+ step in every DSL here, so it is a kind and a payload rather than a list. */
187
+ type scenario = {
188
+ title: string,
189
+ given: array<element>,
190
+ whenKind: string,
191
+ whenElements: array<element>,
192
+ thenKind: string,
193
+ thenElements: array<element>,
194
+ thenValues: array<(string, JSON.t)>,
195
+ }
196
+
197
+ type corpus = {
198
+ /** The component the file is about: the filename stem with `_GWT` removed,
199
+ which is the spec name the plugin structure knows it by. */
200
+ component: string,
201
+ path: string,
202
+ scenarios: array<scenario>,
203
+ }
204
+
205
+ /** `[[name, exampleValue], …]` — the shape the sidecar writes a record literal
206
+ in. Anything that is not a two-element pair is skipped rather than guessed
207
+ at. */
208
+ let valuesOf = (step: dict<JSON.t>): array<(string, JSON.t)> =>
209
+ getArr(step, "values")->Array.filterMap(v =>
210
+ switch v->JSON.Decode.array {
211
+ | Some([name, value]) => name->JSON.Decode.string->Option.map(n => (n, value))
212
+ | _ => None
213
+ }
214
+ )
215
+
216
+ let elementsOf = (steps: array<dict<JSON.t>>): array<element> =>
217
+ steps->Array.filterMap(s =>
218
+ s->getStr("element")->Option.map(name => {name: lastSegment(name), values: valuesOf(s)})
219
+ )
220
+
221
+ let kindOf = (steps: array<dict<JSON.t>>): string =>
222
+ steps->Array.get(0)->Option.flatMap(s => s->getStr("kind"))->Option.getOr("")
223
+
224
+ let scenarioOf = (j: JSON.t): option<scenario> =>
225
+ j
226
+ ->asObj
227
+ ->Option.map(d => {
228
+ let given = getObjs(d, "given")
229
+ let when_ = getObjs(d, "when")
230
+ let then_ = getObjs(d, "then")
231
+ {
232
+ title: d->getStr("title")->Option.getOr(""),
233
+ given: elementsOf(given),
234
+ whenKind: kindOf(when_),
235
+ whenElements: elementsOf(when_),
236
+ thenKind: kindOf(then_),
237
+ thenElements: elementsOf(then_),
238
+ thenValues: then_->Array.get(0)->Option.map(valuesOf)->Option.getOr([]),
239
+ }
240
+ })
241
+
242
+ let readCorpus = (path: string): option<corpus> =>
243
+ switch path->NodeFs.readFileSync->JSON.parseOrThrow->asObj {
244
+ | None => None
245
+ | Some(d) =>
246
+ let stem = d->getStr("stem")->Option.getOr("")
247
+ Some({
248
+ component: stem->String.replace("_GWT", ""),
249
+ path,
250
+ scenarios: getArr(d, "scenarios")->Array.filterMap(scenarioOf),
251
+ })
252
+ | exception _ => None
253
+ }
254
+
255
+ let rec filesUnder = (dir: string, ~suffix: string): array<string> =>
256
+ switch NodeFs.readdirSync(dir, {withFileTypes: true}) {
257
+ | entries =>
258
+ entries->Array.flatMap(entry => {
259
+ let name = entry->NodeFs.direntName
260
+ let full = NodePath.join([dir, name])
261
+ if entry->NodeFs.isDirectory {
262
+ // `lib` holds a second copy of every compiled module, and `node_modules`
263
+ // a copy of every dependency's tests. Both would be harvested twice, and
264
+ // neither copy is the one the plugin's own imports resolve to.
265
+ name == "node_modules" || name == "lib" || name->String.startsWith(".")
266
+ ? []
267
+ : filesUnder(full, ~suffix)
268
+ } else if name->String.endsWith(suffix) {
269
+ [full]
270
+ } else {
271
+ []
272
+ }
273
+ })
274
+ | exception _ => []
275
+ }
276
+
277
+ // ── Driving the build that writes the sidecars ──────────────────────────────
278
+
279
+ let gwtSources = (~pluginDirs: array<string>): array<string> =>
280
+ pluginDirs->Array.flatMap(dir => filesUnder(NodePath.join([dir, "tests"]), ~suffix="_GWT.res"))
281
+
282
+ let sidecarOf = (gwt: string): string => gwt->String.replace("_GWT.res", "_GWT.gwt.json")
283
+
284
+ /** A build that never set `REVENTLESS_EMIT_SIDECAR` leaves no corpus at all, and
285
+ an empty corpus reads as every edge `unverified` — a warning, so the run
286
+ would pass having checked nothing. Say so instead.
287
+
288
+ Only the empty case is checked, not sidecar-per-file: a GWT file whose
289
+ `describe` argument is not a string literal legitimately emits none, and the
290
+ committed goldens are what catch a corpus that merely got thinner. */
291
+ let hasCorpus = (~pluginDirs: array<string>): bool =>
292
+ gwtSources(~pluginDirs)->Array.some(f => f->sidecarOf->NodeFs.existsSync)
293
+
294
+ let checkSidecars = (~pluginDirs: array<string>): result<unit, string> =>
295
+ hasCorpus(~pluginDirs)
296
+ ? Ok()
297
+ : Error(
298
+ "--reuse-sidecars was passed, but no scenario sidecar exists. Build with " ++
299
+ "REVENTLESS_EMIT_SIDECAR=1 first, or drop the flag.",
300
+ )
301
+
302
+ /** Sidecars are gitignored build artifacts, so the harvest produces its own
303
+ rather than trusting whatever a previous build happened to leave behind.
304
+
305
+ The touch is not superstition. `rescript` caches on source mtime, and the
306
+ sidecar is a side effect of *parsing* — so a tree that is already built emits
307
+ nothing at all no matter what the environment says. Making the sources look
308
+ newer is the only lever a caller has over a compiler's cache.
309
+
310
+ **One root build, not one per plugin.** Each build root's clean step orphans
311
+ the in-source test outputs of packages in its dependency graph but outside
312
+ itself, and the root `build` script is a chain ordered to re-emit them. Six
313
+ per-plugin builds would leave several packages' test outputs deleted, which
314
+ is not a build failure — it is a jest project that discovers nothing and
315
+ passes. So the harvest drives the same ordered chain everyone else does, and
316
+ leaves the tree exactly as it found it.
317
+
318
+ **The chain it drives is the working directory's**, not each root's. That is
319
+ right for the ordinary invocations — this repository's gate, and `--root .`
320
+ from an app — and wrong for a `--root` pointing somewhere else, which is why
321
+ the caller checks for a corpus afterwards either way rather than trusting
322
+ that a build it did not target wrote one. */
323
+ let emitSidecars = (~pluginDirs: array<string>): result<unit, string> => {
324
+ let now = Date.now() /. 1000.0
325
+ gwtSources(~pluginDirs)->Array.forEach(f => NodeFs.utimesSync(f, now, now))
326
+
327
+ let env =
328
+ NodeProcess.env
329
+ ->Dict.toArray
330
+ ->Array.concat([("REVENTLESS_EMIT_SIDECAR", "1")])
331
+ ->Dict.fromArray
332
+
333
+ try {
334
+ let _ = NodeChildProcess.execFileSync(
335
+ "pnpm",
336
+ ["run", "build"],
337
+ {cwd: repoRoot, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024},
338
+ )
339
+ Ok()
340
+ } catch {
341
+ | exn =>
342
+ Error(
343
+ exn
344
+ ->JsExn.fromException
345
+ ->Option.flatMap(JsExn.message)
346
+ ->Option.getOr("the build that emits the scenario sidecars failed"),
347
+ )
348
+ }
349
+ }
350
+
351
+ // ── The declared side ───────────────────────────────────────────────────────
352
+
353
+ type declaredCommand = {
354
+ command: string,
355
+ level: string,
356
+ /** The field carrying the id of the row the command addresses, where the
357
+ component has one. Used to tell this row's setup from the rest of it. */
358
+ aggregateIdField: option<string>,
359
+ allowedStates: option<array<string>>,
360
+ targetState: option<string>,
361
+ /** `"unrestricted"` is the one value this reads for: it is how a declared
362
+ "legal in every state" reaches here, since the from-set it publishes is
363
+ the same `None` as saying nothing at all. */
364
+ allowedStatesSource: option<string>,
365
+ }
366
+
367
+ type declaredWritable = {
368
+ name: string,
369
+ linkedViews: array<string>,
370
+ commands: array<declaredCommand>,
371
+ }
372
+
373
+ type declaredView = {name: string, lifecycleField: option<string>}
374
+
375
+ type declared = {writables: array<declaredWritable>, views: array<declaredView>}
376
+
377
+ /** `commandLevel` is a sury-encoded variant, so it arrives as `"Collection"` /
378
+ `"Instance"` or as a tagged object depending on how it was built. Both are
379
+ read; anything else is left blank rather than guessed, since a wrong level is
380
+ worse than an absent one. */
381
+ let levelOf = (d: dict<JSON.t>): string =>
382
+ switch d->Dict.get("level") {
383
+ | Some(String(s)) => s
384
+ | Some(Object(o)) => o->getStr("TAG")->Option.getOr("")
385
+ | _ => ""
386
+ }
387
+
388
+ let declaredCommandOf = (d: dict<JSON.t>): option<declaredCommand> =>
389
+ d
390
+ ->getStr("name")
391
+ ->Option.map(command => {
392
+ command,
393
+ level: levelOf(d),
394
+ aggregateIdField: d->getStr("aggregateIdField"),
395
+ allowedStates: getStrsOpt(d, "allowedStates"),
396
+ targetState: d->getStr("targetState"),
397
+ allowedStatesSource: d->getStr("allowedStatesSource"),
398
+ })
399
+
400
+ let declaredOf = (structure: JSON.t): option<declared> =>
401
+ structure
402
+ ->asObj
403
+ ->Option.map(s => {
404
+ let writablesFrom = key =>
405
+ getObjs(s, key)->Array.filterMap(w =>
406
+ w
407
+ ->getStr("name")
408
+ ->Option.map(name => {
409
+ name,
410
+ linkedViews: getStrs(w, "linkedViews"),
411
+ commands: getObjs(w, "commands")->Array.filterMap(declaredCommandOf),
412
+ })
413
+ )
414
+ let viewsFrom = key =>
415
+ getObjs(s, key)->Array.filterMap(v =>
416
+ v->getStr("name")->Option.map(name => {name, lifecycleField: v->getStr("lifecycleField")})
417
+ )
418
+ {
419
+ writables: Array.concat(writablesFrom("aggregates"), writablesFrom("stateChangeSlices")),
420
+ views: Array.concat(viewsFrom("readModels"), viewsFrom("stateViewSlices")),
421
+ }
422
+ })
423
+
424
+ @module("./loadPluginStructure.mjs")
425
+ external loadPluginStructure: string => promise<JSON.t> = "loadPluginStructure"
426
+
427
+ let readDeclared = async (~pluginDir: string): result<declared, string> => {
428
+ let raw = await loadPluginStructure(pluginDir)
429
+ switch raw->asObj {
430
+ | None => Error("the structure loader returned something unreadable")
431
+ | Some(d) =>
432
+ switch d->Dict.get("ok") {
433
+ | Some(Boolean(true)) =>
434
+ switch d->Dict.get("structure")->Option.flatMap(declaredOf) {
435
+ | Some(declared) => Ok(declared)
436
+ | None => Error("the plugin structure could not be read")
437
+ }
438
+ | _ => Error(d->getStr("error")->Option.getOr("the plugin structure could not be loaded"))
439
+ }
440
+ }
441
+ }
442
+
443
+ // ── Rule 1: labelling a history with a lifecycle state ──────────────────────
444
+
445
+ /** The lifecycle value a `thenState` step asserts, for the field the view
446
+ declares as its lifecycle. A lifecycle case is a payload-less constructor, so
447
+ it arrives as the sidecar's `enum` kind; a view whose lifecycle is spelled as
448
+ a string is read too, since nothing forbids one. */
449
+ let lifecycleValue = (values: array<(string, JSON.t)>, ~field: string): option<string> =>
450
+ values
451
+ ->Array.find(((name, _)) => name == field)
452
+ ->Option.flatMap(((_, value)) => value->asObj)
453
+ ->Option.flatMap(v =>
454
+ switch v->getStr("kind") {
455
+ | Some("enum") | Some("string") => v->getStr("value")->Option.map(lastSegment)
456
+ | _ => None
457
+ }
458
+ )
459
+
460
+ /** Which lifecycle value each event *sets*, per view.
461
+
462
+ An event that leaves the value alone must not be recorded as setting it, or
463
+ every history ends at whatever its last event happened to be tested from. So
464
+ an event earns a mapping only where a scenario shows it *changing* the value:
465
+ creating the row from nothing, or landing somewhere its own setup was not.
466
+
467
+ That takes more than one pass — a scenario's setup can only be folded once
468
+ the events in it have mappings — so this runs to a fixpoint. The bound is a
469
+ guard against a corpus that oscillates, not an expected exit. */
470
+ let lifecycleMapFor = (
471
+ ~scenarios: array<scenario>,
472
+ ~field: string,
473
+ ~ambiguities: array<(string, string)>,
474
+ ~view: string,
475
+ ): dict<string> => {
476
+ let map = Dict.make()
477
+
478
+ let fold = (events: array<element>) =>
479
+ events->Array.reduce(noRow, (current, event) =>
480
+ switch map->Dict.get(event.name) {
481
+ | Some(value) => value
482
+ | None => current
483
+ }
484
+ )
485
+
486
+ let record = (event, value, ~title) =>
487
+ switch map->Dict.get(event) {
488
+ | Some(existing) if existing != value =>
489
+ // Two scenarios disagree about where this event lands. Reported rather
490
+ // than resolved: picking one would invent a precision the corpus does not
491
+ // have, and the honest answer is that the view needs another scenario.
492
+ ambiguities
493
+ ->Array.push((
494
+ view,
495
+ `${view}: ${event} is projected as both "${existing}" and "${value}" ` ++
496
+ `(seen in "${title}") — the harvest keeps "${existing}"`,
497
+ ))
498
+ ->ignore
499
+ | Some(_) => ()
500
+ | None => map->Dict.set(event, value)
501
+ }
502
+
503
+ let changed = ref(true)
504
+ let rounds = ref(0)
505
+ while changed.contents && rounds.contents < 8 {
506
+ changed := false
507
+ rounds := rounds.contents + 1
508
+ let before = map->Dict.keysToArray->Array.length
509
+
510
+ scenarios->Array.forEach(s =>
511
+ if s.whenKind == "event" && s.thenKind == "state" {
512
+ switch (s.whenElements->last, lifecycleValue(s.thenValues, ~field)) {
513
+ | (Some(event), Some(value)) =>
514
+ if Array.length(s.given) == 0 || fold(s.given) != value {
515
+ record(event.name, value, ~title=s.title)
516
+ }
517
+ | _ => ()
518
+ }
519
+ }
520
+ )
521
+
522
+ if map->Dict.keysToArray->Array.length != before {
523
+ changed := true
524
+ }
525
+ }
526
+
527
+ map
528
+ }
529
+
530
+ // ── Rule 2 and 3: the relation a command's own scenarios describe ───────────
531
+
532
+ type outcome = Emitted | NoChange | Refused
533
+
534
+ type observation = {
535
+ title: string,
536
+ command: string,
537
+ from: string,
538
+ outcome: outcome,
539
+ to: string,
540
+ }
541
+
542
+ type derivedCommand = {
543
+ component: string,
544
+ command: string,
545
+ /** States a scenario shows the command taking effect from — the from-set. */
546
+ allowedStates: array<string>,
547
+ /** States a scenario exercises where the command has no effect: refused, or
548
+ accepted and silent. Kept separately because it is what turns a declared
549
+ state the corpus disagrees with into a contradiction rather than a gap. */
550
+ inertStates: array<string>,
551
+ /** Where the edges land. A relation, not a single value: a command observed
552
+ landing in two states is the signal that the published `targetState` cannot
553
+ carry the model, and that is worth seeing before spending the change. */
554
+ targets: array<string>,
555
+ /** `""` where the corpus cannot say. Without a lifecycle map every history
556
+ folds to "no row", which would make every command in the plugin look like
557
+ it creates one — a confident wrong answer where the honest one is silence. */
558
+ level: string,
559
+ scenarios: int,
560
+ }
561
+
562
+ let outcomeOf = (s: scenario): outcome =>
563
+ switch s.thenKind {
564
+ | "event" => Emitted
565
+ // `thenNoEvent`, and the empty `then` an older sidecar wrote for the same
566
+ // thing. Accepted, and nothing happened.
567
+ | "noEvent" | "" => NoChange
568
+ | "error" => Refused
569
+ // A `then` this harvest has no reading for — a side effect, a published
570
+ // command. Not an effect on this row either way.
571
+ | _ => NoChange
572
+ }
573
+
574
+ let valueOf = (values: array<(string, JSON.t)>, ~field: string): option<JSON.t> =>
575
+ values->Array.find(((name, _)) => name == field)->Option.map(((_, v)) => v)
576
+
577
+ /** Whether a setup event is about the row the command names.
578
+
579
+ A slice's `given` names whatever the decision needs, which for a DCB slice is
580
+ routinely a different entity — a synced product, another customer's order. It
581
+ also, quite legitimately, names *this* entity's other rows: "placing a second
582
+ order" sets up `OrderPlaced(o1)` and then places `o2`. Folding either into
583
+ this row's state is how a scenario about a fresh row gets read as a scenario
584
+ about an existing one, and it is the difference between a command reported as
585
+ creating and the same command reported as guarding.
586
+
587
+ An event that does not carry the id field at all is kept. That is the normal
588
+ shape for an aggregate, whose events identify their instance by the stream
589
+ they are in rather than by a field, and dropping them would empty every
590
+ aggregate's history. */
591
+ let sameRow = (event: element, ~idField: option<string>, ~idValue: option<JSON.t>): bool =>
592
+ switch (idField, idValue) {
593
+ | (Some(field), Some(wanted)) =>
594
+ switch event.values->valueOf(~field) {
595
+ | Some(actual) => actual == wanted
596
+ | None => true
597
+ }
598
+ | _ => true
599
+ }
600
+
601
+ let observe = (
602
+ ~scenarios: array<scenario>,
603
+ ~map: dict<string>,
604
+ ~idFieldFor: string => option<string>,
605
+ ): array<observation> => {
606
+ let fold = (events: array<element>) =>
607
+ events->Array.reduce(noRow, (current, event) =>
608
+ switch map->Dict.get(event.name) {
609
+ | Some(value) => value
610
+ | None => current
611
+ }
612
+ )
613
+
614
+ scenarios->Array.filterMap(s =>
615
+ switch s.whenElements->Array.get(0) {
616
+ | Some(command) if s.whenKind == "command" =>
617
+ let idField = idFieldFor(command.name)
618
+ let idValue = idField->Option.flatMap(field => command.values->valueOf(~field))
619
+ let history = s.given->Array.filter(e => e->sameRow(~idField, ~idValue))
620
+ let from = fold(history)
621
+ let outcome = outcomeOf(s)
622
+ Some({
623
+ title: s.title,
624
+ command: command.name,
625
+ from,
626
+ outcome,
627
+ to: outcome == Emitted ? fold(Array.concat(history, s.thenElements)) : from,
628
+ })
629
+ | _ => None
630
+ }
631
+ )
632
+ }
633
+
634
+ let deriveCommands = (
635
+ ~component: string,
636
+ ~observations: array<observation>,
637
+ ~labelled: bool,
638
+ ): array<derivedCommand> => {
639
+ let names = sortedUnique(observations->Array.map(o => o.command))
640
+ names->Array.map(command => {
641
+ let mine = observations->Array.filter(o => o.command == command)
642
+ let effective = mine->Array.filter(o => o.outcome == Emitted)
643
+ {
644
+ component,
645
+ command,
646
+ allowedStates: sortedUnique(
647
+ effective->Array.filterMap(o => o.from == noRow ? None : Some(o.from)),
648
+ ),
649
+ inertStates: sortedUnique(
650
+ mine->Array.filterMap(o =>
651
+ o.outcome == Emitted || o.from == noRow ? None : Some(o.from)
652
+ ),
653
+ ),
654
+ targets: sortedUnique(
655
+ effective->Array.filterMap(o => o.to == o.from || o.to == noRow ? None : Some(o.to)),
656
+ ),
657
+ // A command whose every successful scenario starts from no row is creating
658
+ // one. This is what the published metadata guesses at today from the
659
+ // command's name stem, which misreads `Enroll`, `Provision`, `Onboard`.
660
+ level: switch (labelled, Array.length(effective)) {
661
+ | (false, _) | (_, 0) => ""
662
+ | (true, _) => effective->Array.every(o => o.from == noRow) ? "Collection" : "Instance"
663
+ },
664
+ scenarios: Array.length(mine),
665
+ }
666
+ })
667
+ }
668
+
669
+ // ── The three verdicts ──────────────────────────────────────────────────────
670
+
671
+ type finding = {
672
+ severity: string, // "contradicted" | "unverified" | "undeclared" | "level" | "ambiguous"
673
+ plugin: string,
674
+ /** The component the finding is about: the writable whose switch made the
675
+ claim, or — for `ambiguous` — the view whose corpus disagrees with itself.
676
+ Kept beside `message` rather than only inside it, because a consumer has to
677
+ find the file before it can say anything about it. */
678
+ component: string,
679
+ /** Empty for a finding that is about the component rather than one command. */
680
+ command: string,
681
+ /** The lifecycle state(s) the finding names, where it names any. This is what
682
+ a generated scenario's `given` has to fold to, so it is the one part of the
683
+ sentence a consumer cannot re-derive. */
684
+ states: array<string>,
685
+ message: string,
686
+ }
687
+
688
+ /** A corpus the walk can only partly read.
689
+
690
+ Several DSL verbs record their `given` and nothing else — `thenIssuesCommand`,
691
+ `whenReacts`, `whenPublishedThrough` and the rest — and so does a readable
692
+ verb handed a let-bound value rather than a literal, since the sidecar records
693
+ the constructor application it can see. Either way the scenario reaches here
694
+ with an empty `when`, and the walk has nothing to exercise.
695
+
696
+ Counted per component and published, because the distinction a consumer MUST
697
+ keep is "not covered" against "not analysed": a slice with twenty thorough
698
+ scenarios and no readable `when` is not an untested slice, and rendering it as
699
+ one is the fastest way to teach people to ignore the warning. */
700
+ type opaque = {
701
+ plugin: string,
702
+ component: string,
703
+ path: string,
704
+ scenarios: int,
705
+ /** Of those, the ones whose `when` the sidecar recorded as empty. */
706
+ unreadable: int,
707
+ }
708
+
709
+ /** Everything a declared command claims, reported as unverified.
710
+
711
+ Reached two ways, and both are the same statement: a command with no
712
+ scenarios at all, and a command whose corpus cannot be labelled because its
713
+ views declare no lifecycle. In neither case has anything ever exercised what
714
+ the declaration claims, which is exactly what a warning is for. */
715
+ let allUnverified = (
716
+ ~cmd: declaredCommand,
717
+ ~add: (string, array<string>, string) => unit,
718
+ ~why: string,
719
+ ): unit => {
720
+ switch cmd.allowedStates {
721
+ | Some(states) if Array.length(states) > 0 =>
722
+ add("unverified", states, `the switch names ${states->Array.join(", ")}, and ${why}`)
723
+ | _ => ()
724
+ }
725
+ switch cmd.targetState {
726
+ | Some(target) => add("unverified", [target], `the switch targets "${target}", and ${why}`)
727
+ | None => ()
728
+ }
729
+ }
730
+
731
+ let compare = (
732
+ ~plugin: string,
733
+ ~writable: declaredWritable,
734
+ ~derived: derivedCommand,
735
+ ~findings: array<finding>,
736
+ ): unit => {
737
+ let where = `${plugin}/${writable.name}.${derived.command}`
738
+ let add = (severity, states, message) =>
739
+ findings
740
+ ->Array.push({
741
+ severity,
742
+ plugin,
743
+ component: writable.name,
744
+ command: derived.command,
745
+ states,
746
+ message: `${where}: ${message}`,
747
+ })
748
+ ->ignore
749
+
750
+ let declared = writable.commands->Array.find(c => c.command == derived.command)
751
+
752
+ switch declared {
753
+ | None => ()
754
+ | Some(cmd) =>
755
+ switch cmd.allowedStates {
756
+ // "Legal in every state", declared. A scenario showing the command taking
757
+ // effect somewhere agrees with that rather than narrowing it — the corpus
758
+ // covers the states somebody wrote a scenario for, and silence about the
759
+ // rest is not refusal. What DOES refute the claim is a state the command was
760
+ // exercised in and did nothing: that is the switch and the behaviour
761
+ // disagreeing about the same row.
762
+ | None if cmd.allowedStatesSource == Some("unrestricted") =>
763
+ derived.inertStates->Array.forEach(state =>
764
+ add(
765
+ "contradicted",
766
+ [state],
767
+ `the switch declares it legal in every state, and a scenario from "${state}" ` ++
768
+ `shows it refused or producing nothing`,
769
+ )
770
+ )
771
+ | None =>
772
+ if Array.length(derived.allowedStates) > 0 {
773
+ add(
774
+ "undeclared",
775
+ derived.allowedStates,
776
+ `scenarios show it taking effect from ${derived.allowedStates->Array.join(", ")}, ` ++
777
+ `and it declares no edge`,
778
+ )
779
+ }
780
+ | Some(states) =>
781
+ states->Array.forEach(state =>
782
+ if derived.allowedStates->Array.includes(state) {
783
+ ()
784
+ } else if derived.inertStates->Array.includes(state) {
785
+ add(
786
+ "contradicted",
787
+ [state],
788
+ `the switch names "${state}", and a scenario from "${state}" shows it ` ++
789
+ `refused or producing nothing`,
790
+ )
791
+ } else {
792
+ add("unverified", [state], `the switch names "${state}", and no scenario starts there`)
793
+ }
794
+ )
795
+ derived.allowedStates->Array.forEach(state =>
796
+ if !(states->Array.includes(state)) {
797
+ add(
798
+ "contradicted",
799
+ [state],
800
+ `a scenario shows it taking effect from "${state}", which its declared ` ++
801
+ `from-set (${states->Array.join(", ")}) excludes`,
802
+ )
803
+ }
804
+ )
805
+ if Array.length(states) > 0 && Array.length(derived.allowedStates) == 0 {
806
+ add(
807
+ "unverified",
808
+ states,
809
+ `the switch declares ${Array.length(states)->Int.toString} state(s) and ` ++
810
+ `no scenario shows the command taking effect anywhere`,
811
+ )
812
+ }
813
+ }
814
+
815
+ switch (cmd.targetState, derived.targets) {
816
+ | (None, _) => ()
817
+ | (Some(target), []) =>
818
+ add("unverified", [target], `the switch targets "${target}", and no scenario shows an edge`)
819
+ | (Some(target), observed) =>
820
+ if !(observed->Array.includes(target)) {
821
+ add(
822
+ "contradicted",
823
+ [target],
824
+ `the switch targets "${target}", and scenarios land in ` ++
825
+ `${observed->Array.join(", ")}`,
826
+ )
827
+ }
828
+ observed->Array.forEach(state =>
829
+ if state != target {
830
+ add(
831
+ "contradicted",
832
+ [target, state],
833
+ `the switch targets "${target}", and a scenario lands in "${state}" — ` ++
834
+ `the published targetState carries one state, so this edge cannot be expressed`,
835
+ )
836
+ }
837
+ )
838
+ }
839
+
840
+ if derived.level != "" && cmd.level != "" && derived.level != cmd.level {
841
+ add(
842
+ "level",
843
+ [],
844
+ `scenarios make it ${derived.level}-level; the published metadata says ${cmd.level}`,
845
+ )
846
+ }
847
+ }
848
+ }
849
+
850
+ // ── Per-app run ─────────────────────────────────────────────────────────────
851
+
852
+ /** A plugin is a directory with both a composition root and a corpus. Found
853
+ rather than listed so a new app, or a new plugin in one, is covered without
854
+ this file being edited. */
855
+ let pluginDirsIn = (exampleDir: string): array<string> =>
856
+ switch NodeFs.readdirSync(exampleDir, {withFileTypes: true}) {
857
+ | entries =>
858
+ entries
859
+ ->Array.filter(e => e->NodeFs.isDirectory)
860
+ ->Array.map(e => NodePath.join([exampleDir, e->NodeFs.direntName]))
861
+ ->Array.filter(dir =>
862
+ NodePath.join([dir, "src", "Plugin.res"])->NodeFs.existsSync &&
863
+ NodePath.join([dir, "tests"])->NodeFs.existsSync
864
+ )
865
+ | exception _ => []
866
+ }
867
+
868
+ /** The apps to check: every `--root`, or — with none — every subdirectory of
869
+ `<cwd>/examples`, which is what this repository's own gate passes nothing to
870
+ get. A `--root` is resolved against the working directory so a relative one
871
+ means what the person who typed it meant, and labelled by its basename so the
872
+ prose reads the same either way. */
873
+ // `--root` typed with nothing usable after it — `--root --json`, or a trailing
874
+ // `--root`. Falling back to the default scan below would check the whole examples
875
+ // tree while the person believed they had narrowed it to one app, so refuse here
876
+ // rather than answer a question nobody asked.
877
+ if NodeProcess.argv->Array.includes("--root") && Array.length(flagValues("--root")) == 0 {
878
+ Console.error("--root needs a directory after it")
879
+ NodeProcess.exit(1)
880
+ }
881
+
882
+ let roots: array<appRoot> = switch flagValues("--root") {
883
+ | [] =>
884
+ switch NodeFs.readdirSync(examplesDir, {withFileTypes: true}) {
885
+ | entries =>
886
+ entries
887
+ ->Array.filter(e => e->NodeFs.isDirectory)
888
+ ->Array.map(e => e->NodeFs.direntName)
889
+ ->Array.toSorted(String.compare)
890
+ ->Array.map(name => {label: name, dir: NodePath.join([examplesDir, name])})
891
+ | exception _ => []
892
+ }
893
+ | given =>
894
+ given->Array.map(given => {
895
+ let dir = NodePath.resolve([given])
896
+ {label: NodePath.basename(dir), dir}
897
+ })
898
+ }
899
+
900
+ /** Sidecar paths that describe a queryable, and those that describe a writable.
901
+ Told apart by the folder the source sits in, which is the same vocabulary the
902
+ plugin generator and the PPX already read a component's kind from. */
903
+ let isViewPath = (path: string) =>
904
+ ["/ReadModel/", "/ReadModelStream/", "/StateViewSlice/", "/StateViewSliceStream/"]->Array.some(
905
+ seg => path->String.includes(seg),
906
+ )
907
+
908
+ let isWritablePath = (path: string) =>
909
+ ["/Aggregate/", "/StateChangeSlice/"]->Array.some(seg => path->String.includes(seg))
910
+
911
+ let runPlugin = async (
912
+ ~plugin: string,
913
+ ~pluginDir: string,
914
+ ~findings: array<finding>,
915
+ ~opaque: array<opaque>,
916
+ ): result<array<derivedCommand>, string> =>
917
+ switch await readDeclared(~pluginDir) {
918
+ | Error(msg) => Error(msg)
919
+ | Ok(declared) =>
920
+ let corpora =
921
+ filesUnder(NodePath.join([pluginDir, "tests"]), ~suffix=".gwt.json")->Array.filterMap(
922
+ readCorpus,
923
+ )
924
+
925
+ // Every corpus, not only the ones the walk goes on to use: the kinds that
926
+ // are unreadable in full — extension points, automation and translation
927
+ // slices — are exactly the ones that sit outside the view/writable folders
928
+ // below, and they are the ones a coverage UI would otherwise slander.
929
+ corpora->Array.forEach(c => {
930
+ let unreadable = c.scenarios->Array.filter(s => Array.length(s.whenElements) == 0)
931
+ if Array.length(unreadable) > 0 {
932
+ opaque
933
+ ->Array.push({
934
+ plugin,
935
+ component: c.component,
936
+ path: c.path,
937
+ scenarios: Array.length(c.scenarios),
938
+ unreadable: Array.length(unreadable),
939
+ })
940
+ ->ignore
941
+ }
942
+ })
943
+
944
+ // Views first: a command's history cannot be labelled until the events in
945
+ // it have somewhere to land.
946
+ let mapsByView = Dict.make()
947
+ let ambiguities = []
948
+ corpora->Array.forEach(c =>
949
+ if isViewPath(c.path) {
950
+ switch declared.views->Array.find(v => v.name == c.component) {
951
+ | Some({lifecycleField: Some(field)}) =>
952
+ mapsByView->Dict.set(
953
+ c.component,
954
+ lifecycleMapFor(~scenarios=c.scenarios, ~field, ~ambiguities, ~view=c.component),
955
+ )
956
+ // A view with no lifecycle field labels nothing, and that is ordinary
957
+ // — most views have no lifecycle at all.
958
+ | _ => ()
959
+ }
960
+ }
961
+ )
962
+ ambiguities->Array.forEach(((view, message)) =>
963
+ findings
964
+ ->Array.push({
965
+ severity: "ambiguous",
966
+ plugin,
967
+ component: view,
968
+ command: "",
969
+ states: [],
970
+ message,
971
+ })
972
+ ->ignore
973
+ )
974
+
975
+ let derived = []
976
+ corpora->Array.forEach(c =>
977
+ if isWritablePath(c.path) {
978
+ switch declared.writables->Array.find(w => w.name == c.component) {
979
+ | None => ()
980
+ | Some(writable) =>
981
+ // The union of the maps of every view this writable feeds. A union
982
+ // rather than a single view for the reason the platform's own name
983
+ // check uses one: a slice feeding two views is not claiming which of
984
+ // them a state belongs to.
985
+ let map = Dict.make()
986
+ writable.linkedViews->Array.forEach(view =>
987
+ switch mapsByView->Dict.get(view) {
988
+ | Some(m) => m->Dict.forEachWithKey((value, event) => map->Dict.set(event, value))
989
+ | None => ()
990
+ }
991
+ )
992
+ let labelled = Array.length(map->Dict.keysToArray) > 0
993
+ let idFieldFor = command =>
994
+ writable.commands
995
+ ->Array.find(c => c.command == command)
996
+ ->Option.flatMap(c => c.aggregateIdField)
997
+ let observations = observe(~scenarios=c.scenarios, ~map, ~idFieldFor)
998
+ let commands = deriveCommands(~component=c.component, ~observations, ~labelled)
999
+
1000
+ commands->Array.forEach(d => {
1001
+ // Without a lifecycle map every history folds to "no row", so
1002
+ // there is nothing to confirm a claim against and nothing to
1003
+ // contradict it with. Reporting the claim as unverified is the
1004
+ // honest answer; running the comparison would manufacture
1005
+ // contradictions out of a missing map.
1006
+ if labelled {
1007
+ compare(~plugin, ~writable, ~derived=d, ~findings)
1008
+ }
1009
+ derived->Array.push(d)
1010
+ })
1011
+
1012
+ let why = labelled
1013
+ ? "no scenario exercises the command"
1014
+ : `${writable.linkedViews->Array.join(", ")} declares no lifecycle field, so its ` ++
1015
+ `scenarios cannot be labelled`
1016
+ writable.commands->Array.forEach(cmd =>
1017
+ if labelled && commands->Array.some(d => d.command == cmd.command) {
1018
+ ()
1019
+ } else {
1020
+ allUnverified(~cmd, ~why, ~add=(severity, states, message) =>
1021
+ findings
1022
+ ->Array.push({
1023
+ severity,
1024
+ plugin,
1025
+ component: writable.name,
1026
+ command: cmd.command,
1027
+ states,
1028
+ message: `${plugin}/${writable.name}.${cmd.command}: ${message}`,
1029
+ })
1030
+ ->ignore
1031
+ )
1032
+ }
1033
+ )
1034
+ }
1035
+ }
1036
+ )
1037
+ Ok(derived)
1038
+ }
1039
+
1040
+ // ── The golden ──────────────────────────────────────────────────────────────
1041
+
1042
+ /** The derived model, written out so a change to it shows up as a reviewable
1043
+ diff in the pull request that causes it — the same contract the GraphQL
1044
+ goldens hold. A rule that stops holding for a corpus it was never validated
1045
+ against becomes a line in a diff instead of a silent change of answer. */
1046
+ let byComponentThenCommand = (derived: array<derivedCommand>): array<derivedCommand> =>
1047
+ derived->Array.toSorted((a, b) =>
1048
+ switch String.compare(a.component, b.component) {
1049
+ | 0. => String.compare(a.command, b.command)
1050
+ | c => c
1051
+ }
1052
+ )
1053
+
1054
+ let goldenJson = (derived: array<derivedCommand>): string => {
1055
+ let entries =
1056
+ derived
1057
+ ->byComponentThenCommand
1058
+ ->Array.map(d =>
1059
+ JSON.Encode.object(
1060
+ Dict.fromArray([
1061
+ ("component", JSON.Encode.string(d.component)),
1062
+ ("command", JSON.Encode.string(d.command)),
1063
+ ("level", JSON.Encode.string(d.level)),
1064
+ ("allowedStates", JSON.Encode.array(d.allowedStates->Array.map(JSON.Encode.string))),
1065
+ ("targets", JSON.Encode.array(d.targets->Array.map(JSON.Encode.string))),
1066
+ ("scenarios", JSON.Encode.int(d.scenarios)),
1067
+ ]),
1068
+ )
1069
+ )
1070
+ JSON.stringify(JSON.Encode.array(entries), ~space=2) ++ "\n"
1071
+ }
1072
+
1073
+ let goldenPath = (~root: appRoot) => NodePath.join([root.dir, "schema", "lifecycle-model.json"])
1074
+
1075
+ // ── The machine-readable run ────────────────────────────────────────────────
1076
+
1077
+ /** The same run as the prose, as fields.
1078
+
1079
+ Three things travel here that the prose does not carry, each because a
1080
+ consumer cannot recover it from the sentence:
1081
+
1082
+ - `states` on a finding — the state a generated scenario's `given` has to
1083
+ fold to. The sentence names it; parsing it back out is the thing this
1084
+ exists to avoid.
1085
+ - `inertStates` on a command — internal to the check until now. With it, a
1086
+ state in neither `allowedStates` nor `inertStates` is one no scenario has
1087
+ ever exercised, which is a sharper "missing scenario" than a verdict.
1088
+ - `opaque` — the corpora the walk cannot read, so a consumer can say "not
1089
+ analysed" where it would otherwise say "not covered".
1090
+
1091
+ `str` is the schema's own version, bumped when a consumer would have to
1092
+ change. */
1093
+ let reportJson = (
1094
+ ~findings: array<finding>,
1095
+ ~opaque: array<opaque>,
1096
+ ~derived: array<(string, derivedCommand)>,
1097
+ ~failures: array<string>,
1098
+ ): string => {
1099
+ let strs = xs => JSON.Encode.array(xs->Array.map(JSON.Encode.string))
1100
+ let obj = pairs => JSON.Encode.object(Dict.fromArray(pairs))
1101
+
1102
+ let findingJson = (f: finding) =>
1103
+ obj([
1104
+ ("verdict", JSON.Encode.string(f.severity)),
1105
+ ("plugin", JSON.Encode.string(f.plugin)),
1106
+ ("component", JSON.Encode.string(f.component)),
1107
+ ("command", JSON.Encode.string(f.command)),
1108
+ ("states", strs(f.states)),
1109
+ ("message", JSON.Encode.string(f.message)),
1110
+ ])
1111
+
1112
+ let commandJson = ((plugin, d): (string, derivedCommand)) =>
1113
+ obj([
1114
+ ("plugin", JSON.Encode.string(plugin)),
1115
+ ("component", JSON.Encode.string(d.component)),
1116
+ ("command", JSON.Encode.string(d.command)),
1117
+ ("level", JSON.Encode.string(d.level)),
1118
+ ("allowedStates", strs(d.allowedStates)),
1119
+ ("inertStates", strs(d.inertStates)),
1120
+ ("targets", strs(d.targets)),
1121
+ ("scenarios", JSON.Encode.int(d.scenarios)),
1122
+ ])
1123
+
1124
+ let opaqueJson = (o: opaque) =>
1125
+ obj([
1126
+ ("plugin", JSON.Encode.string(o.plugin)),
1127
+ ("component", JSON.Encode.string(o.component)),
1128
+ ("path", JSON.Encode.string(o.path)),
1129
+ ("scenarios", JSON.Encode.int(o.scenarios)),
1130
+ ("unreadable", JSON.Encode.int(o.unreadable)),
1131
+ ])
1132
+
1133
+ JSON.stringify(
1134
+ obj([
1135
+ ("version", JSON.Encode.int(1)),
1136
+ ("findings", JSON.Encode.array(findings->Array.map(findingJson))),
1137
+ ("commands", JSON.Encode.array(derived->Array.map(commandJson))),
1138
+ ("opaque", JSON.Encode.array(opaque->Array.map(opaqueJson))),
1139
+ ("unreadable", strs(failures)),
1140
+ ]),
1141
+ ~space=2,
1142
+ ) ++ "\n"
1143
+ }
1144
+
1145
+ // ── The value structure assembly reads ──────────────────────────────────────
1146
+
1147
+ /** The same derivation as a committed ReScript value, so `buildStructure` gets
1148
+ the model as data. It cannot read the corpus itself: tests are not published
1149
+ with a plugin package, and metadata that read them would make deleting a test
1150
+ file change a production command menu.
1151
+
1152
+ Only what the structure resolves an edge from travels — a scenario count says
1153
+ nothing to a menu, and belongs in the golden a person reads. A command the
1154
+ corpus could label nothing about is left out for the same reason: an entry
1155
+ that resolves to no level, no from-set and no target is read exactly as an
1156
+ absent one, and writing it out would make most of the file say nothing. */
1157
+ let modelSource = (~plugin: string, ~derived: array<derivedCommand>): string => {
1158
+ let saysSomething = (d: derivedCommand) =>
1159
+ d.level != "" || Array.length(d.allowedStates) > 0 || Array.length(d.targets) > 0
1160
+ let quoted = (xs: array<string>) =>
1161
+ "[" ++ xs->Array.map(s => `"${s}"`)->Array.join(", ") ++ "]"
1162
+ let entries = derived->Array.filter(saysSomething)->byComponentThenCommand->Array.map(d => {
1163
+ // Omitted rather than written as an absent value: `level` is an optional
1164
+ // field, and a corpus that could not label this command's histories has
1165
+ // nothing to say about it.
1166
+ let level = switch d.level {
1167
+ | "Collection" | "Instance" => `level: Reventless.Plugin.${d.level}, `
1168
+ | _ => ""
1169
+ }
1170
+ ` {component: "${d.component}", command: "${d.command}", ${level}` ++
1171
+ `allowedStates: ${quoted(d.allowedStates)}, targets: ${quoted(d.targets)}},`
1172
+ })
1173
+ Array.flat([
1174
+ [
1175
+ `// AUTO-GENERATED — do not edit. Run \`pnpm run check:lifecycle:update\` to update.`,
1176
+ `//`,
1177
+ `// What ${plugin}'s own given/when/then scenarios say about each command: the`,
1178
+ `// states one shows it taking effect from, the states those land in, and whether`,
1179
+ `// it brings a row into existence. \`Plugin_Structure\` prefers this to the`,
1180
+ `// \`@transition\` annotation where it says anything, and falls back to the`,
1181
+ `// annotation where it is silent.`,
1182
+ ``,
1183
+ `let model: array<Reventless.Plugin.derivedEdge> = [`,
1184
+ ],
1185
+ entries,
1186
+ ["]", ""],
1187
+ ])->Array.join("\n")
1188
+ }
1189
+
1190
+ let modelPath = (~pluginDir: string) => NodePath.join([pluginDir, "src", "LifecycleModel.res"])
1191
+
1192
+ /** Rewrite under `--update`, and when nothing is there yet so a plugin harvested
1193
+ for the first time is not a failure. Otherwise compare, and record the drift:
1194
+ a derivation that moved belongs in the diff of the change that moved it. */
1195
+ let writeOrCompare = (~path: string, ~actual: string, ~label: string, ~drifted: array<string>) => {
1196
+ let existed = path->NodeFs.existsSync
1197
+ if update || !existed {
1198
+ NodeFs.writeFileSync(path, actual)
1199
+ Console.log(`${existed ? "updated" : "wrote"} ${label}`)
1200
+ } else if path->NodeFs.readFileSync != actual {
1201
+ drifted->Array.push(label)->ignore
1202
+ Console.error(`\ndrift in ${label}`)
1203
+ }
1204
+ }
1205
+
1206
+ // ── Entry point ─────────────────────────────────────────────────────────────
1207
+
1208
+ let main = async () => {
1209
+ let findings = []
1210
+ let opaque = []
1211
+ let failures = []
1212
+ let drifted = []
1213
+ let allDerived = []
1214
+
1215
+ let allPluginDirs = roots->Array.flatMap(root => pluginDirsIn(root.dir))
1216
+
1217
+ // A run that found nothing to check is a mistyped `--root` far more often than
1218
+ // an app with no plugins, and reporting "ok" for it is how that typo survives.
1219
+ if Array.length(allPluginDirs) == 0 {
1220
+ Console.error(
1221
+ `no plugins found under ${roots->Array.map(r => r.dir)->Array.join(", ")} — a plugin is a ` ++
1222
+ `directory with both src/Plugin.res and tests/`,
1223
+ )
1224
+ NodeProcess.exit(1)
1225
+ }
1226
+
1227
+ switch reuseSidecars
1228
+ ? checkSidecars(~pluginDirs=allPluginDirs)
1229
+ : emitSidecars(~pluginDirs=allPluginDirs) {
1230
+ | Error(msg) =>
1231
+ Console.error(msg)
1232
+ NodeProcess.exit(1)
1233
+ | Ok() => ()
1234
+ }
1235
+
1236
+ // The build above is the working directory's, so a `--root` elsewhere can come
1237
+ // back successful having emitted nothing for the tree actually being checked.
1238
+ // An empty corpus reads as every edge unverified — a warning — so without this
1239
+ // the run would pass having checked nothing, which is the one outcome worth
1240
+ // refusing outright.
1241
+ if !hasCorpus(~pluginDirs=allPluginDirs) {
1242
+ Console.error(
1243
+ `no scenario sidecar exists under ${roots->Array.map(r => r.dir)->Array.join(", ")} after ` ++
1244
+ `the build. Build that tree with REVENTLESS_EMIT_SIDECAR=1 and pass --reuse-sidecars.`,
1245
+ )
1246
+ NodeProcess.exit(1)
1247
+ }
1248
+
1249
+ for i in 0 to Array.length(roots) - 1 {
1250
+ switch roots->Array.get(i) {
1251
+ | None => ()
1252
+ | Some(root) =>
1253
+ let example = root.label
1254
+ let exampleDir = root.dir
1255
+ let derived = []
1256
+ let dirs = pluginDirsIn(exampleDir)
1257
+
1258
+ for j in 0 to Array.length(dirs) - 1 {
1259
+ switch dirs->Array.get(j) {
1260
+ | None => ()
1261
+ | Some(pluginDir) =>
1262
+ let plugin = NodePath.basename(pluginDir)
1263
+ let qualified = `${example}/${plugin}`
1264
+ switch await runPlugin(~plugin=qualified, ~pluginDir, ~findings, ~opaque) {
1265
+ | Ok(commands) =>
1266
+ commands->Array.forEach(c => {
1267
+ derived->Array.push(c)
1268
+ allDerived->Array.push((qualified, c))
1269
+ })
1270
+ if !json {
1271
+ writeOrCompare(
1272
+ ~path=modelPath(~pluginDir),
1273
+ ~actual=modelSource(~plugin, ~derived=commands),
1274
+ ~label=`${example}/${plugin}/src/LifecycleModel.res`,
1275
+ ~drifted,
1276
+ )
1277
+ }
1278
+ | Error(msg) => failures->Array.push(`${example}/${plugin}: ${msg}`)->ignore
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ if Array.length(dirs) > 0 && !json {
1284
+ let dir = NodePath.join([exampleDir, "schema"])
1285
+ if !(dir->NodeFs.existsSync) {
1286
+ NodeFs.mkdirSync(dir, {recursive: true})
1287
+ }
1288
+ writeOrCompare(
1289
+ ~path=goldenPath(~root),
1290
+ ~actual=goldenJson(derived),
1291
+ ~label=`${example}/schema/lifecycle-model.json`,
1292
+ ~drifted,
1293
+ )
1294
+ Console.log(
1295
+ `ok ${example} — ${Array.length(derived)->Int.toString} commands derived from scenarios`,
1296
+ )
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ let of_ = severity => findings->Array.filter(f => f.severity == severity)
1302
+ let contradicted = of_("contradicted")
1303
+
1304
+ if json {
1305
+ Console.log(reportJson(~findings, ~opaque, ~derived=allDerived, ~failures))
1306
+ } else {
1307
+ ["contradicted", "unverified", "undeclared", "level", "ambiguous"]->Array.forEach(severity => {
1308
+ let group = of_(severity)
1309
+ if Array.length(group) > 0 {
1310
+ Console.log(`\n${severity} (${Array.length(group)->Int.toString})`)
1311
+ group->Array.forEach(f => Console.log(` ${f.message}`))
1312
+ }
1313
+ })
1314
+ }
1315
+
1316
+ if Array.length(failures) > 0 && !json {
1317
+ Console.error(`\ncould not read:`)
1318
+ failures->Array.forEach(f => Console.error(` ${f}`))
1319
+ }
1320
+
1321
+ if Array.length(drifted) > 0 {
1322
+ Console.error(
1323
+ `\n${Array.length(drifted)->Int.toString} lifecycle artifact(s) changed. If the change is ` ++
1324
+ `intended, re-run with --update and commit them alongside the change that moved them.`,
1325
+ )
1326
+ }
1327
+
1328
+ // Warnings do not fail the build: an unverified edge is a corpus that has not
1329
+ // caught up, which is a thing to work on rather than a thing to stop for. A
1330
+ // contradiction is a disagreement between two statements about the same
1331
+ // command, and one of them is wrong.
1332
+ if Array.length(contradicted) > 0 || Array.length(drifted) > 0 || Array.length(failures) > 0 {
1333
+ NodeProcess.exit(1)
1334
+ }
1335
+ }
1336
+
1337
+ let _ = main()