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

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/package.json +4 -3
  3. package/run-check-lifecycle.mjs +2 -1
  4. package/run-prepare-accounts.mjs +3 -0
  5. package/src/AnsiStyle.res +0 -1
  6. package/src/LogPrefix.res +4 -13
  7. package/src/components/AutomationSlice.res +1 -5
  8. package/src/components/CapabilityManifest.res +6 -3
  9. package/src/components/CapabilityManifest.res.mjs +13 -2
  10. package/src/components/DcbDecode.res +6 -2
  11. package/src/components/DcbScopeInference.res +16 -12
  12. package/src/components/DcbTag.res +74 -46
  13. package/src/components/DcbValidation.res +114 -89
  14. package/src/components/DisplayName.res +4 -2
  15. package/src/components/ExtensionPoint.res +0 -1
  16. package/src/components/FieldDefault.res +2 -4
  17. package/src/components/InboundTranslationSlice.res +0 -2
  18. package/src/components/OutboundTranslationSlice.res +10 -8
  19. package/src/components/Owner.res +4 -4
  20. package/src/components/Plugin.res +10 -4
  21. package/src/components/ReadModel.res +0 -1
  22. package/src/components/Reference.res +2 -8
  23. package/src/components/Sensitive.res +4 -4
  24. package/src/components/StateAnnotations.res +4 -2
  25. package/src/components/StateChangeSlice.res +0 -2
  26. package/src/components/StateViewSlice.res +0 -2
  27. package/src/components/TaggedUnion.res +1 -2
  28. package/src/components/Task.res +0 -1
  29. package/src/components/TraitCertificate.res +2 -2
  30. package/src/components/TraitManifest.res +1 -1
  31. package/src/generator/CertifyTrait.res +2 -4
  32. package/src/generator/Codegen.res +77 -68
  33. package/src/generator/Discovery.res +29 -12
  34. package/src/generator/GraftTrait.res +17 -9
  35. package/src/generator/Pairing.res +39 -13
  36. package/src/generator/PlatformCodegen.res +14 -11
  37. package/src/generator/PlatformCodegen.res.mjs +5 -0
  38. package/src/generator/PlatformManifests.res +1 -3
  39. package/src/generator/PluginGenerator.res +0 -1
  40. package/src/generator/TraitManifestCli.res +7 -7
  41. package/src/lifecycle/CheckLifecycleModel.res +206 -148
  42. package/src/lifecycle/CheckLifecycleModel.res.mjs +23 -5
  43. package/src/semantic/Bytes.res +0 -1
  44. package/src/semantic/CalendarDate.res +2 -2
  45. package/src/semantic/Capabilities.res +21 -6
  46. package/src/semantic/Capabilities.res.mjs +18 -12
  47. package/src/semantic/CapabilityNeed.res +16 -5
  48. package/src/semantic/CapabilityNeed.res.mjs +9 -4
  49. package/src/semantic/CaptionedImage.res +0 -1
  50. package/src/semantic/Color.res +0 -1
  51. package/src/semantic/Currency.res +189 -170
  52. package/src/semantic/DateRange.res +3 -4
  53. package/src/semantic/DateTime.res +5 -5
  54. package/src/semantic/Duration.res +2 -2
  55. package/src/semantic/Email.res +0 -1
  56. package/src/semantic/FileRef.res +2 -2
  57. package/src/semantic/GeoPoint.res +17 -21
  58. package/src/semantic/Geocoding.res +8 -9
  59. package/src/semantic/Geolocation.res +6 -9
  60. package/src/semantic/IdentityProvider.res +127 -0
  61. package/src/semantic/IdentityProvider.res.mjs +65 -0
  62. package/src/semantic/ImageRef.res +2 -2
  63. package/src/semantic/MemberRef.res +0 -1
  64. package/src/semantic/Messaging.res +141 -21
  65. package/src/semantic/Messaging.res.mjs +51 -8
  66. package/src/semantic/Money.res +23 -20
  67. package/src/semantic/Offload.res +6 -4
  68. package/src/semantic/Percent.res +3 -3
  69. package/src/semantic/Phone.res +0 -1
  70. package/src/semantic/RowImage.res +4 -3
  71. package/src/semantic/Secrets.res +76 -0
  72. package/src/semantic/Secrets.res.mjs +49 -0
  73. package/src/semantic/Semantic.res +8 -11
  74. package/src/semantic/StorageRef.res +17 -17
  75. package/src/semantic/Template.res +5 -7
  76. package/src/semantic/UploadableFile.res +0 -1
  77. package/src/semantic/UploadableImage.res +0 -1
  78. package/src/semantic/Url.res +3 -3
  79. package/src/types/AccountsManifest.res +281 -0
  80. package/src/types/AccountsManifest.res.mjs +311 -0
  81. package/src/types/AdminGroup.res +27 -0
  82. package/src/types/AdminGroup.res.mjs +9 -0
  83. package/src/types/Authorization.res +1 -2
  84. package/src/types/Handler.res +0 -1
  85. package/src/types/Identity.res +1 -3
  86. package/src/types/Lifecycle.res +7 -6
  87. package/src/types/Message.res +6 -4
  88. package/src/types/OwnerScope.res +28 -3
  89. package/src/types/OwnerScope.res.mjs +12 -0
  90. package/src/types/PrepareAccounts.res +119 -0
  91. package/src/types/PrepareAccounts.res.mjs +148 -0
  92. package/src/types/Projection.res +9 -2
  93. package/src/types/StoredEvent.res +2 -4
  94. package/src/types/Transition.res +8 -4
  95. package/src/util/Util_Password.res +60 -0
  96. package/src/util/Util_Password.res.mjs +53 -0
  97. package/src/util/Util_Sury.res +1 -4
