@reventlessdev/reventless-spec 3.0.0-alpha.129 → 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.
- package/CHANGELOG.md +23 -0
- package/package.json +3 -2
- package/run-check-lifecycle.mjs +2 -0
- package/schema/platform-api.graphql +1 -0
- package/src/components/DcbTag.res +229 -32
- package/src/components/DcbTag.res.mjs +232 -41
- package/src/components/Plugin.res +47 -4
- package/src/components/Plugin.res.mjs +1 -0
- package/src/components/Reference.res +48 -0
- package/src/components/Reference.res.mjs +28 -0
- package/src/generator/Codegen.res +14 -1
- package/src/generator/Codegen.res.mjs +9 -5
- package/src/generator/PluginGenerator.res +4 -1
- package/src/generator/PluginGenerator.res.mjs +2 -1
- package/src/lifecycle/CheckLifecycleModel.res +1337 -0
- package/src/lifecycle/CheckLifecycleModel.res.mjs +1107 -0
- package/src/lifecycle/loadPluginStructure.mjs +66 -0
- package/src/types/Transition.res +30 -3
- package/src/types/Transition.res.mjs +13 -4
|
@@ -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()
|