@reventlessdev/reventless-spec 3.0.0-alpha.135 → 3.0.0-alpha.137

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.
@@ -10,6 +10,7 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
10
10
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
11
11
  import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
12
12
  import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
13
+ import * as ComponentKind$Reventless from "./ComponentKind.res.mjs";
13
14
  import * as DcbScopeInference$Reventless from "./DcbScopeInference.res.mjs";
14
15
 
15
16
  let tagSchema = Sury.$schema(s => ({
@@ -68,6 +69,10 @@ let derivedPartitionTagSchema = Sury.union([
68
69
  Sury.$schema(s => ({
69
70
  TAG: "Composite",
70
71
  _0: s.m(compositePartitionSpecSchema)
72
+ })),
73
+ Sury.$schema(s => ({
74
+ TAG: "ByEventType",
75
+ _0: s.m(Sury.dict(Sury.string))
71
76
  }))
72
77
  ]);
73
78
 
@@ -789,7 +794,23 @@ function extractPartitionTagFields(schema) {
789
794
  }
790
795
  }
791
796
 
792
- function sliceShapeFromSchemas(name, commandSchema, consumedEventSchema, eventSchema) {
797
+ function chapterOfModuleUrl(moduleUrl) {
798
+ let parts = moduleUrl.split("/src/");
799
+ let len = parts.length;
800
+ if (len === 1) {
801
+ return;
802
+ }
803
+ if (len === 0) {
804
+ return;
805
+ }
806
+ let segments = parts[parts.length - 1 | 0].split("/");
807
+ let first = segments[0];
808
+ if (first !== undefined && segments.length > 1 && !ComponentKind$Reventless.isKindFolder(first)) {
809
+ return first;
810
+ }
811
+ }
812
+
813
+ function sliceShapeFromSchemas(name, commandSchema, consumedEventSchema, eventSchema, moduleUrl) {
793
814
  let match = extractPartitionTagFields(eventSchema);
794
815
  let partitionHint = match.length !== 1 ? undefined : match[0];
795
816
  return {
@@ -797,10 +818,15 @@ function sliceShapeFromSchemas(name, commandSchema, consumedEventSchema, eventSc
797
818
  command: eventShapesOfSchema(commandSchema).flatMap(e => e.idFields),
798
819
  consumed: eventShapesOfSchema(consumedEventSchema),
799
820
  produced: eventShapesOfSchema(eventSchema),
800
- partitionHint: partitionHint
821
+ partitionHint: partitionHint,
822
+ chapter: Stdlib_Option.flatMap(moduleUrl, chapterOfModuleUrl)
801
823
  };
802
824
  }
803
825
 
826
+ function sliceShape(s) {
827
+ return sliceShapeFromSchemas(s.name, s.commandSchema, s.consumedEventSchema, s.eventSchema, s.moduleUrl);
828
+ }
829
+
804
830
  function deriveEffectiveScope(slices) {
805
831
  let producedSchemas = slices.map(s => s.eventSchema);
806
832
  let seen = new Set();
@@ -813,8 +839,7 @@ function deriveEffectiveScope(slices) {
813
839
  }
814
840
  });
815
841
  let annotatedTagKeys = mergeTagKeysByEventType(producedSchemas.map(extractTagKeysByEventType));
816
- let shapes = slices.map(s => sliceShapeFromSchemas(s.name, s.commandSchema, s.consumedEventSchema, s.eventSchema));
817
- let inferred = DcbScopeInference$Reventless.infer(shapes);
842
+ let inferred = DcbScopeInference$Reventless.infer(slices.map(sliceShape));
818
843
  let useInferred = inferred.ambiguities.length === 0;
819
844
  return {
820
845
  crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
@@ -824,46 +849,6 @@ function deriveEffectiveScope(slices) {
824
849
  };
825
850
  }
826
851
 
827
- function hasMultiTagVariant(schema) {
828
- switch (schema.type) {
829
- case "object" :
830
- let tagCount = Object.entries(schema.properties).filter(param => Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbTagId))).length;
831
- return tagCount > 1;
832
- case "anyOf" :
833
- return schema.anyOf.some(variantSchema => {
834
- if (variantSchema.type !== "object") {
835
- return false;
836
- }
837
- let tagCount = Object.entries(variantSchema.properties).filter(param => Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbTagId))).length;
838
- return tagCount > 1;
839
- });
840
- default:
841
- return false;
842
- }
843
- }
844
-
845
- function findMultiTagVariantNames(schema) {
846
- let variantName = variantSchema => {
847
- if (variantSchema.type !== "object") {
848
- return;
849
- }
850
- let properties = variantSchema.properties;
851
- let tagCount = Object.entries(properties).filter(param => Stdlib_Option.isSome(Sury.$Metadata_get(param[1], dcbTagId))).length;
852
- if (tagCount > 1) {
853
- return Stdlib_Option.getOr(variantTagName(properties), "(unknown)");
854
- }
855
- };
856
- if (schema.type === "anyOf") {
857
- return Stdlib_Array.filterMap(schema.anyOf, variantName);
858
- }
859
- let name = variantName(schema);
860
- if (name !== undefined) {
861
- return [name];
862
- } else {
863
- return [];
864
- }
865
- }
866
-
867
852
  function extractCompositePartitionFieldsFromProperties(properties) {
868
853
  return Stdlib_Array.filterMap(Object.entries(properties), param => {
869
854
  let meta = Sury.$Metadata_get(param[1], dcbCompositePartitionMemberId);
@@ -918,98 +903,118 @@ function getCompositePartitionKeyValue(tags, spec) {
918
903
  }).join("");
919
904
  }
920
905
 
921
- function derivePartitionTag(namedSchemas) {
922
- let schemas = namedSchemas.map(param => param[2]);
923
- let seen = new Set();
924
- let allCompositeFields = schemas.flatMap(extractCompositePartitionFields).filter(info => {
925
- if (seen.has(info.name)) {
926
- return false;
927
- } else {
928
- seen.add(info.name);
929
- return true;
930
- }
931
- });
932
- let hasComposite = allCompositeFields.length !== 0;
933
- let seen$1 = new Set();
934
- let allPartitionFields = schemas.flatMap(extractPartitionTagFields).filter(f => {
935
- if (seen$1.has(f)) {
936
- return false;
937
- } else {
938
- seen$1.add(f);
939
- return true;
940
- }
941
- });
942
- if (hasComposite && allPartitionFields.length !== 0) {
943
- Stdlib_JsError.throwWithMessage(`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`);
906
+ function compositePartitionOf(schemas) {
907
+ let dedupe = (items, keyOf) => {
908
+ let seen = new Set();
909
+ return items.filter(item => {
910
+ let k = keyOf(item);
911
+ if (seen.has(k)) {
912
+ return false;
913
+ } else {
914
+ seen.add(k);
915
+ return true;
916
+ }
917
+ });
918
+ };
919
+ let compositeFields = dedupe(schemas.flatMap(extractCompositePartitionFields), info => info.name);
920
+ let partitionFields = dedupe(schemas.flatMap(extractPartitionTagFields), f => f);
921
+ if (compositeFields.length === 0) {
922
+ return;
944
923
  }
945
- if (hasComposite) {
946
- if (allCompositeFields.length < 2) {
947
- Stdlib_JsError.throwWithMessage(`@compositePartitionTag requires at least 2 annotated fields — only ` + allCompositeFields.length.toString() + ` found`);
948
- }
949
- let sorted = allCompositeFields.toSorted((a, b) => Primitive_int.compare(a.position, b.position));
950
- let keys = sorted.map(info => info.name);
951
- let seps = sorted.slice(0, sorted.length - 1 | 0).map(info => info.sep);
924
+ if (partitionFields.length !== 0) {
925
+ return Stdlib_JsError.throwWithMessage(`DCB spec mixes @compositePartitionTag and @partitionTag — use one strategy per schema`);
926
+ }
927
+ if (compositeFields.length === 1) {
928
+ return Stdlib_JsError.throwWithMessage(`@compositePartitionTag requires at least 2 annotated fields — only 1 found`);
929
+ }
930
+ let sorted = compositeFields.toSorted((a, b) => Primitive_int.compare(a.position, b.position));
931
+ return {
932
+ keys: sorted.map(info => info.name),
933
+ seps: sorted.slice(0, sorted.length - 1 | 0).map(info => info.sep)
934
+ };
935
+ }
936
+
937
+ function deriveBoundaryPartition(slices) {
938
+ let spec = compositePartitionOf(slices.map(s => s.eventSchema));
939
+ if (spec !== undefined) {
952
940
  return {
953
- TAG: "Composite",
954
- _0: {
955
- keys: keys,
956
- seps: seps
941
+ partitionBySlice: {},
942
+ partitionTag: {
943
+ TAG: "Composite",
944
+ _0: spec
957
945
  }
958
946
  };
959
947
  }
960
- let seen$2 = new Set();
961
- let allTaggedFields = schemas.flatMap(extractTaggedFields).filter(f => {
962
- if (seen$2.has(f)) {
963
- return false;
964
- } else {
965
- seen$2.add(f);
966
- return true;
967
- }
948
+ let resolution = DcbScopeInference$Reventless.resolvePartitions(slices.map(sliceShape));
949
+ if (resolution.ambiguities.length !== 0) {
950
+ Stdlib_JsError.throwWithMessage(`DCB partition key cannot be inferred — ` + resolution.ambiguities.map(param => param[0] + `: ` + param[1]).join(" | "));
951
+ }
952
+ let byEventType = {};
953
+ slices.forEach(s => {
954
+ let key = resolution.partitionBySlice[s.name];
955
+ Object.entries(extractTagKeysByEventType(s.eventSchema)).forEach(param => {
956
+ let tagKeys = param[1];
957
+ let eventType = param[0];
958
+ if (tagKeys.length !== 0 && !tagKeys.includes(key)) {
959
+ Stdlib_JsError.throwWithMessage(`DCB slice ` + s.name + ` is partitioned by ` + key + `, but its event ` + eventType + ` carries no ` + key + ` tag (it carries ` + tagKeys.join(", ") + `) — add ` + key + ` to the event, or declare the partition with @partitionTag`);
960
+ }
961
+ let other = byEventType[eventType];
962
+ if (other !== undefined && other !== key) {
963
+ return Stdlib_JsError.throwWithMessage(`DCB event ` + eventType + ` is written under two partition keys (` + other + `, ` + key + `) — every slice writing it must be partitioned by the same key`);
964
+ } else {
965
+ byEventType[eventType] = key;
966
+ return;
967
+ }
968
+ });
968
969
  });
969
- let len = allTaggedFields.length;
970
- if (len !== 1) {
971
- if (len === 0) {
972
- return Stdlib_JsError.throwWithMessage("DCB spec has no tagged fields — cannot derive partition tag");
970
+ return {
971
+ partitionBySlice: resolution.partitionBySlice,
972
+ partitionTag: {
973
+ TAG: "ByEventType",
974
+ _0: byEventType
973
975
  }
974
- let needsExplicitPartition = schemas.some(hasMultiTagVariant);
975
- if (needsExplicitPartition) {
976
- let context = Stdlib_Array.filterMap(namedSchemas, param => {
977
- let variantNames = findMultiTagVariantNames(param[2]);
978
- if (variantNames.length !== 0) {
979
- return param[0] + ` (` + variantNames.join(", ") + `) @ ` + param[1];
980
- }
981
- }).join(", ");
982
- let len$1 = allPartitionFields.length;
983
- if (len$1 !== 1) {
984
- if (len$1 !== 0) {
985
- return Stdlib_JsError.throwWithMessage(`DCB spec has multiple fields annotated with @partitionTag (` + allPartitionFields.join(", ") + `) — only one is allowed — affected: ` + context);
986
- } else {
987
- return Stdlib_JsError.throwWithMessage(`DCB spec has variants with multiple tagged fields (` + allTaggedFields.join(", ") + `) but none is annotated with @partitionTag — affected: ` + context + ` — mark one field as the partition key`);
988
- }
989
- }
990
- let singlePartition = allPartitionFields[0];
991
- return {
976
+ };
977
+ }
978
+
979
+ function slicePartitionTag(bp, sliceName) {
980
+ let other = bp.partitionTag;
981
+ switch (other.TAG) {
982
+ case "Simple" :
983
+ case "Composite" :
984
+ return other;
985
+ case "ByEventType" :
986
+ return Stdlib_Option.map(bp.partitionBySlice[sliceName], key => ({
992
987
  TAG: "Simple",
993
988
  _0: {
994
- key: singlePartition
989
+ key: key
995
990
  }
996
- };
997
- }
998
- let sorted$1 = allTaggedFields.toSorted(Primitive_string.compare);
999
- return {
1000
- TAG: "Simple",
1001
- _0: {
1002
- key: sorted$1[0]
1003
- }
1004
- };
991
+ }));
992
+ }
993
+ }
994
+
995
+ function deriveSlicePartition(slice) {
996
+ let tag = slicePartitionTag(deriveBoundaryPartition([slice]), slice.name);
997
+ if (tag !== undefined) {
998
+ return tag;
999
+ } else {
1000
+ return Stdlib_JsError.throwWithMessage(`DCB slice ` + slice.name + ` has no partition key`);
1001
+ }
1002
+ }
1003
+
1004
+ function partitionValueOfTags(tags, pt) {
1005
+ switch (pt.TAG) {
1006
+ case "Simple" :
1007
+ let key = pt._0.key;
1008
+ return Stdlib_Option.getOr(Stdlib_Array.findMap(tags, t => {
1009
+ if (t.key === key) {
1010
+ return t.value;
1011
+ }
1012
+ }), "");
1013
+ case "Composite" :
1014
+ return getCompositePartitionKeyValue(tags, pt._0);
1015
+ case "ByEventType" :
1016
+ return "";
1005
1017
  }
1006
- let singleField = allTaggedFields[0];
1007
- return {
1008
- TAG: "Simple",
1009
- _0: {
1010
- key: singleField
1011
- }
1012
- };
1013
1018
  }
1014
1019
 
1015
1020
  function getPartitionTagValue(query, pt) {
@@ -1081,14 +1086,18 @@ export {
1081
1086
  idFieldsOfProperties,
1082
1087
  eventShapesOfSchema,
1083
1088
  extractPartitionTagFields,
1089
+ chapterOfModuleUrl,
1084
1090
  sliceShapeFromSchemas,
1091
+ sliceShape,
1085
1092
  deriveEffectiveScope,
1086
- hasMultiTagVariant,
1087
- findMultiTagVariantNames,
1088
1093
  extractCompositePartitionFieldsFromProperties,
1089
1094
  extractCompositePartitionFields,
1090
1095
  getCompositePartitionKeyValue,
1091
- derivePartitionTag,
1096
+ compositePartitionOf,
1097
+ deriveBoundaryPartition,
1098
+ slicePartitionTag,
1099
+ deriveSlicePartition,
1100
+ partitionValueOfTags,
1092
1101
  getPartitionTagValue,
1093
1102
  }
1094
1103
  /* tagSchema Not a pure module */
@@ -481,3 +481,53 @@ let validateScopeVsInference = (
481
481
  )
482
482
  {contradictions, redundancies}
483
483
  }
484
+
485
+ /**
486
+ Checks each `@partitionTag` against what inference derives with that hint removed.
487
+
488
+ - **Contradiction** — inference resolves the slice to a different key, or the
489
+ hint names a key the slice only reads as a reference to another entity. Storage,
490
+ fence and read scope would all follow the wrong key.
491
+ - **Redundant** — inference reaches the same key unaided; the annotation can go.
492
+
493
+ A hint that names a key the slice's events do not carry is not checked here:
494
+ inference ignores it, and the boundary derivation reports the slice instead.
495
+ */
496
+ let validatePartitionHintsVsInference = (
497
+ ~shapes: array<DcbScopeInference.sliceShape>,
498
+ ): scopeInferenceIssues => {
499
+ let contradictions: array<validationError> = []
500
+ let redundancies: array<validationError> = []
501
+ shapes->Array.forEach(s =>
502
+ switch s.partitionHint {
503
+ | Some(hint) if DcbScopeInference.producedKeys(s)->Array.includes(hint) =>
504
+ let unaided = DcbScopeInference.resolvePartitions(
505
+ shapes->Array.map(o => o.sliceName == s.sliceName ? {...o, partitionHint: None} : o),
506
+ )
507
+ switch unaided.partitionBySlice->Dict.get(s.sliceName) {
508
+ | Some(inferred) if inferred == hint =>
509
+ redundancies->Array.push({
510
+ sliceName: s.sliceName,
511
+ message: `@partitionTag ${hint} is what inference derives without it — the annotation is redundant and can be removed.`,
512
+ })
513
+ | Some(inferred) =>
514
+ contradictions->Array.push({
515
+ sliceName: s.sliceName,
516
+ message: `@partitionTag names ${hint}, but inference derives ${inferred} from the slice graph — ${hint} is read from another entity. Remove the annotation, or move it to ${inferred}.`,
517
+ })
518
+ | None =>
519
+ let candidates = unaided.candidatesBySlice->Dict.get(s.sliceName)->Option.getOr([])
520
+ if !(candidates->Array.includes(hint)) {
521
+ contradictions->Array.push({
522
+ sliceName: s.sliceName,
523
+ message: `@partitionTag names ${hint}, which this slice only reads as a reference to another entity (candidates: ${candidates->Array.join(
524
+ ", ",
525
+ )}). Move the annotation to the slice's own key.`,
526
+ })
527
+ }
528
+ }
529
+ | _ => ()
530
+ }
531
+ )
532
+ {contradictions, redundancies}
533
+ }
@@ -5,6 +5,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
7
  import * as DcbTag$Reventless from "./DcbTag.res.mjs";
8
+ import * as DcbScopeInference$Reventless from "./DcbScopeInference.res.mjs";
8
9
 
9
10
  function extractVariantInfo(variantSchema) {
10
11
  switch (variantSchema.type) {
@@ -425,6 +426,55 @@ function validateScopeVsInference(annotations, inferred) {
425
426
  };
426
427
  }
427
428
 
429
+ function validatePartitionHintsVsInference(shapes) {
430
+ let contradictions = [];
431
+ let redundancies = [];
432
+ shapes.forEach(s => {
433
+ let hint = s.partitionHint;
434
+ if (hint === undefined) {
435
+ return;
436
+ }
437
+ if (!DcbScopeInference$Reventless.producedKeys(s).includes(hint)) {
438
+ return;
439
+ }
440
+ let unaided = DcbScopeInference$Reventless.resolvePartitions(shapes.map(o => {
441
+ if (o.sliceName !== s.sliceName) {
442
+ return o;
443
+ }
444
+ let newrecord = {...o};
445
+ newrecord.partitionHint = undefined;
446
+ return newrecord;
447
+ }));
448
+ let inferred = unaided.partitionBySlice[s.sliceName];
449
+ if (inferred !== undefined) {
450
+ if (inferred === hint) {
451
+ redundancies.push({
452
+ sliceName: s.sliceName,
453
+ message: `@partitionTag ` + hint + ` is what inference derives without it — the annotation is redundant and can be removed.`
454
+ });
455
+ } else {
456
+ contradictions.push({
457
+ sliceName: s.sliceName,
458
+ message: `@partitionTag names ` + hint + `, but inference derives ` + inferred + ` from the slice graph — ` + hint + ` is read from another entity. Remove the annotation, or move it to ` + inferred + `.`
459
+ });
460
+ }
461
+ return;
462
+ }
463
+ let candidates = Stdlib_Option.getOr(unaided.candidatesBySlice[s.sliceName], []);
464
+ if (!candidates.includes(hint)) {
465
+ contradictions.push({
466
+ sliceName: s.sliceName,
467
+ message: `@partitionTag names ` + hint + `, which this slice only reads as a reference to another entity (candidates: ` + candidates.join(", ") + `). Move the annotation to the slice's own key.`
468
+ });
469
+ return;
470
+ }
471
+ });
472
+ return {
473
+ contradictions: contradictions,
474
+ redundancies: redundancies
475
+ };
476
+ }
477
+
428
478
  export {
429
479
  extractVariantInfo,
430
480
  extractAllVariants,
@@ -438,5 +488,6 @@ export {
438
488
  validateProducedAndConsumed,
439
489
  validateCrossPartitionScope,
440
490
  validateScopeVsInference,
491
+ validatePartitionHintsVsInference,
441
492
  }
442
493
  /* DcbTag-Reventless Not a pure module */
@@ -638,7 +638,8 @@ let renderComposition = (
638
638
  `name: ${stem}.name, ` ++
639
639
  `commandSchema: ${stem}.commandSchema->S.castToUnknown, ` ++
640
640
  `consumedEventSchema: ${stem}.consumedEventSchema->S.castToUnknown, ` ++
641
- `eventSchema: ${stem}.eventSchema->S.castToUnknown` ++ "},",
641
+ `eventSchema: ${stem}.eventSchema->S.castToUnknown, ` ++
642
+ `moduleUrl: ${stem}.moduleUrl` ++ "},",
642
643
  )
643
644
  )
644
645
  lines->Array.push("]")
@@ -406,7 +406,7 @@ function renderComposition(config, resolved, componentChapters, hasLifecycleMode
406
406
  lines.push("");
407
407
  lines.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [");
408
408
  resolved.stateChangeSlices.forEach(stem => {
409
- lines.push(" {" + (`name: ` + stem + `.name, `) + (`commandSchema: ` + stem + `.commandSchema->S.castToUnknown, `) + (`consumedEventSchema: ` + stem + `.consumedEventSchema->S.castToUnknown, `) + (`eventSchema: ` + stem + `.eventSchema->S.castToUnknown`) + "},");
409
+ lines.push(" {" + (`name: ` + stem + `.name, `) + (`commandSchema: ` + stem + `.commandSchema->S.castToUnknown, `) + (`consumedEventSchema: ` + stem + `.consumedEventSchema->S.castToUnknown, `) + (`eventSchema: ` + stem + `.eventSchema->S.castToUnknown, `) + (`moduleUrl: ` + stem + `.moduleUrl`) + "},");
410
410
  });
411
411
  lines.push("]");
412
412
  }
@@ -681,6 +681,90 @@ let deriveCommands = (
681
681
  })
682
682
  }
683
683
 
684
+ // ── What each command yields ────────────────────────────────────────────────
685
+
686
+ /** Which command yields which event or error, as its scenarios show it — what a
687
+ consumer draws a command's outcome arrows from, read off the corpus rather
688
+ than guessed from names. It needs no lifecycle labelling, so every corpus a
689
+ plugin has is read, though only its command scenarios count.
690
+
691
+ An empty `then` is skipped rather than read as "accepted, nothing happened".
692
+ The PPX currently writes `then: []` for `thenNoEvent`, so an empty `then` is
693
+ indistinguishable from one the sidecar could not read, and an outcome shown
694
+ here has to be one a scenario actually states. */
695
+ type shownOutcome = {
696
+ kind: string, // "event" | "error" | "noEvent"
697
+ /** The whole `then`, in order: `thenEvents([A, B])` is one outcome of two
698
+ events — both, not either. Empty for `noEvent`. */
699
+ names: array<string>,
700
+ /** The title of every scenario showing this outcome, in corpus order. */
701
+ scenarios: array<string>,
702
+ }
703
+
704
+ type commandOutcomes = {
705
+ plugin: string,
706
+ component: string,
707
+ command: string,
708
+ /** One per distinct outcome, in order of first appearance. */
709
+ outcomes: array<shownOutcome>,
710
+ }
711
+
712
+ let shownOutcomeOf = (s: scenario): option<(string, array<string>)> =>
713
+ switch s.thenKind {
714
+ | "event" | "error" => Some((s.thenKind, s.thenElements->Array.map(e => e.name)))
715
+ | "noEvent" => Some(("noEvent", []))
716
+ // The empty `then` above, and a `then` that is not about this command's
717
+ // result — a side effect, a published command.
718
+ | _ => None
719
+ }
720
+
721
+ let byPluginComponentCommand = (xs: array<commandOutcomes>): array<commandOutcomes> =>
722
+ xs->Array.toSorted((a, b) =>
723
+ switch String.compare(a.plugin, b.plugin) {
724
+ | 0. =>
725
+ switch String.compare(a.component, b.component) {
726
+ | 0. => String.compare(a.command, b.command)
727
+ | c => c
728
+ }
729
+ | c => c
730
+ }
731
+ )
732
+
733
+ /** A command with no readable outcome is left out: an entry must say something. */
734
+ let commandOutcomes = (~plugin: string, ~corpora: array<corpus>): array<commandOutcomes> => {
735
+ let entries: array<commandOutcomes> = []
736
+ corpora->Array.forEach(c =>
737
+ c.scenarios->Array.forEach(s =>
738
+ switch (s.whenKind, s.whenElements->Array.get(0), shownOutcomeOf(s)) {
739
+ | ("command", Some(command), Some((kind, names))) =>
740
+ let entry = switch entries->Array.find(
741
+ e => e.component == c.component && e.command == command.name,
742
+ ) {
743
+ | Some(e) => e
744
+ | None =>
745
+ let e: commandOutcomes = {
746
+ plugin,
747
+ component: c.component,
748
+ command: command.name,
749
+ outcomes: [],
750
+ }
751
+ entries->Array.push(e)
752
+ e
753
+ }
754
+ switch entry.outcomes->Array.find(o => o.kind == kind && o.names == names) {
755
+ | Some(o) =>
756
+ if !(o.scenarios->Array.includes(s.title)) {
757
+ o.scenarios->Array.push(s.title)
758
+ }
759
+ | None => entry.outcomes->Array.push({kind, names, scenarios: [s.title]})
760
+ }
761
+ | _ => ()
762
+ }
763
+ )
764
+ )
765
+ entries->byPluginComponentCommand
766
+ }
767
+
684
768
  // ── The three verdicts ──────────────────────────────────────────────────────
685
769
 
686
770
  type finding = {
@@ -953,6 +1037,7 @@ let runPlugin = async (
953
1037
  ~pluginDir: string,
954
1038
  ~findings: array<finding>,
955
1039
  ~opaque: array<opaque>,
1040
+ ~outcomes: array<commandOutcomes>,
956
1041
  ): result<array<derivedCommand>, string> =>
957
1042
  switch await readDeclared(~pluginDir) {
958
1043
  | Error(msg) => Error(msg)
@@ -962,6 +1047,8 @@ let runPlugin = async (
962
1047
  readCorpus,
963
1048
  )
964
1049
 
1050
+ outcomes->Array.pushMany(commandOutcomes(~plugin, ~corpora))
1051
+
965
1052
  // Every corpus, not only the ones the walk goes on to use: the kinds that
966
1053
  // are unreadable in full — extension points, automation and translation
967
1054
  // slices — are exactly the ones that sit outside the view/writable folders
@@ -1131,13 +1218,16 @@ let goldenPath = (~root: appRoot) => NodePath.join([root.dir, "schema", "lifecyc
1131
1218
  ever exercised, which is a sharper "missing scenario" than a verdict.
1132
1219
  - `opaque` — the corpora the walk cannot read, so a consumer can say "not
1133
1220
  analysed" where it would otherwise say "not covered".
1221
+ - `outcomes` — which command yields which event or error, and the scenarios
1222
+ that show it, sorted by plugin, component and command.
1134
1223
 
1135
- `str` is the schema's own version, bumped when a consumer would have to
1136
- change. */
1224
+ `version` is the schema's own version, bumped when a consumer would have to
1225
+ change. An added section is not such a change. */
1137
1226
  let reportJson = (
1138
1227
  ~findings: array<finding>,
1139
1228
  ~opaque: array<opaque>,
1140
1229
  ~derived: array<(string, derivedCommand)>,
1230
+ ~outcomes: array<commandOutcomes>,
1141
1231
  ~failures: array<string>,
1142
1232
  ): string => {
1143
1233
  let strs = xs => JSON.Encode.array(xs->Array.map(JSON.Encode.string))
@@ -1174,11 +1264,30 @@ let reportJson = (
1174
1264
  ("unreadable", JSON.Encode.int(o.unreadable)),
1175
1265
  ])
1176
1266
 
1267
+ let shownOutcomeJson = (o: shownOutcome) =>
1268
+ obj([
1269
+ ("kind", JSON.Encode.string(o.kind)),
1270
+ ("names", strs(o.names)),
1271
+ ("scenarios", strs(o.scenarios)),
1272
+ ])
1273
+
1274
+ let commandOutcomesJson = (e: commandOutcomes) =>
1275
+ obj([
1276
+ ("plugin", JSON.Encode.string(e.plugin)),
1277
+ ("component", JSON.Encode.string(e.component)),
1278
+ ("command", JSON.Encode.string(e.command)),
1279
+ ("outcomes", JSON.Encode.array(e.outcomes->Array.map(shownOutcomeJson))),
1280
+ ])
1281
+
1177
1282
  JSON.stringify(
1178
1283
  obj([
1179
1284
  ("version", JSON.Encode.int(1)),
1180
1285
  ("findings", JSON.Encode.array(findings->Array.map(findingJson))),
1181
1286
  ("commands", JSON.Encode.array(derived->Array.map(commandJson))),
1287
+ (
1288
+ "outcomes",
1289
+ JSON.Encode.array(outcomes->byPluginComponentCommand->Array.map(commandOutcomesJson)),
1290
+ ),
1182
1291
  ("opaque", JSON.Encode.array(opaque->Array.map(opaqueJson))),
1183
1292
  ("unreadable", strs(failures)),
1184
1293
  ]),
@@ -1270,6 +1379,7 @@ let writeOrCompare = (~path: string, ~actual: string, ~label: string, ~drifted:
1270
1379
  let main = async () => {
1271
1380
  let findings = []
1272
1381
  let opaque = []
1382
+ let outcomes = []
1273
1383
  let failures = []
1274
1384
  let drifted = []
1275
1385
  let allDerived = []
@@ -1327,7 +1437,7 @@ let main = async () => {
1327
1437
  | Some(pluginDir) =>
1328
1438
  let plugin = NodePath.basename(pluginDir)
1329
1439
  let qualified = `${example}/${plugin}`
1330
- switch await runPlugin(~plugin=qualified, ~pluginDir, ~findings, ~opaque) {
1440
+ switch await runPlugin(~plugin=qualified, ~pluginDir, ~findings, ~opaque, ~outcomes) {
1331
1441
  | Ok(commands) =>
1332
1442
  commands->Array.forEach(c => {
1333
1443
  derived->Array.push(c)
@@ -1370,7 +1480,7 @@ let main = async () => {
1370
1480
  let contradicted = of_("contradicted")
1371
1481
 
1372
1482
  if json {
1373
- Console.log(reportJson(~findings, ~opaque, ~derived=allDerived, ~failures))
1483
+ Console.log(reportJson(~findings, ~opaque, ~derived=allDerived, ~outcomes, ~failures))
1374
1484
  } else {
1375
1485
  ["contradicted", "unverified", "undeclared", "level", "ambiguous"]->Array.forEach(severity => {
1376
1486
  let group = of_(severity)