@@ -48,7 +48,9 @@ let moduleBase = (traitPackage: string) =>
48
48
 
49
49
  let resolveFrom = (specifier: string) =>
50
50
  try Some(
51
- NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(specifier),
51
+ NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(
52
+ specifier,
53
+ ),
52
54
  ) catch {
53
55
  | _ => None
54
56
  }
@@ -80,8 +82,7 @@ let main = async () => {
80
82
  let packageJson = switch resolveFrom(`${traitPackage}/package.json`) {
81
83
  | None =>
82
84
  fail(
83
- `${traitPackage} is not installed here. A manifest is derived from the trait, so ` ++
84
- `the trait has to be resolvable.`,
85
+ `${traitPackage} is not installed here. A manifest is derived from the trait, so ` ++ `the trait has to be resolvable.`,
85
86
  )
86
87
  JSON.Encode.null
87
88
  | Some(path) => NodeFs.readFileSync(path)->JSON.parseOrThrow
@@ -95,8 +96,7 @@ let main = async () => {
95
96
  | None =>
96
97
  fail(
97
98
  `${traitPackage} exports no ${base} module, so its capability needs cannot be read.\n` ++
98
- ` A trait states them as a value — an empty array if it brokers nothing — because ` ++
99
- `an unstated need fails silently at run time.`,
99
+ ` A trait states them as a value — an empty array if it brokers nothing — because ` ++ `an unstated need fails silently at run time.`,
100
100
  )
101
101
  %raw(`undefined`)
102
102
  | Some(path) => await dynImport(NodeUrl.pathToFileURL(path)["href"])
@@ -127,8 +127,8 @@ let main = async () => {
127
127
  NodeFs.writeFileSync(out, manifest->TraitManifest.render)
128
128
  Console.log(
129
129
  `trait-manifest: ${manifest.trait}@${manifest.version} — ` ++
130
- `${(manifest.capabilities->Array.length)->Int.toString} capabilit(ies), ` ++
131
- `${(manifest.config->Array.length)->Int.toString} config field(s)`,
130
+ `${manifest.capabilities->Array.length->Int.toString} capabilit(ies), ` ++
131
+ `${manifest.config->Array.length->Int.toString} config field(s)`,
132
132
  )
133
133
  Console.log(`Wrote: ${out}`)
134
134
  }
@@ -60,7 +60,6 @@ check-lifecycle --root . --reuse-sidecars --json
60
60
  check-lifecycle --root . --update # write this app's models and golden
61
61
  ```
62
62
  */
63
-
64
63
  // The model this script writes is folded into `pluginStructure`, so by the time
65
64
  // it runs again the "declared" side it reads back would be its own last answer —
66
65
  // every edge confirmed, and a real disagreement between an annotation and the
@@ -103,7 +102,8 @@ type appRoot = {label: string, dir: string}
103
102
  corpus is on disk; a second pass over a warm tree buys nothing and costs the
104
103
  multi-root build chain's habit of cleaning artifacts outside the root it is
105
104
  building, which lands intermittently on a stale `.cmi`. */
106
- let reuseSidecars = NodeProcess.argv->Array.includes("--reuse-sidecars")
105
+ let reuseSidecars =
106
+ NodeProcess.argv->Array.includes("--reuse-sidecars")
107
107
 
108
108
  /** Report the run as one JSON document on stdout instead of the grouped prose.
109
109
 
@@ -117,7 +117,8 @@ let reuseSidecars = NodeProcess.argv->Array.includes("--reuse-sidecars")
117
117
  repository says it said — nor fail because the two have drifted, which is a
118
118
  fact about the repository rather than about the corpus it was asked to
119
119
  report. A contradiction still exits non-zero, so this composes with a gate. */
120
- let json = NodeProcess.argv->Array.includes("--json")
120
+ let json =
121
+ NodeProcess.argv->Array.includes("--json")
121
122
 
122
123
  /** The label a state carries when no row exists yet. Not a lifecycle case — no
123
124
  enum declares it — so it is spelled in a way no constructor can name, and a
@@ -295,8 +296,7 @@ let checkSidecars = (~pluginDirs: array<string>): result<unit, string> =>
295
296
  hasCorpus(~pluginDirs)
296
297
  ? Ok()
297
298
  : Error(
298
- "--reuse-sidecars was passed, but no scenario sidecar exists. Build with " ++
299
- "REVENTLESS_EMIT_SIDECAR=1 first, or drop the flag.",
299
+ "--reuse-sidecars was passed, but no scenario sidecar exists. Build with " ++ "REVENTLESS_EMIT_SIDECAR=1 first, or drop the flag.",
300
300
  )
301
301
 
302
302
  /** Sidecars are gitignored build artifacts, so the harvest produces its own
@@ -405,11 +405,13 @@ let declaredOf = (structure: JSON.t): option<declared> =>
405
405
  getObjs(s, key)->Array.filterMap(w =>
406
406
  w
407
407
  ->getStr("name")
408
- ->Option.map(name => {
409
- name,
410
- linkedViews: getStrs(w, "linkedViews"),
411
- commands: getObjs(w, "commands")->Array.filterMap(declaredCommandOf),
412
- })
408
+ ->Option.map(
409
+ name => {
410
+ name,
411
+ linkedViews: getStrs(w, "linkedViews"),
412
+ commands: getObjs(w, "commands")->Array.filterMap(declaredCommandOf),
413
+ },
414
+ )
413
415
  )
414
416
  let viewsFrom = key =>
415
417
  getObjs(s, key)->Array.filterMap(v =>
@@ -548,6 +550,18 @@ type derivedCommand = {
548
550
  accepted and silent. Kept separately because it is what turns a declared
549
551
  state the corpus disagrees with into a contradiction rather than a gap. */
550
552
  inertStates: array<string>,
553
+ /** The strict subset of [inertStates] where the command was *refused* — an
554
+ error, not an accepted no-op.
555
+
556
+ Separate because the two refute different claims. A named from-set says the
557
+ command **takes effect** from those states, so anything inert there
558
+ contradicts it. `Unrestricted` says only that the command is **never
559
+ refused**, which an accepted `Ok([])` agrees with rather than contradicts —
560
+ returning no events for a command that would change nothing is this
561
+ codebase's idempotency convention, not evidence the state was illegal.
562
+ Reading one field for both questions made every idempotent
563
+ legal-in-every-state command look like a contradiction. */
564
+ refusedStates: array<string>,
551
565
  /** Where the edges land. A relation, not a single value: a command observed
552
566
  landing in two states is the signal that the published `targetState` cannot
553
567
  carry the model, and that is worth seeing before spending the change. */
@@ -647,9 +661,10 @@ let deriveCommands = (
647
661
  effective->Array.filterMap(o => o.from == noRow ? None : Some(o.from)),
648
662
  ),
649
663
  inertStates: sortedUnique(
650
- mine->Array.filterMap(o =>
651
- o.outcome == Emitted || o.from == noRow ? None : Some(o.from)
652
- ),
664
+ mine->Array.filterMap(o => o.outcome == Emitted || o.from == noRow ? None : Some(o.from)),
665
+ ),
666
+ refusedStates: sortedUnique(
667
+ mine->Array.filterMap(o => o.outcome == Refused && o.from != noRow ? Some(o.from) : None),
653
668
  ),
654
669
  targets: sortedUnique(
655
670
  effective->Array.filterMap(o => o.to == o.from || o.to == noRow ? None : Some(o.to)),
@@ -757,15 +772,24 @@ let compare = (
757
772
  // effect somewhere agrees with that rather than narrowing it — the corpus
758
773
  // covers the states somebody wrote a scenario for, and silence about the
759
774
  // 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
775
+ // exercised in and **refused**: that is the switch and the behaviour
761
776
  // disagreeing about the same row.
777
+ //
778
+ // 🚨 **Refused, not merely inert.** `Unrestricted` claims the command is
779
+ // never refused — it does not claim the command always emits. A state where a
780
+ // scenario shows it accepted and silent AGREES with that: returning `Ok([])`
781
+ // for a command that would change nothing is this codebase's idempotency
782
+ // convention, required because commands arrive at least once. Reading
783
+ // `inertStates` here marked every such command contradicted, which is the
784
+ // opposite of what the corpus showed — a verdict redelivered, or landing
785
+ // after deactivation, is precisely the case the switch declared legal so that
786
+ // the reporting slice would not retry forever.
762
787
  | None if cmd.allowedStatesSource == Some("unrestricted") =>
763
- derived.inertStates->Array.forEach(state =>
788
+ derived.refusedStates->Array.forEach(state =>
764
789
  add(
765
790
  "contradicted",
766
791
  [state],
767
- `the switch declares it legal in every state, and a scenario from "${state}" ` ++
768
- `shows it refused or producing nothing`,
792
+ `the switch declares it legal in every state, and a scenario from "${state}" ` ++ `shows it refused`,
769
793
  )
770
794
  )
771
795
  | None =>
@@ -773,8 +797,9 @@ let compare = (
773
797
  add(
774
798
  "undeclared",
775
799
  derived.allowedStates,
776
- `scenarios show it taking effect from ${derived.allowedStates->Array.join(", ")}, ` ++
777
- `and it declares no edge`,
800
+ `scenarios show it taking effect from ${derived.allowedStates->Array.join(
801
+ ", ",
802
+ )}, ` ++ `and it declares no edge`,
778
803
  )
779
804
  }
780
805
  | Some(states) =>
@@ -785,8 +810,7 @@ let compare = (
785
810
  add(
786
811
  "contradicted",
787
812
  [state],
788
- `the switch names "${state}", and a scenario from "${state}" shows it ` ++
789
- `refused or producing nothing`,
813
+ `the switch names "${state}", and a scenario from "${state}" shows it ` ++ `refused or producing nothing`,
790
814
  )
791
815
  } else {
792
816
  add("unverified", [state], `the switch names "${state}", and no scenario starts there`)
@@ -806,8 +830,9 @@ let compare = (
806
830
  add(
807
831
  "unverified",
808
832
  states,
809
- `the switch declares ${Array.length(states)->Int.toString} state(s) and ` ++
810
- `no scenario shows the command taking effect anywhere`,
833
+ `the switch declares ${Array.length(
834
+ states,
835
+ )->Int.toString} state(s) and ` ++ `no scenario shows the command taking effect anywhere`,
811
836
  )
812
837
  }
813
838
  }
@@ -830,8 +855,7 @@ let compare = (
830
855
  add(
831
856
  "contradicted",
832
857
  [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`,
858
+ `the switch targets "${target}", and a scenario lands in "${state}" — ` ++ `the published targetState carries one state, so this edge cannot be expressed`,
835
859
  )
836
860
  }
837
861
  )
