@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 +9 -0
- package/package.json +2 -2
- package/src/generator/Codegen.res +13 -1
- package/src/generator/Codegen.res.mjs +10 -5
- package/src/generator/Discovery.res +31 -0
- package/src/generator/Discovery.res.mjs +32 -1
- package/src/generator/PluginGenerator.res +2 -1
- package/src/generator/PluginGenerator.res.mjs +2 -1
- package/src/lifecycle/CheckLifecycleModel.res +102 -37
- package/src/lifecycle/CheckLifecycleModel.res.mjs +114 -34
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.
|
|
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.
|
|
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 =>
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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:
|
|
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",
|
|
575
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
| (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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: "
|
|
242
|
-
_0:
|
|
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
|
-
}
|
|
245
|
-
|
|
292
|
+
}
|
|
293
|
+
if (!(match$1 == null) && match$1 === 0) {
|
|
246
294
|
return {
|
|
247
|
-
TAG: "
|
|
248
|
-
_0:
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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:
|
|
498
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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,
|