@reventlessdev/reventless-spec 3.0.0-alpha.149 → 3.0.0-alpha.150

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 CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.150 (2026-09-27)
7
+
8
+ ### Features
9
+
10
+ * **core,spec:** an identity the plugin declares is information, not a dangling reference ([30d7917](https://github.com/ReventlessDev/reventless-core/commit/30d7917669ff062439f9c65e5274ab63a2e20de0))
11
+ * **ppx,spec:** a view with no lifecycle field places a creating command by its scenarios ([acfc7fe](https://github.com/ReventlessDev/reventless-core/commit/acfc7feeb4d23d40cddaade7ae177fcd627b3d48))
12
+ * **spec:** check-lifecycle says why it cannot build, and what works instead ([73bd527](https://github.com/ReventlessDev/reventless-core/commit/73bd527230b3cadc97c73e3325747e5c5ada4567))
13
+
14
+
6
15
  # 3.0.0-alpha.149 (2026-09-25)
7
16
 
8
17
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.149",
3
+ "version": "3.0.0-alpha.150",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "sury": "11.0.0",
27
27
  "sury-ppx": "11.0.0",
28
28
  "yaml": "^2.8.3",
29
- "@reventlessdev/rescript-node": "2.0.0-alpha.15"
29
+ "@reventlessdev/rescript-node": "2.0.0-alpha.16"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.1",
@@ -340,6 +340,7 @@ let renderPluginStructureCall = (
340
340
  ~extensionPoints: array<Pairing.extensionPointDef>,
341
341
  ~componentChapters: array<(string, string)>,
342
342
  ~hasLifecycleModel: bool,
343
+ ~identities: array<string>,
343
344
  ): option<array<string>> => {
344
345
  // The structure call carries the EP *mapping* files (one per Delegate
345
346
  // connection), not the wrapped ExtensionPoint module — Plugin_Structure reads
@@ -431,6 +432,13 @@ let renderPluginStructureCall = (
431
432
  if hasLifecycleModel {
432
433
  ls->Array.push(" ~lifecycleModel=LifecycleModel.model,")
433
434
  }
435
+
436
+ // The identities the plugin declares, so one no view lists yet is reported
437
+ // as information rather than a warning. Omitted when there are none.
438
+ if identities->Array.length > 0 {
439
+ let keys = identities->Array.map(m => m ++ ".key")
440
+ ls->Array.push(" ~identities=[" ++ keys->Array.join(", ") ++ "],")
441
+ }
434
442
  ls->Array.push(" )")
435
443
  Some(ls)
436
444
  }
@@ -610,6 +618,7 @@ let renderComposition = (
610
618
  ~resolved: Pairing.resolved,
611
619
  ~componentChapters: array<(string, string)>,
612
620
  ~hasLifecycleModel: bool,
621
+ ~identities: array<string>,
613
622
  ): string => {
614
623
  let lines: array<string> = []
615
624
 
@@ -754,6 +763,7 @@ let renderComposition = (
754
763
  ~extensionPoints=resolved.extensionPoints,
755
764
  ~componentChapters,
756
765
  ~hasLifecycleModel,
766
+ ~identities,
757
767
  )
758
768
  let hasPluginStructure = pluginStructureLines->Option.isSome
759
769
  switch pluginStructureLines {
@@ -856,6 +866,7 @@ let render = (
856
866
  ~resolved: Pairing.resolved,
857
867
  ~discovered: array<Discovery.discoveredFile>,
858
868
  ~hasLifecycleModel: bool=false,
869
+ ~identities: array<string>=[],
859
870
  ): string => {
860
871
  validateUniqueSpecStems(~discovered)
861
872
  validateSliceTargets(~resolved)
@@ -881,6 +892,7 @@ let render = (
881
892
  )
882
893
  switch config.variant {
883
894
  | Aws({compositionNamespace}) => renderAwsWrapper(~compositionNamespace)
884
- | Composition => renderComposition(~config, ~resolved, ~componentChapters, ~hasLifecycleModel)
895
+ | Composition =>
896
+ renderComposition(~config, ~resolved, ~componentChapters, ~hasLifecycleModel, ~identities)
885
897
  }
886
898
  }
@@ -239,7 +239,7 @@ function renderTaskMakeParam(tasks) {
239
239
  return " ~tasks=[" + entries.join(", ") + "],";
240
240
  }
241
241
 
242
- function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters, hasLifecycleModel) {
242
+ function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters, hasLifecycleModel, identities) {
243
243
  let epMappingStems = extensionPoints.flatMap(param => param.mappings);
244
244
  let hasComponents = aggregates.length !== 0 || readModels.length !== 0 || stateViewSlices.length !== 0 || stateViewSlicesStream.length !== 0 || stateChangeSlices.length !== 0 || automationSlices.length !== 0 || outboundTranslationSlices.length !== 0 || inboundTranslationSlices.length !== 0 || extensions.length !== 0 || epMappingStems.length !== 0;
245
245
  if (!hasComponents) {
@@ -298,6 +298,10 @@ function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices
298
298
  if (hasLifecycleModel) {
299
299
  ls.push(" ~lifecycleModel=LifecycleModel.model,");
300
300
  }
301
+ if (identities.length !== 0) {
302
+ let keys = identities.map(m => m + ".key");
303
+ ls.push(" ~identities=[" + keys.join(", ") + "],");
304
+ }
301
305
  ls.push(" )");
302
306
  return ls;
303
307
  }
@@ -397,7 +401,7 @@ function renderAwsWrapper(compositionNamespace) {
397
401
  ].join("\n");
398
402
  }
399
403
 
400
- function renderComposition(config, resolved, componentChapters, hasLifecycleModel) {
404
+ function renderComposition(config, resolved, componentChapters, hasLifecycleModel, identities) {
401
405
  let lines = [];
402
406
  lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
403
407
  lines.push("");
@@ -462,7 +466,7 @@ function renderComposition(config, resolved, componentChapters, hasLifecycleMode
462
466
  lines.push(" // Extensions");
463
467
  push(renderExtensions(resolved.extensions));
464
468
  }
465
- let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints, componentChapters, hasLifecycleModel);
469
+ let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints, componentChapters, hasLifecycleModel, identities);
466
470
  let hasPluginStructure = Stdlib_Option.isSome(pluginStructureLines);
467
471
  if (pluginStructureLines !== undefined) {
468
472
  pluginStructureLines.forEach(l => {
@@ -520,8 +524,9 @@ function renderComposition(config, resolved, componentChapters, hasLifecycleMode
520
524
  return lines.join("\n");
521
525
  }
522
526
 
523
- function render(config, resolved, discovered, hasLifecycleModelOpt) {
527
+ function render(config, resolved, discovered, hasLifecycleModelOpt, identitiesOpt) {
524
528
  let hasLifecycleModel = hasLifecycleModelOpt !== undefined ? hasLifecycleModelOpt : false;
529
+ let identities = identitiesOpt !== undefined ? identitiesOpt : [];
525
530
  validateUniqueSpecStems(discovered);
526
531
  validateSliceTargets(resolved);
527
532
  let componentStems = [
@@ -537,7 +542,7 @@ function render(config, resolved, discovered, hasLifecycleModelOpt) {
537
542
  let componentChapters = Discovery$Reventless.chaptersByStem(discovered).filter(param => componentStems.includes(param[0]));
538
543
  let match = config.variant;
539
544
  if (typeof match !== "object") {
540
- return renderComposition(config, resolved, componentChapters, hasLifecycleModel);
545
+ return renderComposition(config, resolved, componentChapters, hasLifecycleModel, identities);
541
546
  } else {
542
547
  return renderAwsWrapper(match.compositionNamespace);
543
548
  }
@@ -245,3 +245,34 @@ let scan = (~srcDir: string, ~exclude: array<string>): array<discoveredFile> =>
245
245
  walkDir(~dir=srcDir, ~relDir="", ~parentComponentType=None, ~epGroup=None, ~exclude, ~acc)
246
246
  acc
247
247
  }
248
+
249
+ /** The identity modules the plugin declares in its own `src/`: a `<Name>Id.res`
250
+ whose source calls `Id.Make(`. Named by module, so the generated call reads
251
+ each key off the compiled module (`CategoryId.key`) rather than parsing it
252
+ out of a string. Sorted, so the generated file is stable. */
253
+ let scanIdentities = (~srcDir: string, ~exclude: array<string>): array<string> => {
254
+ let found = []
255
+ let rec walk = (~dir, ~relDir) =>
256
+ Generator_Node.readDir(dir)->Array.forEach(entry => {
257
+ let name = entry->NodeFs.direntName
258
+ let relPath = relDir === "" ? name : relDir ++ "/" ++ name
259
+ let path = NodePath.join([dir, name])
260
+ if isExcluded(relPath, exclude) {
261
+ ()
262
+ } else if entry->NodeFs.isDirectory {
263
+ if !isAlwaysExcludedDir(name) {
264
+ walk(~dir=path, ~relDir=relPath)
265
+ }
266
+ } else {
267
+ switch stemOf(name) {
268
+ | Some(stem)
269
+ if stem->String.endsWith("Id") &&
270
+ path->NodeFs.readFileSync->String.includes("Id.Make(") =>
271
+ found->Array.push(stem)
272
+ | _ => ()
273
+ }
274
+ }
275
+ })
276
+ walk(~dir=srcDir, ~relDir="")
277
+ found->Array.toSorted(String.compare)
278
+ }
@@ -1,6 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Nodefs from "node:fs";
3
4
  import * as Nodepath from "node:path";
5
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
4
6
  import * as ComponentKind$Reventless from "../components/ComponentKind.res.mjs";
5
7
  import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
6
8
 
@@ -180,6 +182,34 @@ function scan(srcDir, exclude) {
180
182
  return acc;
181
183
  }
182
184
 
185
+ function scanIdentities(srcDir, exclude) {
186
+ let found = [];
187
+ let walk = (dir, relDir) => {
188
+ Generator_Node$Reventless.readDir(dir).forEach(entry => {
189
+ let name = entry.name;
190
+ let relPath = relDir === "" ? name : relDir + "/" + name;
191
+ let path = Nodepath.join(dir, name);
192
+ if (isExcluded(relPath, exclude)) {
193
+ return;
194
+ }
195
+ if (entry.isDirectory()) {
196
+ if (!isAlwaysExcludedDir(name)) {
197
+ return walk(path, relPath);
198
+ } else {
199
+ return;
200
+ }
201
+ }
202
+ let stem = stemOf(name);
203
+ if (stem !== undefined && stem.endsWith("Id") && Nodefs.readFileSync(path, "utf8").includes("Id.Make(")) {
204
+ found.push(stem);
205
+ return;
206
+ }
207
+ });
208
+ };
209
+ walk(srcDir, "");
210
+ return found.toSorted(Primitive_string.compare);
211
+ }
212
+
183
213
  let folderToComponentType = ComponentKind$Reventless.folderToKind;
184
214
 
185
215
  export {
@@ -194,5 +224,6 @@ export {
194
224
  collectFiles,
195
225
  walkDir,
196
226
  scan,
227
+ scanIdentities,
197
228
  }
198
- /* node:path Not a pure module */
229
+ /* node:fs Not a pure module */
@@ -37,7 +37,8 @@ let main = () =>
37
37
  // Sits at the src root rather than in a kind folder, so `Discovery` never
38
38
  // sees it and it is not a component. Its presence is the whole question.
39
39
  let hasLifecycleModel = NodePath.join([srcDir, "LifecycleModel.res"])->NodeFs.existsSync
40
- let source = Codegen.render(~config, ~resolved, ~discovered, ~hasLifecycleModel)
40
+ let identities = Discovery.scanIdentities(~srcDir, ~exclude=config.exclude)
41
+ let source = Codegen.render(~config, ~resolved, ~discovered, ~hasLifecycleModel, ~identities)
41
42
 
42
43
  let outputDir = switch variant {
43
44
  | Config.Composition => srcDir
@@ -81,7 +81,8 @@ function main() {
81
81
  let discovered = Discovery$Reventless.scan(srcDir, config_exclude);
82
82
  let resolved = Pairing$Reventless.resolve(discovered, srcDir);
83
83
  let hasLifecycleModel = Nodefs.existsSync(Nodepath.join(srcDir, "LifecycleModel.res"));
84
- let source = Codegen$Reventless.render(config, resolved, discovered, hasLifecycleModel);
84
+ let identities = Discovery$Reventless.scanIdentities(srcDir, config_exclude);
85
+ let source = Codegen$Reventless.render(config, resolved, discovered, hasLifecycleModel, identities);
85
86
  let outputDir;
86
87
  outputDir = typeof variant !== "object" ? srcDir : Nodepath.join(process.cwd(), "src");
87
88
  let pluginPath = Nodepath.join(outputDir, "Plugin.res");
@@ -185,7 +185,9 @@ let sortedUnique = (xs: array<string>): array<string> => {
185
185
  slice's setup routinely names other entities, and folding those into this
186
186
  row's state is how a scenario about a second order gets read as a scenario
187
187
  about the first one's. */
188
- type element = {name: string, values: array<(string, JSON.t)>}
188
+ /** `opaque` is a step the sidecar could not read (`givenEvents([added])`): it is
189
+ there, but its name is source text, not an element. */
190
+ type element = {name: string, values: array<(string, JSON.t)>, opaque?: bool}
189
191
 
190
192
  /** One scenario, reduced to what the harvest reads. `then` carries at most one
191
193
  step in every DSL here, so it is a kind and a payload rather than a list. */
@@ -220,12 +222,29 @@ let valuesOf = (step: dict<JSON.t>): array<(string, JSON.t)> =>
220
222
 
221
223
  let elementsOf = (steps: array<dict<JSON.t>>): array<element> =>
222
224
  steps->Array.filterMap(s =>
223
- s->getStr("element")->Option.map(name => {name: lastSegment(name), values: valuesOf(s)})
225
+ s
226
+ ->getStr("element")
227
+ ->Option.map(name => {
228
+ name: lastSegment(name),
229
+ values: valuesOf(s),
230
+ opaque: s->getStr("kind") == Some("opaque"),
231
+ })
224
232
  )
225
233
 
234
+ let isOpaque = (e: element) => e.opaque == Some(true)
235
+
226
236
  let kindOf = (steps: array<dict<JSON.t>>): string =>
227
237
  steps->Array.get(0)->Option.flatMap(s => s->getStr("kind"))->Option.getOr("")
228
238
 
239
+ /** A `then` the sidecar could not read still says which kind it asserts:
240
+ `thenEvent(added)` emitted, though nothing says what. A `when` keeps
241
+ `opaque`, so no command is named after a variable. */
242
+ let thenKindOf = (steps: array<dict<JSON.t>>): string =>
243
+ switch steps->Array.get(0) {
244
+ | Some(s) if s->getStr("kind") == Some("opaque") => s->getStr("of")->Option.getOr("")
245
+ | _ => kindOf(steps)
246
+ }
247
+
229
248
  let scenarioOf = (j: JSON.t): option<scenario> =>
230
249
  j
231
250
  ->asObj
@@ -238,7 +257,7 @@ let scenarioOf = (j: JSON.t): option<scenario> =>
238
257
  given: elementsOf(given),
239
258
  whenKind: kindOf(when_),
240
259
  whenElements: elementsOf(when_),
241
- thenKind: kindOf(then_),
260
+ thenKind: thenKindOf(then_),
242
261
  thenElements: elementsOf(then_),
243
262
  thenValues: then_->Array.get(0)->Option.map(valuesOf)->Option.getOr([]),
244
263
  }
@@ -323,34 +342,68 @@ let checkSidecars = (~pluginDirs: array<string>): result<unit, string> =>
323
342
  right for the ordinary invocations — this repository's gate, and `--root .`
324
343
  from an app — and wrong for a `--root` pointing somewhere else, which is why
325
344
  the caller checks for a corpus afterwards either way rather than trusting
326
- that a build it did not target wrote one. */
327
- let emitSidecars = (~pluginDirs: array<string>): result<unit, string> => {
328
- let now = Date.now() /. 1000.0
329
- gwtSources(~pluginDirs)->Array.forEach(f => NodeFs.utimesSync(f, now, now))
330
-
331
- let env =
332
- NodeProcess.env
333
- ->Dict.toArray
334
- ->Array.concat([("REVENTLESS_EMIT_SIDECAR", "1")])
335
- ->Dict.fromArray
336
-
337
- try {
338
- let _ = NodeChildProcess.execFileSync(
345
+ that a build it did not target wrote one.
346
+
347
+ An app laid out as one package per plugin has no root `build`; there the
348
+ harvest stops before running anything and names the way that does work. */
349
+ let reuseAdvice = "Build the packages with REVENTLESS_EMIT_SIDECAR=1 set, then run check-lifecycle --reuse-sidecars."
350
+
351
+ let hasBuildScript = (~dir: string): bool => {
352
+ let path = NodePath.join([dir, "package.json"])
353
+ NodeFs.existsSync(path) &&
354
+ switch path
355
+ ->NodeFs.readFileSync
356
+ ->JSON.parseOrThrow
357
+ ->asObj
358
+ ->Option.flatMap(d => d->Dict.get("scripts"))
359
+ ->Option.flatMap(asObj) {
360
+ | Some(scripts) => scripts->getStr("build")->Option.isSome
361
+ | None => false
362
+ | exception _ => false
363
+ }
364
+ }
365
+
366
+ let lastLines = (output: string, ~count: int): string => {
367
+ let lines = output->String.trimEnd->String.split("\n")
368
+ lines->Array.slice(~start=Math.Int.max(0, Array.length(lines) - count))->Array.join("\n")
369
+ }
370
+
371
+ let emitSidecars = (~pluginDirs: array<string>, ~dir=repoRoot): result<unit, string> =>
372
+ if !hasBuildScript(~dir) {
373
+ Error(
374
+ `${dir} has no "build" script, so there is no build to run with REVENTLESS_EMIT_SIDECAR=1. ${reuseAdvice}`,
375
+ )
376
+ } else {
377
+ let now = Date.now() /. 1000.0
378
+ gwtSources(~pluginDirs)->Array.forEach(f => NodeFs.utimesSync(f, now, now))
379
+
380
+ let env =
381
+ NodeProcess.env
382
+ ->Dict.toArray
383
+ ->Array.concat([("REVENTLESS_EMIT_SIDECAR", "1")])
384
+ ->Dict.fromArray
385
+
386
+ let result = NodeChildProcess.spawnSync(
339
387
  "pnpm",
340
388
  ["run", "build"],
341
- {cwd: repoRoot, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024},
342
- )
343
- Ok()
344
- } catch {
345
- | exn =>
346
- Error(
347
- exn
348
- ->JsExn.fromException
349
- ->Option.flatMap(JsExn.message)
350
- ->Option.getOr("the build that emits the scenario sidecars failed"),
389
+ {cwd: dir, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024},
351
390
  )
391
+ switch (result.error->Nullable.toOption, result.status->Nullable.toOption) {
392
+ | (None, Some(0)) => Ok()
393
+ | (Some(err), _) =>
394
+ Error(
395
+ `pnpm could not be started to build ${dir}: ${err
396
+ ->JsExn.message
397
+ ->Option.getOr("no reason given")}. ${reuseAdvice}`,
398
+ )
399
+ | (None, _) =>
400
+ let output =
401
+ [result.stdout, result.stderr]
402
+ ->Array.filterMap(Nullable.toOption)
403
+ ->Array.join("\n")
404
+ Error(`The build at ${dir} failed. ${reuseAdvice}\n\n${output->lastLines(~count=20)}`)
405
+ }
352
406
  }
353
- }
354
407
 
355
408
  // ── The declared side ───────────────────────────────────────────────────────
356
409
 
@@ -543,6 +596,9 @@ type observation = {
543
596
  from: string,
544
597
  outcome: outcome,
545
598
  to: string,
599
+ /** The setup events about this command's row. Zero is "no row before", which
600
+ a view with no lifecycle field can still say, having no states to fold. */
601
+ rowHistory: int,
546
602
  }
547
603
 
548
604
  type derivedCommand = {
@@ -571,8 +627,8 @@ type derivedCommand = {
571
627
  carry the model, and that is worth seeing before spending the change. */
572
628
  targets: array<string>,
573
629
  /** `""` where the corpus cannot say. Without a lifecycle map every history
574
- folds to "no row", which would make every command in the plugin look like
575
- it creates one — a confident wrong answer where the honest one is silence. */
630
+ folds to "no row", so the fold cannot answer; an empty history still can,
631
+ and a command that only ever succeeds on one is `Collection`. */
576
632
  level: string,
577
633
  scenarios: int,
578
634
  }
@@ -637,13 +693,18 @@ let observe = (
637
693
  let history = s.given->Array.filter(e => e->sameRow(~idField, ~idValue))
638
694
  let from = fold(history)
639
695
  let outcome = outcomeOf(s)
640
- Some({
641
- title: s.title,
642
- command: command.name,
643
- from,
644
- outcome,
645
- to: outcome == Emitted ? fold(Array.concat(history, s.thenElements)) : from,
646
- })
696
+ // A history holding a step nobody could read has a from-state nobody
697
+ // knows; reading it as "no row" makes an update look like a create.
698
+ history->Array.some(isOpaque)
699
+ ? None
700
+ : Some({
701
+ title: s.title,
702
+ command: command.name,
703
+ from,
704
+ outcome,
705
+ to: outcome == Emitted ? fold(Array.concat(history, s.thenElements)) : from,
706
+ rowHistory: Array.length(history),
707
+ })
647
708
  | _ => None
648
709
  }
649
710
  )
@@ -676,9 +737,12 @@ let deriveCommands = (
676
737
  // A command whose every successful scenario starts from no row is creating
677
738
  // one. This is what the published metadata guesses at today from the
678
739
  // command's name stem, which misreads `Enroll`, `Provision`, `Onboard`.
740
+ // Without labels only an empty history says "no row"; a non-empty one
741
+ // cannot tell a live row from events that fold to none, so it says nothing.
679
742
  level: switch (labelled, Array.length(effective)) {
680
- | (false, _) | (_, 0) => ""
743
+ | (_, 0) => ""
681
744
  | (true, _) => effective->Array.every(o => o.from == noRow) ? "Collection" : "Instance"
745
+ | (false, _) => effective->Array.every(o => o.rowHistory == 0) ? "Collection" : ""
682
746
  },
683
747
  scenarios: Array.length(mine),
684
748
  }
@@ -715,6 +779,7 @@ type commandOutcomes = {
715
779
 
716
780
  let shownOutcomeOf = (s: scenario): option<(string, array<string>)> =>
717
781
  switch s.thenKind {
782
+ | "event" | "error" if s.thenElements->Array.some(isOpaque) => None
718
783
  | "event" | "error" => Some((s.thenKind, s.thenElements->Array.map(e => e.name)))
719
784
  | "noEvent" => Some(("noEvent", []))
720
785
  // The empty `then` above, and a `then` that is not about this command's
@@ -9,10 +9,10 @@ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
9
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
10
  import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
11
11
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
12
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
12
13
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
13
14
  import * as CliArgs$Reventless from "../CliArgs.res.mjs";
14
15
  import * as Nodechild_process from "node:child_process";
15
- import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
16
16
  import * as ComponentKind$Reventless from "../components/ComponentKind.res.mjs";
17
17
  import * as LoadPluginStructureMjs from "./loadPluginStructure.mjs";
18
18
 
@@ -123,14 +123,28 @@ function valuesOf(step) {
123
123
  function elementsOf(steps) {
124
124
  return Stdlib_Array.filterMap(steps, s => Stdlib_Option.map(getStr(s, "element"), name => ({
125
125
  name: lastSegment(name),
126
- values: valuesOf(s)
126
+ values: valuesOf(s),
127
+ opaque: Primitive_object.equal(getStr(s, "kind"), "opaque")
127
128
  })));
128
129
  }
129
130
 
131
+ function isOpaque(e) {
132
+ return Primitive_object.equal(e.opaque, true);
133
+ }
134
+
130
135
  function kindOf(steps) {
131
136
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(steps[0], s => getStr(s, "kind")), "");
132
137
  }
133
138
 
139
+ function thenKindOf(steps) {
140
+ let s = steps[0];
141
+ if (s !== undefined && Primitive_object.equal(getStr(s, "kind"), "opaque")) {
142
+ return Stdlib_Option.getOr(getStr(s, "of"), "");
143
+ } else {
144
+ return kindOf(steps);
145
+ }
146
+ }
147
+
134
148
  function scenarioOf(j) {
135
149
  return Stdlib_Option.map(Stdlib_JSON.Decode.object(j), d => {
136
150
  let given = getObjs(d, "given");
@@ -141,7 +155,7 @@ function scenarioOf(j) {
141
155
  given: elementsOf(given),
142
156
  whenKind: kindOf(when_),
143
157
  whenElements: elementsOf(when_),
144
- thenKind: kindOf(then_),
158
+ thenKind: thenKindOf(then_),
145
159
  thenElements: elementsOf(then_),
146
160
  thenValues: Stdlib_Option.getOr(Stdlib_Option.map(then_[0], valuesOf), [])
147
161
  };
@@ -218,7 +232,39 @@ function checkSidecars(pluginDirs) {
218
232
  }
219
233
  }
220
234
 
221
- function emitSidecars(pluginDirs) {
235
+ let reuseAdvice = "Build the packages with REVENTLESS_EMIT_SIDECAR=1 set, then run check-lifecycle --reuse-sidecars.";
236
+
237
+ function hasBuildScript(dir) {
238
+ let path = Nodepath.join(dir, "package.json");
239
+ if (!Nodefs.existsSync(path)) {
240
+ return false;
241
+ }
242
+ let scripts;
243
+ try {
244
+ scripts = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(path, "utf8"))), d => d["scripts"]), asObj);
245
+ } catch (exn) {
246
+ return false;
247
+ }
248
+ if (scripts !== undefined) {
249
+ return Stdlib_Option.isSome(getStr(scripts, "build"));
250
+ } else {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ function lastLines(output, count) {
256
+ let lines = output.trimEnd().split("\n");
257
+ return lines.slice(Math.max(0, lines.length - count | 0)).join("\n");
258
+ }
259
+
260
+ function emitSidecars(pluginDirs, dirOpt) {
261
+ let dir = dirOpt !== undefined ? dirOpt : repoRoot;
262
+ if (!hasBuildScript(dir)) {
263
+ return {
264
+ TAG: "Error",
265
+ _0: dir + ` has no "build" script, so there is no build to run with REVENTLESS_EMIT_SIDECAR=1. ` + reuseAdvice
266
+ };
267
+ }
222
268
  let now = Date.now() / 1000.0;
223
269
  gwtSources(pluginDirs).forEach(f => {
224
270
  Nodefs.utimesSync(f, now, now);
@@ -227,27 +273,43 @@ function emitSidecars(pluginDirs) {
227
273
  "REVENTLESS_EMIT_SIDECAR",
228
274
  "1"
229
275
  ]]));
230
- try {
231
- Nodechild_process.execFileSync("pnpm", [
232
- "run",
233
- "build"
234
- ], {
235
- cwd: repoRoot,
236
- encoding: "utf8",
237
- env: env,
238
- maxBuffer: 268435456
239
- });
276
+ let result = Nodechild_process.spawnSync("pnpm", [
277
+ "run",
278
+ "build"
279
+ ], {
280
+ cwd: dir,
281
+ encoding: "utf8",
282
+ env: env,
283
+ maxBuffer: 268435456
284
+ });
285
+ let match = result.error;
286
+ let match$1 = result.status;
287
+ if (!(match == null)) {
240
288
  return {
241
- TAG: "Ok",
242
- _0: undefined
289
+ TAG: "Error",
290
+ _0: `pnpm could not be started to build ` + dir + `: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(match), "no reason given") + `. ` + reuseAdvice
243
291
  };
244
- } catch (raw_exn) {
245
- let exn = Primitive_exceptions.internalToException(raw_exn);
292
+ }
293
+ if (!(match$1 == null) && match$1 === 0) {
246
294
  return {
247
- TAG: "Error",
248
- _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "the build that emits the scenario sidecars failed")
295
+ TAG: "Ok",
296
+ _0: undefined
249
297
  };
250
298
  }
299
+ let output = Stdlib_Array.filterMap([
300
+ result.stdout,
301
+ result.stderr
302
+ ], prim => {
303
+ if (prim == null) {
304
+ return;
305
+ } else {
306
+ return Primitive_option.some(prim);
307
+ }
308
+ }).join("\n");
309
+ return {
310
+ TAG: "Error",
311
+ _0: `The build at ` + dir + ` failed. ` + reuseAdvice + `\n\n` + lastLines(output, 20)
312
+ };
251
313
  }
252
314
 
253
315
  function levelOf(d) {
@@ -449,13 +511,18 @@ function observe(scenarios, map, idFieldFor) {
449
511
  let history = s.given.filter(e => sameRow(e, idField, idValue));
450
512
  let from = fold(history);
451
513
  let outcome = outcomeOf(s);
452
- return {
453
- title: s.title,
454
- command: command.name,
455
- from: from,
456
- outcome: outcome,
457
- to: outcome === "Emitted" ? fold(history.concat(s.thenElements)) : from
458
- };
514
+ if (history.some(isOpaque)) {
515
+ return;
516
+ } else {
517
+ return {
518
+ title: s.title,
519
+ command: command.name,
520
+ from: from,
521
+ outcome: outcome,
522
+ to: outcome === "Emitted" ? fold(history.concat(s.thenElements)) : from,
523
+ rowHistory: history.length
524
+ };
525
+ }
459
526
  });
460
527
  }
461
528
 
@@ -494,8 +561,12 @@ function deriveCommands(component, observations, labelled) {
494
561
  return o.to;
495
562
  }
496
563
  })),
497
- level: labelled && match !== 0 ? (
498
- effective.every(o => o.from === noRow) ? "Collection" : "Instance"
564
+ level: match !== 0 ? (
565
+ labelled ? (
566
+ effective.every(o => o.from === noRow) ? "Collection" : "Instance"
567
+ ) : (
568
+ effective.every(o => o.rowHistory === 0) ? "Collection" : ""
569
+ )
499
570
  ) : "",
500
571
  scenarios: mine.length
501
572
  };
@@ -516,10 +587,14 @@ function shownOutcomeOf(s) {
516
587
  default:
517
588
  return;
518
589
  }
519
- return [
520
- s.thenKind,
521
- s.thenElements.map(e => e.name)
522
- ];
590
+ if (s.thenElements.some(isOpaque)) {
591
+ return;
592
+ } else {
593
+ return [
594
+ s.thenKind,
595
+ s.thenElements.map(e => e.name)
596
+ ];
597
+ }
523
598
  }
524
599
 
525
600
  function byPluginComponentCommand(xs) {
@@ -1149,7 +1224,7 @@ function main() {
1149
1224
  console.error(`no plugins found under ` + roots.map(r => r.dir).join(", ") + ` — a plugin is a directory with both src/Plugin.res and tests/`);
1150
1225
  process.exit(1);
1151
1226
  }
1152
- let msg = param.reuseSidecars ? checkSidecars(allPluginDirs) : emitSidecars(allPluginDirs);
1227
+ let msg = param.reuseSidecars ? checkSidecars(allPluginDirs) : emitSidecars(allPluginDirs, undefined);
1153
1228
  if (msg.TAG !== "Ok") {
1154
1229
  console.error(msg._0);
1155
1230
  process.exit(1);
@@ -1256,7 +1331,9 @@ export {
1256
1331
  sortedUnique,
1257
1332
  valuesOf,
1258
1333
  elementsOf,
1334
+ isOpaque,
1259
1335
  kindOf,
1336
+ thenKindOf,
1260
1337
  scenarioOf,
1261
1338
  readCorpus,
1262
1339
  filesUnder,
@@ -1264,6 +1341,9 @@ export {
1264
1341
  sidecarOf,
1265
1342
  hasCorpus,
1266
1343
  checkSidecars,
1344
+ reuseAdvice,
1345
+ hasBuildScript,
1346
+ lastLines,
1267
1347
  emitSidecars,
1268
1348
  levelOf,
1269
1349
  declaredCommandOf,