@@ -933,107 +957,111 @@ let runPlugin = async (
933
957
  switch await readDeclared(~pluginDir) {
934
958
  | Error(msg) => Error(msg)
935
959
  | Ok(declared) =>
936
- let corpora =
937
- filesUnder(NodePath.join([pluginDir, "tests"]), ~suffix=".gwt.json")->Array.filterMap(
938
- readCorpus,
939
- )
940
-
941
- // Every corpus, not only the ones the walk goes on to use: the kinds that
942
- // are unreadable in full — extension points, automation and translation
943
- // slices — are exactly the ones that sit outside the view/writable folders
944
- // below, and they are the ones a coverage UI would otherwise slander.
945
- corpora->Array.forEach(c => {
946
- let unreadable = c.scenarios->Array.filter(s => Array.length(s.whenElements) == 0)
947
- if Array.length(unreadable) > 0 {
948
- opaque
949
- ->Array.push({
950
- plugin,
951
- component: c.component,
952
- path: c.path,
953
- scenarios: Array.length(c.scenarios),
954
- unreadable: Array.length(unreadable),
955
- })
956
- ->ignore
957
- }
958
- })
959
-
960
- // Views first: a command's history cannot be labelled until the events in
961
- // it have somewhere to land.
962
- let mapsByView = Dict.make()
963
- let ambiguities = []
964
- corpora->Array.forEach(c =>
965
- if isViewPath(c.path) {
966
- switch declared.views->Array.find(v => v.name == c.component) {
967
- | Some({lifecycleField: Some(field)}) =>
968
- mapsByView->Dict.set(
969
- c.component,
970
- lifecycleMapFor(~scenarios=c.scenarios, ~field, ~ambiguities, ~view=c.component),
971
- )
972
- // A view with no lifecycle field labels nothing, and that is ordinary
973
- // — most views have no lifecycle at all.
974
- | _ => ()
975
- }
976
- }
960
+ let corpora =
961
+ filesUnder(NodePath.join([pluginDir, "tests"]), ~suffix=".gwt.json")->Array.filterMap(
962
+ readCorpus,
977
963
  )
978
- ambiguities->Array.forEach(((view, message)) =>
979
- findings
964
+
965
+ // Every corpus, not only the ones the walk goes on to use: the kinds that
966
+ // are unreadable in full — extension points, automation and translation
967
+ // slices — are exactly the ones that sit outside the view/writable folders
968
+ // below, and they are the ones a coverage UI would otherwise slander.
969
+ corpora->Array.forEach(c => {
970
+ let unreadable = c.scenarios->Array.filter(s => Array.length(s.whenElements) == 0)
971
+ if Array.length(unreadable) > 0 {
972
+ opaque
980
973
  ->Array.push({
981
- severity: "ambiguous",
982
974
  plugin,
983
- component: view,
984
- command: "",
985
- states: [],
986
- message,
975
+ component: c.component,
976
+ path: c.path,
977
+ scenarios: Array.length(c.scenarios),
978
+ unreadable: Array.length(unreadable),
987
979
  })
988
980
  ->ignore
989
- )
981
+ }
982
+ })
990
983
 
991
- let derived = []
992
- corpora->Array.forEach(c =>
993
- if isWritablePath(c.path) {
994
- switch declared.writables->Array.find(w => w.name == c.component) {
995
- | None => ()
996
- | Some(writable) =>
997
- // The union of the maps of every view this writable feeds. A union
998
- // rather than a single view for the reason the platform's own name
999
- // check uses one: a slice feeding two views is not claiming which of
1000
- // them a state belongs to.
1001
- let map = Dict.make()
1002
- writable.linkedViews->Array.forEach(view =>
1003
- switch mapsByView->Dict.get(view) {
1004
- | Some(m) => m->Dict.forEachWithKey((value, event) => map->Dict.set(event, value))
1005
- | None => ()
1006
- }
1007
- )
1008
- let labelled = Array.length(map->Dict.keysToArray) > 0
1009
- let idFieldFor = command =>
1010
- writable.commands
1011
- ->Array.find(c => c.command == command)
1012
- ->Option.flatMap(c => c.aggregateIdField)
1013
- let observations = observe(~scenarios=c.scenarios, ~map, ~idFieldFor)
1014
- let commands = deriveCommands(~component=c.component, ~observations, ~labelled)
1015
-
1016
- commands->Array.forEach(d => {
1017
- // Without a lifecycle map every history folds to "no row", so
1018
- // there is nothing to confirm a claim against and nothing to
1019
- // contradict it with. Reporting the claim as unverified is the
1020
- // honest answer; running the comparison would manufacture
1021
- // contradictions out of a missing map.
1022
- if labelled {
1023
- compare(~plugin, ~writable, ~derived=d, ~findings)
1024
- }
1025
- derived->Array.push(d)
1026
- })
984
+ // Views first: a command's history cannot be labelled until the events in
985
+ // it have somewhere to land.
986
+ let mapsByView = Dict.make()
987
+ let ambiguities = []
988
+ corpora->Array.forEach(c =>
989
+ if isViewPath(c.path) {
990
+ switch declared.views->Array.find(v => v.name == c.component) {
991
+ | Some({lifecycleField: Some(field)}) =>
992
+ mapsByView->Dict.set(
993
+ c.component,
994
+ lifecycleMapFor(~scenarios=c.scenarios, ~field, ~ambiguities, ~view=c.component),
995
+ )
996
+ // A view with no lifecycle field labels nothing, and that is ordinary
997
+ // — most views have no lifecycle at all.
998
+ | _ => ()
999
+ }
1000
+ }
1001
+ )
1002
+ ambiguities->Array.forEach(((view, message)) =>
1003
+ findings
1004
+ ->Array.push({
1005
+ severity: "ambiguous",
1006
+ plugin,
1007
+ component: view,
1008
+ command: "",
1009
+ states: [],
1010
+ message,
1011
+ })
1012
+ ->ignore
1013
+ )
1014
+
1015
+ let derived = []
1016
+ corpora->Array.forEach(c =>
1017
+ if isWritablePath(c.path) {
1018
+ switch declared.writables->Array.find(w => w.name == c.component) {
1019
+ | None => ()
1020
+ | Some(writable) =>
1021
+ // The union of the maps of every view this writable feeds. A union
1022
+ // rather than a single view for the reason the platform's own name
1023
+ // check uses one: a slice feeding two views is not claiming which of
1024
+ // them a state belongs to.
1025
+ let map = Dict.make()
1026
+ writable.linkedViews->Array.forEach(view =>
1027
+ switch mapsByView->Dict.get(view) {
1028
+ | Some(m) => m->Dict.forEachWithKey((value, event) => map->Dict.set(event, value))
1029
+ | None => ()
1030
+ }
1031
+ )
1032
+ let labelled = Array.length(map->Dict.keysToArray) > 0
1033
+ let idFieldFor = command =>
1034
+ writable.commands
1035
+ ->Array.find(c => c.command == command)
1036
+ ->Option.flatMap(c => c.aggregateIdField)
1037
+ let observations = observe(~scenarios=c.scenarios, ~map, ~idFieldFor)
1038
+ let commands = deriveCommands(~component=c.component, ~observations, ~labelled)
1039
+
1040
+ commands->Array.forEach(d => {
1041
+ // Without a lifecycle map every history folds to "no row", so
1042
+ // there is nothing to confirm a claim against and nothing to
1043
+ // contradict it with. Reporting the claim as unverified is the
1044
+ // honest answer; running the comparison would manufacture
1045
+ // contradictions out of a missing map.
1046
+ if labelled {
1047
+ compare(~plugin, ~writable, ~derived=d, ~findings)
1048
+ }
1049
+ derived->Array.push(d)
1050
+ })
1027
1051
 
1028
- let why = labelled
1029
- ? "no scenario exercises the command"
1030
- : `${writable.linkedViews->Array.join(", ")} declares no lifecycle field, so its ` ++
1031
- `scenarios cannot be labelled`
1032
- writable.commands->Array.forEach(cmd =>
1033
- if labelled && commands->Array.some(d => d.command == cmd.command) {
1034
- ()
1035
- } else {
1036
- allUnverified(~cmd, ~why, ~add=(severity, states, message) =>
1052
+ let why = labelled
1053
+ ? "no scenario exercises the command"
1054
+ : `${writable.linkedViews->Array.join(
1055
+ ", ",
1056
+ )} declares no lifecycle field, so its ` ++ `scenarios cannot be labelled`
1057
+ writable.commands->Array.forEach(cmd =>
1058
+ if labelled && commands->Array.some(d => d.command == cmd.command) {
1059
+ ()
1060
+ } else {
1061
+ allUnverified(
1062
+ ~cmd,
1063
+ ~why,
1064
+ ~add=(severity, states, message) =>
1037
1065
  findings
1038
1066
  ->Array.push({
1039
1067
  severity,
@@ -1043,14 +1071,14 @@ let runPlugin = async (
1043
1071
  states,
1044
1072
  message: `${plugin}/${writable.name}.${cmd.command}: ${message}`,
1045
1073
  })
1046
- ->ignore
1047
- )
1048
- }
1049
- )
1050
- }
1074
+ ->ignore,
1075
+ )
1076
+ }
1077
+ )
1051
1078
  }
1052
- )
1053
- Ok(derived)
1079
+ }
1080
+ )
1081
+ Ok(derived)
1054
1082
  }
1055
1083
 
1056
1084
  // ── The golden ──────────────────────────────────────────────────────────────
@@ -1160,6 +1188,19 @@ let reportJson = (
1160
1188
 
1161
1189
  // ── The value structure assembly reads ──────────────────────────────────────
1162
1190
 
1191
+ /** The emitted file is committed ReScript, so it faces the tree's format gate
1192
+ like any other source. Laying the text out by hand would put this script and
1193
+ the formatter in a standing argument only one of them can win — so it emits
1194
+ whatever reads clearly and lets the formatter settle the layout. Raising
1195
+ beats falling back: unformatted source would satisfy this check and fail the
1196
+ format gate, which is the stand-off the call exists to end. */
1197
+ let formatted = (source: string): string =>
1198
+ NodeChildProcess.execFileSync(
1199
+ "pnpm",
1200
+ ["exec", "rescript", "format", "--stdin", ".res"],
1201
+ {cwd: repoRoot, encoding: "utf8", input: source},
1202
+ )
1203
+
1163
1204
  /** The same derivation as a committed ReScript value, so `buildStructure` gets
1164
1205
  the model as data. It cannot read the corpus itself: tests are not published
1165
1206
  with a plugin package, and metadata that read them would make deleting a test
@@ -1173,19 +1214,22 @@ let reportJson = (
1173
1214
  let modelSource = (~plugin: string, ~derived: array<derivedCommand>): string => {
1174
1215
  let saysSomething = (d: derivedCommand) =>
1175
1216
  d.level != "" || Array.length(d.allowedStates) > 0 || Array.length(d.targets) > 0
1176
- let quoted = (xs: array<string>) =>
1177
- "[" ++ xs->Array.map(s => `"${s}"`)->Array.join(", ") ++ "]"
1178
- let entries = derived->Array.filter(saysSomething)->byComponentThenCommand->Array.map(d => {
1179
- // Omitted rather than written as an absent value: `level` is an optional
1180
- // field, and a corpus that could not label this command's histories has
1181
- // nothing to say about it.
1182
- let level = switch d.level {
1183
- | "Collection" | "Instance" => `level: Reventless.Plugin.${d.level}, `
1184
- | _ => ""
1185
- }
1186
- ` {component: "${d.component}", command: "${d.command}", ${level}` ++
1187
- `allowedStates: ${quoted(d.allowedStates)}, targets: ${quoted(d.targets)}},`
1188
- })
1217
+ let quoted = (xs: array<string>) => "[" ++ xs->Array.map(s => `"${s}"`)->Array.join(", ") ++ "]"
1218
+ let entries =
1219
+ derived
1220
+ ->Array.filter(saysSomething)
1221
+ ->byComponentThenCommand
1222
+ ->Array.map(d => {
1223
+ // Omitted rather than written as an absent value: `level` is an optional
1224
+ // field, and a corpus that could not label this command's histories has
1225
+ // nothing to say about it.
1226
+ let level = switch d.level {
1227
+ | "Collection" | "Instance" => `level: Reventless.Plugin.${d.level}, `
1228
+ | _ => ""
1229
+ }
1230
+ ` {component: "${d.component}", command: "${d.command}", ${level}` ++
1231
+ `allowedStates: ${quoted(d.allowedStates)}, targets: ${quoted(d.targets)}},`
1232
+ })
1189
1233
  Array.flat([
1190
1234
  [
1191
1235
  `// AUTO-GENERATED — do not edit. Run \`pnpm run check:lifecycle:update\` to update.`,
@@ -1200,7 +1244,9 @@ let modelSource = (~plugin: string, ~derived: array<derivedCommand>): string =>
1200
1244
  ],
1201
1245
  entries,
1202
1246
  ["]", ""],
1203
- ])->Array.join("\n")
1247
+ ])
1248
+ ->Array.join("\n")
1249
+ ->formatted
1204
1250
  }
1205
1251
 
1206
1252
  let modelPath = (~pluginDir: string) => NodePath.join([pluginDir, "src", "LifecycleModel.res"])
@@ -1234,8 +1280,9 @@ let main = async () => {
1234
1280
  // an app with no plugins, and reporting "ok" for it is how that typo survives.
1235
1281
  if Array.length(allPluginDirs) == 0 {
1236
1282
  Console.error(
1237
- `no plugins found under ${roots->Array.map(r => r.dir)->Array.join(", ")} — a plugin is a ` ++
1238
- `directory with both src/Plugin.res and tests/`,
1283
+ `no plugins found under ${roots
1284
+ ->Array.map(r => r.dir)
1285
+ ->Array.join(", ")} — a plugin is a ` ++ `directory with both src/Plugin.res and tests/`,
1239
1286
  )
1240
1287
  NodeProcess.exit(1)
1241
1288
  }
@@ -1256,8 +1303,11 @@ let main = async () => {
1256
1303
  // refusing outright.
1257
1304
  if !hasCorpus(~pluginDirs=allPluginDirs) {
1258
1305
  Console.error(
1259
- `no scenario sidecar exists under ${roots->Array.map(r => r.dir)->Array.join(", ")} after ` ++
1260
- `the build. Build that tree with REVENTLESS_EMIT_SIDECAR=1 and pass --reuse-sidecars.`,
1306
+ `no scenario sidecar exists under ${roots
1307
+ ->Array.map(r => r.dir)
1308
+ ->Array.join(
1309
+ ", ",
1310
+ )} after ` ++ `the build. Build that tree with REVENTLESS_EMIT_SIDECAR=1 and pass --reuse-sidecars.`,
1261
1311
  )
1262
1312
  NodeProcess.exit(1)
1263
1313
  }
@@ -1308,7 +1358,9 @@ let main = async () => {
1308
1358
  ~drifted,
1309
1359
  )
1310
1360
  Console.log(
1311
- `ok ${example} — ${Array.length(derived)->Int.toString} commands derived from scenarios`,
1361
+ `ok ${example} — ${Array.length(
1362
+ derived,
1363
+ )->Int.toString} commands derived from scenarios`,
1312
1364
  )
1313
1365
  }
1314
1366
  }
@@ -1336,8 +1388,9 @@ let main = async () => {
1336
1388
 
1337
1389
  if Array.length(drifted) > 0 {
1338
1390
  Console.error(
1339
- `\n${Array.length(drifted)->Int.toString} lifecycle artifact(s) changed. If the change is ` ++
1340
- `intended, re-run with --update and commit them alongside the change that moved them.`,
1391
+ `\n${Array.length(
1392
+ drifted,
1393
+ )->Int.toString} lifecycle artifact(s) changed. If the change is ` ++ `intended, re-run with --update and commit them alongside the change that moved them.`,
1341
1394
  )
1342
1395
  }
1343
1396
 
@@ -1350,4 +1403,9 @@ let main = async () => {
1350
1403
  }
1351
1404
  }
1352
1405
 
1353
- let _ = main()
1406
+ // 🚨 **No top-level call.** `../../run-check-lifecycle.mjs` invokes [main]; this
1407
+ // module only defines it. While the call was here, importing the module *ran the
1408
+ // whole check* and then called `NodeProcess.exit`, so nothing in this file could
1409
+ // be reached from a test — including [deriveCommands] and [compare], which are
1410
+ // pure and are where every verdict is actually decided. That is the same trap
1411
+ // `ProvisionIdentity` records having shipped a never-matching guard through.
@@ -469,6 +469,11 @@ function deriveCommands(component, observations, labelled) {
469
469
  return o.from;
470
470
  }
471
471
  })),
472
+ refusedStates: sortedUnique(Stdlib_Array.filterMap(mine, o => {
473
+ if (o.outcome === "Refused" && o.from !== noRow) {
474
+ return o.from;
475
+ }
476
+ })),
472
477
  targets: sortedUnique(Stdlib_Array.filterMap(effective, o => {
473
478
  if (o.to === o.from || o.to === noRow) {
474
479
  return;
@@ -531,7 +536,7 @@ function compare(plugin, writable, derived, findings) {
531
536
  add("unverified", states, `the switch declares ` + states.length.toString() + ` state(s) and no scenario shows the command taking effect anywhere`);
532
537
  }
533
538
  } else if (Primitive_object.equal(declared.allowedStatesSource, "unrestricted")) {
534
- derived.inertStates.forEach(state => add("contradicted", [state], `the switch declares it legal in every state, and a scenario from "` + state + `" shows it refused or producing nothing`));
539
+ derived.refusedStates.forEach(state => add("contradicted", [state], `the switch declares it legal in every state, and a scenario from "` + state + `" shows it refused`));
535
540
  } else if (derived.allowedStates.length !== 0) {
536
541
  add("undeclared", derived.allowedStates, `scenarios show it taking effect from ` + derived.allowedStates.join(", ") + `, and it declares no edge`);
537
542
  }
@@ -910,6 +915,20 @@ function reportJson(findings, opaque, derived, failures) {
910
915
  ]), undefined, 2) + "\n";
911
916
  }
912
917
 
918
+ function formatted(source) {
919
+ return Nodechild_process.execFileSync("pnpm", [
920
+ "exec",
921
+ "rescript",
922
+ "format",
923
+ "--stdin",
924
+ ".res"
925
+ ], {
926
+ cwd: repoRoot,
927
+ encoding: "utf8",
928
+ input: source
929
+ });
930
+ }
931
+
913
932
  function modelSource(plugin, derived) {
914
933
  let saysSomething = d => {
915
934
  if (d.level !== "" || d.allowedStates.length !== 0) {
@@ -932,7 +951,7 @@ function modelSource(plugin, derived) {
932
951
  }
933
952
  return ` {component: "` + d.component + `", command: "` + d.command + `", ` + level + (`allowedStates: ` + quoted(d.allowedStates) + `, targets: ` + quoted(d.targets) + `},`);
934
953
  });
935
- return [
954
+ return formatted([
936
955
  [
937
956
  `// AUTO-GENERATED — do not edit. Run \`pnpm run check:lifecycle:update\` to update.`,
938
957
  `//`,
@@ -949,7 +968,7 @@ function modelSource(plugin, derived) {
949
968
  "]",
950
969
  ""
951
970
  ]
952
- ].flat().join("\n");
971
+ ].flat().join("\n"));
953
972
  }
954
973
 
955
974
  function modelPath(pluginDir) {
@@ -1072,8 +1091,6 @@ async function main() {
1072
1091
  }
1073
1092
  }
1074
1093
 
1075
- main();
1076
-
1077
1094
  export {
1078
1095
  repoRoot,
1079
1096
  examplesDir,
@@ -1126,6 +1143,7 @@ export {
1126
1143
  goldenJson,
1127
1144
  goldenPath,
1128
1145
  reportJson,
1146
+ formatted,
1129
1147
  modelSource,
1130
1148
  modelPath,
1131
1149
  writeOrCompare,
@@ -13,7 +13,6 @@ a JSON number either way.
13
13
  }
14
14
  ```
15
15
  */
16
-
17
16
  /** The count's representation. Transparent `float`; see the note above on why
18
17
  it is not `int`. */
19
18
  type t = float
@@ -31,7 +31,6 @@ type state = {
31
31
  }
32
32
  ```
33
33
  */
34
-
35
34
  /** The day's representation. Transparent `string`, like the rest of the branded
36
35
  scalars: the marker refines an existing field rather than replacing it. */
37
36
  type t = string
@@ -52,7 +51,8 @@ let fromString = (raw: string): result<t, string> =>
52
51
 
53
52
  /** The sury schema for a calendar-date field. Write the field's type as
54
53
  `Reventless.CalendarDate.t` and sury-ppx resolves it. */
55
- let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.date, ~check=fromString)
54
+ let schema: S.t<t> =
55
+ S.string->Semantic.refined(~id=Semantic.Id.date, ~check=fromString)
56
56
 
57
57
  /** Whether a field schema carries the calendar-date marker. */
58
58
  let isCalendarDate = (fieldSchema: S.t<unknown>) => fieldSchema->Semantic.has(~id=Semantic.Id.date)