@reventlessdev/reventless-spec 3.0.0-alpha.125 → 3.0.0-alpha.127
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 +25 -0
- package/package.json +1 -1
- package/src/components/Aggregate.res +5 -4
- package/src/components/DcbScopeInference.res +42 -1
- package/src/components/DcbScopeInference.res.mjs +25 -5
- package/src/components/DcbTag.res +19 -0
- package/src/components/DcbTag.res.mjs +3 -1
- package/src/components/InboundTranslationSlice.res +2 -2
- package/src/components/Plugin.res +2 -2
- package/src/components/StateAnnotations.res +2 -2
- package/src/components/StateChangeSlice.res +2 -2
- package/src/generator/Codegen.res +21 -0
- package/src/generator/Codegen.res.mjs +8 -0
- package/src/semantic/Messaging.res +24 -0
- package/src/semantic/Messaging.res.mjs +9 -0
- package/src/types/Transition.res +6 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
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.127 (2026-09-02)
|
|
7
|
+
|
|
8
|
+
* feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **dcb:** a boundary that cannot derive its scope says so ([db79969](https://github.com/ReventlessDev/reventless-core/commit/db79969df36bd90607425f6a22b4624227cfc4a0))
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* `Capability_Messaging_Ses.make` is replaced by
|
|
16
|
+
`Capability_Messaging.make(~name)`, which reads the transport and the address
|
|
17
|
+
from config; the SES module keeps only `emailSender`. A deployment that named its
|
|
18
|
+
sender in code must move it to `platform:messagingEmailSender` or the deploy is
|
|
19
|
+
refused.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# 3.0.0-alpha.126 (2026-09-01)
|
|
24
|
+
|
|
25
|
+
**Note:** Version bump only for package @reventlessdev/reventless-spec
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
6
31
|
# 3.0.0-alpha.125 (2026-09-01)
|
|
7
32
|
|
|
8
33
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -70,10 +70,11 @@ module type Spec = {
|
|
|
70
70
|
type lifecycleState
|
|
71
71
|
|
|
72
72
|
/** The lifecycle edge each command owns, read while the plugin structure is
|
|
73
|
-
assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`, so
|
|
74
|
+
a component whose commands guard nothing needs no line; a host that writes
|
|
75
|
+
the switch by hand gets an exhaustive one over typed states. Refused rather
|
|
76
|
+
than injected when the command type splices, since a spliced command must
|
|
77
|
+
be answered for. See `Transition`. */
|
|
77
78
|
let commandTransition: command => Transition.t<lifecycleState>
|
|
78
79
|
|
|
79
80
|
/** The domain traits grafted into this component, as values the trait packages
|
|
@@ -130,6 +130,40 @@ let foreignConsumedKeys = (s: sliceShape): array<string> => {
|
|
|
130
130
|
)
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
Which consumed arms cost a slice its partition — the actionable half of the
|
|
135
|
+
"no own partition key" ambiguity.
|
|
136
|
+
|
|
137
|
+
Rule 1 subtracts `foreignConsumedKeys` from `producedKeys`, so a slice whose only
|
|
138
|
+
produced key is also declared on a consumed arm it does not itself produce is left
|
|
139
|
+
with nothing. In practice that is almost always one mistake: a *lifecycle* arm
|
|
140
|
+
naming the id it is already partitioned by (`ProductAdded({productId})` on a slice
|
|
141
|
+
whose every event carries `productId`), which reads as "this id comes from a
|
|
142
|
+
foreign producer" when it is in fact this slice's own partition.
|
|
143
|
+
|
|
144
|
+
The ambiguity message can only say a partition was not found. This says which arm
|
|
145
|
+
to delete, which is the whole difference between a diagnostic and a puzzle — so it
|
|
146
|
+
is here, beside the rule that produces the ambiguity, rather than duplicated by
|
|
147
|
+
each surface that reports one.
|
|
148
|
+
|
|
149
|
+
Returns one entry per produced key that a foreign arm claims, naming those arms.
|
|
150
|
+
Empty when the slice has a partition (nothing to explain) or when the produced
|
|
151
|
+
keys are simply too many (a different ambiguity, answered by `@partitionTag`).
|
|
152
|
+
*/
|
|
153
|
+
let partitionBlockers = (s: sliceShape): array<(string, array<string>)> => {
|
|
154
|
+
let foreign = foreignConsumedKeys(s)
|
|
155
|
+
let ownProduced = Set.make()
|
|
156
|
+
s.produced->Array.forEach(e => ownProduced->Set.add(e.eventType))
|
|
157
|
+
producedKeys(s)
|
|
158
|
+
->Array.filter(k => foreign->Array.includes(k))
|
|
159
|
+
->Array.map(k => (
|
|
160
|
+
k,
|
|
161
|
+
s.consumed
|
|
162
|
+
->Array.filter(e => !(ownProduced->Set.has(e.eventType)) && e->keysOfEvent->Array.includes(k))
|
|
163
|
+
->Array.map(e => e.eventType),
|
|
164
|
+
))
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
/**
|
|
134
168
|
Per-slice cross-partition keys for the test harness, which has no global owner
|
|
135
169
|
map: a foreign-read key that is not the slice's own partition is read across
|
|
@@ -169,9 +203,16 @@ let infer = (slices: array<sliceShape>): derived => {
|
|
|
169
203
|
switch produced->Array.filter(k => !(foreign->Array.includes(k))) {
|
|
170
204
|
| [single] => partitionBySlice->Dict.set(s.sliceName, single)
|
|
171
205
|
| [] =>
|
|
206
|
+
// Name the arms that took the key. Almost always a lifecycle arm
|
|
207
|
+
// declaring the id the slice is already partitioned by, where the fix is
|
|
208
|
+
// to drop the field rather than to annotate around it.
|
|
209
|
+
let blame =
|
|
210
|
+
partitionBlockers(s)
|
|
211
|
+
->Array.map(((key, arms)) => `${arms->Array.join("/")} declares ${key}`)
|
|
212
|
+
->Array.join("; ")
|
|
172
213
|
let _ = ambiguities->Array.push((
|
|
173
214
|
s.sliceName,
|
|
174
|
-
|
|
215
|
+
`no own partition key — every produced *Id is read from a foreign producer (${blame}). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`,
|
|
175
216
|
))
|
|
176
217
|
| many =>
|
|
177
218
|
let _ = ambiguities->Array.push((
|
|
@@ -50,6 +50,24 @@ function foreignConsumedKeys(s) {
|
|
|
50
50
|
}));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function partitionBlockers(s) {
|
|
54
|
+
let foreign = foreignConsumedKeys(s);
|
|
55
|
+
let ownProduced = new Set();
|
|
56
|
+
s.produced.forEach(e => {
|
|
57
|
+
ownProduced.add(e.eventType);
|
|
58
|
+
});
|
|
59
|
+
return producedKeys(s).filter(k => foreign.includes(k)).map(k => [
|
|
60
|
+
k,
|
|
61
|
+
s.consumed.filter(e => {
|
|
62
|
+
if (ownProduced.has(e.eventType)) {
|
|
63
|
+
return false;
|
|
64
|
+
} else {
|
|
65
|
+
return e.idFields.map(tagKeyOf).includes(k);
|
|
66
|
+
}
|
|
67
|
+
}).map(e => e.eventType)
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
function crossPartitionForSlice(s) {
|
|
54
72
|
let foreign = foreignConsumedKeys(s);
|
|
55
73
|
let scalar = commandScalarKeys(s);
|
|
@@ -93,12 +111,13 @@ function infer(slices) {
|
|
|
93
111
|
s.sliceName,
|
|
94
112
|
`multiple candidate partition keys (` + many.join(", ") + `) — add an explicit @partitionTag`
|
|
95
113
|
]);
|
|
96
|
-
|
|
97
|
-
ambiguities.push([
|
|
98
|
-
s.sliceName,
|
|
99
|
-
"no own partition key — every produced *Id is read from a foreign producer (pure join?); add an explicit @partitionTag"
|
|
100
|
-
]);
|
|
114
|
+
return;
|
|
101
115
|
}
|
|
116
|
+
let blame = partitionBlockers(s).map(param => param[1].join("/") + ` declares ` + param[0]).join("; ");
|
|
117
|
+
ambiguities.push([
|
|
118
|
+
s.sliceName,
|
|
119
|
+
`no own partition key — every produced *Id is read from a foreign producer (` + blame + `). If that field is this slice's own partition, remove it from the consumed arm; if the slice really is a pure join, add an explicit @partitionTag`
|
|
120
|
+
]);
|
|
102
121
|
return;
|
|
103
122
|
}
|
|
104
123
|
let single = many[0];
|
|
@@ -171,6 +190,7 @@ export {
|
|
|
171
190
|
consumedKeys,
|
|
172
191
|
commandScalarKeys,
|
|
173
192
|
foreignConsumedKeys,
|
|
193
|
+
partitionBlockers,
|
|
174
194
|
crossPartitionForSlice,
|
|
175
195
|
infer,
|
|
176
196
|
}
|
|
@@ -1008,6 +1008,15 @@ The DCB decision-read scope threaded into every StateChangeSlice callback:
|
|
|
1008
1008
|
type effectiveScope = {
|
|
1009
1009
|
crossPartitionTagKeys: array<string>,
|
|
1010
1010
|
tagKeysByEventType: dict<array<string>>,
|
|
1011
|
+
/** Slices whose partition did not resolve, as `(slice, reason)` — non-empty
|
|
1012
|
+
means the two fields above are the *annotated* fallback, not the derivation. */
|
|
1013
|
+
ambiguities: array<(string, string)>,
|
|
1014
|
+
/** Cross-partition keys the inference found that the fallback does not carry.
|
|
1015
|
+
Non-empty is the harmful case and only that: a reference read silently
|
|
1016
|
+
narrows to its own partition, so a slice decides against events it cannot
|
|
1017
|
+
see and rejects a command whose facts are in the log. Empty (even with
|
|
1018
|
+
ambiguities) means the fallback happens to agree and nothing is lost. */
|
|
1019
|
+
droppedCrossPartitionTagKeys: array<string>,
|
|
1011
1020
|
}
|
|
1012
1021
|
|
|
1013
1022
|
/**
|
|
@@ -1023,6 +1032,12 @@ the runtime decision query cannot diverge from the storage/GSI scope. Before thi
|
|
|
1023
1032
|
existed the entry point re-derived scope from annotations alone, silently dropping
|
|
1024
1033
|
inferred cross-partition reference reads — see
|
|
1025
1034
|
`docs/analysis/dcb-runtime-scope-annotation-drift.md`.
|
|
1035
|
+
|
|
1036
|
+
The fallback reports itself. `ambiguities` says it happened and
|
|
1037
|
+
`droppedCrossPartitionTagKeys` says whether it cost anything, so a caller can
|
|
1038
|
+
distinguish a fallback that agrees with the derivation from one that quietly
|
|
1039
|
+
narrows a reference read — the second is a wrong answer waiting for the first
|
|
1040
|
+
command that needs the key, and callers are expected to raise it.
|
|
1026
1041
|
*/
|
|
1027
1042
|
let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
|
|
1028
1043
|
let producedSchemas = slices->Array.map(s => s.eventSchema)
|
|
@@ -1055,6 +1070,10 @@ let deriveEffectiveScope = (slices: array<sliceSchemas>): effectiveScope => {
|
|
|
1055
1070
|
{
|
|
1056
1071
|
crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
|
|
1057
1072
|
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
|
|
1073
|
+
ambiguities: inferred.ambiguities,
|
|
1074
|
+
droppedCrossPartitionTagKeys: useInferred
|
|
1075
|
+
? []
|
|
1076
|
+
: inferred.crossPartitionTagKeys->Array.filter(k => !(annotatedCross->Array.includes(k))),
|
|
1058
1077
|
}
|
|
1059
1078
|
}
|
|
1060
1079
|
|
|
@@ -636,7 +636,9 @@ function deriveEffectiveScope(slices) {
|
|
|
636
636
|
let useInferred = inferred.ambiguities.length === 0;
|
|
637
637
|
return {
|
|
638
638
|
crossPartitionTagKeys: useInferred ? inferred.crossPartitionTagKeys : annotatedCross,
|
|
639
|
-
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys
|
|
639
|
+
tagKeysByEventType: useInferred ? inferred.tagKeysByEventType : annotatedTagKeys,
|
|
640
|
+
ambiguities: inferred.ambiguities,
|
|
641
|
+
droppedCrossPartitionTagKeys: useInferred ? [] : inferred.crossPartitionTagKeys.filter(k => !annotatedCross.includes(k))
|
|
640
642
|
};
|
|
641
643
|
}
|
|
642
644
|
|
|
@@ -72,8 +72,8 @@ module type Spec = {
|
|
|
72
72
|
|
|
73
73
|
/** The lifecycle edge each command owns, read while the plugin structure is
|
|
74
74
|
assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
so a component whose commands guard nothing needs no line; a host that
|
|
76
|
+
writes the switch by hand gets an exhaustive one over typed states.
|
|
77
77
|
See `Transition`. */
|
|
78
78
|
let commandTransition: command => Transition.t<lifecycleState>
|
|
79
79
|
}
|
|
@@ -137,10 +137,10 @@ type commandDef = {
|
|
|
137
137
|
aggregateIdField: @s.matches(stringOptionSchema) option<string>,
|
|
138
138
|
mutationField: string,
|
|
139
139
|
references: array<fieldReference>,
|
|
140
|
-
/** The
|
|
140
|
+
/** The declared *from* set — lifecycle states this command is meaningful in.
|
|
141
141
|
`None` means always available; `Some([])` means never show. */
|
|
142
142
|
allowedStates: @s.matches(stringArrayOptionSchema) option<array<string>>,
|
|
143
|
-
/** The
|
|
143
|
+
/** The declared *to* state this command's handler writes. `None` with a
|
|
144
144
|
from-set present means the command does not move the row. */
|
|
145
145
|
targetState: @s.matches(stringOptionSchema) option<string>,
|
|
146
146
|
/** Whether the variant is exposed in the generated API (non-`@noApi`). */
|
|
@@ -54,7 +54,7 @@ otherwise see the same negative marker on every record they can read.
|
|
|
54
54
|
two-valued enum.
|
|
55
55
|
- `Some(v)` — the **state** form. The row is retired when the field equals `v`,
|
|
56
56
|
and the field is the record's `@lifecycle` field. One field then carries the
|
|
57
|
-
fact once instead of twice, and
|
|
57
|
+
fact once instead of twice, and `Moves([Deactivated], Active)` on the
|
|
58
58
|
way-back command is enough to make a generated menu offer it there and nowhere
|
|
59
59
|
else —
|
|
60
60
|
because retirement is finally expressible in the vocabulary that stance is
|
|
@@ -158,7 +158,7 @@ type stateAnnotationSpec = {
|
|
|
158
158
|
metric: array<(string, metricSpec)>,
|
|
159
159
|
/**
|
|
160
160
|
Field annotated `@lifecycle` on the state record (PPX-emitted) — the enum a
|
|
161
|
-
command's
|
|
161
|
+
command's declared edge is written in terms of, a board draws its columns
|
|
162
162
|
from and a state diagram renders. `Some(name)` when one such annotation
|
|
163
163
|
exists; the PPX errors on duplicate `@lifecycle` annotations within the same
|
|
164
164
|
record. Codegen consumes this to populate `queryableDef.lifecycleField`, and
|
|
@@ -94,8 +94,8 @@ module type Spec = {
|
|
|
94
94
|
|
|
95
95
|
/** The lifecycle edge each command owns, read while the plugin structure is
|
|
96
96
|
assembled. Auto-injected as `_ => Unrestricted` by `@@reventless.spec`,
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
so a component whose commands guard nothing needs no line; a host that
|
|
98
|
+
writes the switch by hand gets an exhaustive one over typed states.
|
|
99
99
|
See `Transition`. */
|
|
100
100
|
let commandTransition: command => Transition.t<lifecycleState>
|
|
101
101
|
|
|
@@ -608,6 +608,27 @@ let renderComposition = (
|
|
|
608
608
|
pluginNameToEnvBase(config.name) ++
|
|
609
609
|
"_UI_BUNDLE_URL\"",
|
|
610
610
|
)
|
|
611
|
+
// The DCB boundary's slices as schemas, outside `Make` because the scope they
|
|
612
|
+
// determine is a property of the specs and not of any platform. Emitted rather
|
|
613
|
+
// than hand-listed so a slice added tomorrow is in it: this is the only value
|
|
614
|
+
// from which the whole boundary's scope can be derived without applying the
|
|
615
|
+
// functor, which is what lets a test — and the repo's scope check — assert that
|
|
616
|
+
// it resolves. A per-slice test cannot: one slice never has a boundary.
|
|
617
|
+
if resolved.stateChangeSlices->Array.length > 0 {
|
|
618
|
+
lines->Array.push("")
|
|
619
|
+
lines->Array.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [")
|
|
620
|
+
resolved.stateChangeSlices->Array.forEach(stem =>
|
|
621
|
+
lines->Array.push(
|
|
622
|
+
" {" ++
|
|
623
|
+
`name: ${stem}.name, ` ++
|
|
624
|
+
`commandSchema: ${stem}.commandSchema->S.castToUnknown, ` ++
|
|
625
|
+
`consumedEventSchema: ${stem}.consumedEventSchema->S.castToUnknown, ` ++
|
|
626
|
+
`eventSchema: ${stem}.eventSchema->S.castToUnknown` ++ "},",
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
lines->Array.push("]")
|
|
630
|
+
}
|
|
631
|
+
|
|
611
632
|
lines->Array.push("")
|
|
612
633
|
lines->Array.push("module Make = (Platform: ReventlessInfra.Platform.T) => {")
|
|
613
634
|
|
|
@@ -399,6 +399,14 @@ function renderComposition(config, resolved, componentChapters) {
|
|
|
399
399
|
lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
|
|
400
400
|
lines.push("");
|
|
401
401
|
lines.push("@val external uiBundleUrl: option<string> = \"process.env." + pluginNameToEnvBase(config.name) + "_UI_BUNDLE_URL\"");
|
|
402
|
+
if (resolved.stateChangeSlices.length !== 0) {
|
|
403
|
+
lines.push("");
|
|
404
|
+
lines.push("let dcbSliceSchemas: array<Reventless.DcbTag.sliceSchemas> = [");
|
|
405
|
+
resolved.stateChangeSlices.forEach(stem => {
|
|
406
|
+
lines.push(" {" + (`name: ` + stem + `.name, `) + (`commandSchema: ` + stem + `.commandSchema->S.castToUnknown, `) + (`consumedEventSchema: ` + stem + `.consumedEventSchema->S.castToUnknown, `) + (`eventSchema: ` + stem + `.eventSchema->S.castToUnknown`) + "},");
|
|
407
|
+
});
|
|
408
|
+
lines.push("]");
|
|
409
|
+
}
|
|
402
410
|
lines.push("");
|
|
403
411
|
lines.push("module Make = (Platform: ReventlessInfra.Platform.T) => {");
|
|
404
412
|
let push = sectionLines => {
|
|
@@ -45,6 +45,30 @@ let channelToString = (channel: channel): string =>
|
|
|
45
45
|
| Push => "Push"
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
The `From:` header a deployment's email sender presents as: the bare address, or
|
|
50
|
+
a display name in front of it.
|
|
51
|
+
|
|
52
|
+
Provider-neutral for the reason everything else here is — the header is the
|
|
53
|
+
RFC's, not a transport's, and two backends formatting it apart would present the
|
|
54
|
+
same deployment under two names. It is applied where the sender is *provisioned*
|
|
55
|
+
rather than at the send, so a transport receives one string and never has to know
|
|
56
|
+
whether a name was configured.
|
|
57
|
+
|
|
58
|
+
Quoted and escaped unconditionally rather than only when the name looks like it
|
|
59
|
+
needs it. The unquoted form excludes characters an ordinary shop name carries — a
|
|
60
|
+
comma above all, which would otherwise split the header into two addresses — and a
|
|
61
|
+
rule applied only where it looks necessary is a rule that gets the exceptions
|
|
62
|
+
wrong.
|
|
63
|
+
*/
|
|
64
|
+
let fromHeader = (~displayName: option<string>, ~address: string): string =>
|
|
65
|
+
switch displayName {
|
|
66
|
+
| None => address
|
|
67
|
+
| Some(name) =>
|
|
68
|
+
let escaped = name->String.replaceAll("\\", "\\\\")->String.replaceAll("\"", "\\\"")
|
|
69
|
+
`"${escaped}" <${address}>`
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
/**
|
|
49
73
|
What to say.
|
|
50
74
|
|
|
@@ -23,6 +23,14 @@ function channelToString(channel) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function fromHeader(displayName, address) {
|
|
27
|
+
if (displayName === undefined) {
|
|
28
|
+
return address;
|
|
29
|
+
}
|
|
30
|
+
let escaped = displayName.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
31
|
+
return `"` + escaped + `" <` + address + `>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
function retriable(failure) {
|
|
27
35
|
switch (failure.TAG) {
|
|
28
36
|
case "Unavailable" :
|
|
@@ -50,6 +58,7 @@ function supports(provider, recipient) {
|
|
|
50
58
|
export {
|
|
51
59
|
channelOf,
|
|
52
60
|
channelToString,
|
|
61
|
+
fromHeader,
|
|
53
62
|
retriable,
|
|
54
63
|
failureReason,
|
|
55
64
|
supports,
|
package/src/types/Transition.res
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// The lifecycle edge a command owns, declared as a value rather than as an
|
|
2
2
|
// attribute on the constructor.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// component's enum, so a misspelling survives to the plugin structure.
|
|
4
|
+
// The attribute this replaces could not say it for a command a host did NOT
|
|
5
|
+
// declare: a variant spread splices members, while the attribute lowered to a
|
|
6
|
+
// dict on the parent union, so a spliced command arrived carrying no edge at
|
|
7
|
+
// all. Nor could it be checked — the PPX extracts leaf identifiers as strings,
|
|
8
|
+
// and the states belong to another component's enum, so a misspelling survived
|
|
9
|
+
// to the plugin structure.
|
|
11
10
|
//
|
|
12
11
|
// A `command => t<'state>` switch answers both. It is exhaustive, so a spliced
|
|
13
12
|
// constructor is a compile error until the host says what it does; and `'state`
|