@reventlessdev/reventless-spec 3.0.0-alpha.100

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 (139) hide show
  1. package/CHANGELOG.md +931 -0
  2. package/LICENSE +202 -0
  3. package/README.md +109 -0
  4. package/package.json +49 -0
  5. package/rescript.json +32 -0
  6. package/run-generator.mjs +2 -0
  7. package/run-platform-generator.mjs +2 -0
  8. package/scripts/generate-currency.mjs +215 -0
  9. package/scripts/iso-4217-list-one.xml +1956 -0
  10. package/src/AnsiStyle.res +40 -0
  11. package/src/AnsiStyle.res.mjs +54 -0
  12. package/src/LogPrefix.res +192 -0
  13. package/src/LogPrefix.res.mjs +159 -0
  14. package/src/PackageVersion.res +67 -0
  15. package/src/PackageVersion.res.mjs +81 -0
  16. package/src/components/Aggregate.res +64 -0
  17. package/src/components/Aggregate.res.mjs +2 -0
  18. package/src/components/AutomationSlice.res +279 -0
  19. package/src/components/AutomationSlice.res.mjs +30 -0
  20. package/src/components/CapabilityManifest.res +74 -0
  21. package/src/components/CapabilityManifest.res.mjs +61 -0
  22. package/src/components/ComponentKind.res +99 -0
  23. package/src/components/ComponentKind.res.mjs +125 -0
  24. package/src/components/Counter.res +24 -0
  25. package/src/components/Counter.res.mjs +2 -0
  26. package/src/components/DcbDecode.res +118 -0
  27. package/src/components/DcbDecode.res.mjs +100 -0
  28. package/src/components/DcbScopeInference.res +244 -0
  29. package/src/components/DcbScopeInference.res.mjs +177 -0
  30. package/src/components/DcbTag.res +1335 -0
  31. package/src/components/DcbTag.res.mjs +898 -0
  32. package/src/components/DcbValidation.res +427 -0
  33. package/src/components/DcbValidation.res.mjs +423 -0
  34. package/src/components/DisplayName.res +40 -0
  35. package/src/components/DisplayName.res.mjs +26 -0
  36. package/src/components/ExtensionPoint.res +27 -0
  37. package/src/components/ExtensionPoint.res.mjs +2 -0
  38. package/src/components/InboundTranslationSlice.res +85 -0
  39. package/src/components/InboundTranslationSlice.res.mjs +2 -0
  40. package/src/components/OutboundTranslationSlice.res +153 -0
  41. package/src/components/OutboundTranslationSlice.res.mjs +2 -0
  42. package/src/components/Plugin.res +538 -0
  43. package/src/components/Plugin.res.mjs +264 -0
  44. package/src/components/PluginName.res +39 -0
  45. package/src/components/PluginName.res.mjs +45 -0
  46. package/src/components/ReadModel.res +199 -0
  47. package/src/components/ReadModel.res.mjs +18 -0
  48. package/src/components/Reference.res +55 -0
  49. package/src/components/Reference.res.mjs +50 -0
  50. package/src/components/Snapshot.res +26 -0
  51. package/src/components/Snapshot.res.mjs +2 -0
  52. package/src/components/StateAnnotations.res +97 -0
  53. package/src/components/StateAnnotations.res.mjs +15 -0
  54. package/src/components/StateChangeSlice.res +131 -0
  55. package/src/components/StateChangeSlice.res.mjs +2 -0
  56. package/src/components/StateViewSlice.res +123 -0
  57. package/src/components/StateViewSlice.res.mjs +2 -0
  58. package/src/components/Task.res +62 -0
  59. package/src/components/Task.res.mjs +2 -0
  60. package/src/generator/Codegen.res +842 -0
  61. package/src/generator/Codegen.res.mjs +565 -0
  62. package/src/generator/Config.res +106 -0
  63. package/src/generator/Config.res.mjs +69 -0
  64. package/src/generator/Discovery.res +230 -0
  65. package/src/generator/Discovery.res.mjs +198 -0
  66. package/src/generator/Generator_Node.res +14 -0
  67. package/src/generator/Generator_Node.res.mjs +18 -0
  68. package/src/generator/Pairing.res +460 -0
  69. package/src/generator/Pairing.res.mjs +415 -0
  70. package/src/generator/PlatformCodegen.res +207 -0
  71. package/src/generator/PlatformCodegen.res.mjs +154 -0
  72. package/src/generator/PlatformGenerator.res +126 -0
  73. package/src/generator/PlatformGenerator.res.mjs +114 -0
  74. package/src/generator/PlatformManifests.res +203 -0
  75. package/src/generator/PlatformManifests.res.mjs +212 -0
  76. package/src/generator/PluginGenerator.res +57 -0
  77. package/src/generator/PluginGenerator.res.mjs +73 -0
  78. package/src/semantic/Bytes.res +54 -0
  79. package/src/semantic/Bytes.res.mjs +38 -0
  80. package/src/semantic/Capabilities.res +43 -0
  81. package/src/semantic/Capabilities.res.mjs +17 -0
  82. package/src/semantic/Color.res +51 -0
  83. package/src/semantic/Color.res.mjs +29 -0
  84. package/src/semantic/Currency.res +598 -0
  85. package/src/semantic/Currency.res.mjs +743 -0
  86. package/src/semantic/DateRange.res +148 -0
  87. package/src/semantic/DateRange.res.mjs +74 -0
  88. package/src/semantic/Duration.res +53 -0
  89. package/src/semantic/Duration.res.mjs +26 -0
  90. package/src/semantic/Email.res +51 -0
  91. package/src/semantic/Email.res.mjs +31 -0
  92. package/src/semantic/GeoPoint.res +226 -0
  93. package/src/semantic/GeoPoint.res.mjs +190 -0
  94. package/src/semantic/Geocoding.res +127 -0
  95. package/src/semantic/Geocoding.res.mjs +36 -0
  96. package/src/semantic/Money.res +196 -0
  97. package/src/semantic/Money.res.mjs +138 -0
  98. package/src/semantic/Offload.res +294 -0
  99. package/src/semantic/Offload.res.mjs +191 -0
  100. package/src/semantic/Percent.res +53 -0
  101. package/src/semantic/Percent.res.mjs +33 -0
  102. package/src/semantic/Phone.res +55 -0
  103. package/src/semantic/Phone.res.mjs +29 -0
  104. package/src/semantic/Semantic.res +162 -0
  105. package/src/semantic/Semantic.res.mjs +95 -0
  106. package/src/semantic/StorageRef.res +164 -0
  107. package/src/semantic/StorageRef.res.mjs +111 -0
  108. package/src/semantic/Url.res +66 -0
  109. package/src/semantic/Url.res.mjs +48 -0
  110. package/src/types/Authorization.res +23 -0
  111. package/src/types/Authorization.res.mjs +33 -0
  112. package/src/types/Behavior.res +86 -0
  113. package/src/types/Behavior.res.mjs +2 -0
  114. package/src/types/DateTime.res +29 -0
  115. package/src/types/DateTime.res.mjs +16 -0
  116. package/src/types/EventMapping.res +100 -0
  117. package/src/types/EventMapping.res.mjs +15 -0
  118. package/src/types/Handler.res +30 -0
  119. package/src/types/Handler.res.mjs +2 -0
  120. package/src/types/Id.res +75 -0
  121. package/src/types/Id.res.mjs +37 -0
  122. package/src/types/Identity.res +46 -0
  123. package/src/types/Identity.res.mjs +51 -0
  124. package/src/types/Message.res +326 -0
  125. package/src/types/Message.res.mjs +186 -0
  126. package/src/types/Projection.res +220 -0
  127. package/src/types/Projection.res.mjs +44 -0
  128. package/src/types/QueryEngine.res +123 -0
  129. package/src/types/QueryEngine.res.mjs +12 -0
  130. package/src/types/ReadConsistency.res +38 -0
  131. package/src/types/ReadConsistency.res.mjs +29 -0
  132. package/src/types/Schedule.res +65 -0
  133. package/src/types/Schedule.res.mjs +68 -0
  134. package/src/types/SideEffect.res +45 -0
  135. package/src/types/SideEffect.res.mjs +2 -0
  136. package/src/types/StoredEvent.res +46 -0
  137. package/src/types/StoredEvent.res.mjs +32 -0
  138. package/src/types/Visibility.res +24 -0
  139. package/src/types/Visibility.res.mjs +25 -0
@@ -0,0 +1,460 @@
1
+ // Applies pairing rules to discovered files.
2
+ // - Aggregates: pairs Foo + FooBehavior; finds Foo_EventMappings if present
3
+ // - ReadModels: pairs FooReadModel + FooProjections; extracts mapping module names from projections file
4
+ // - Slices (Plan 02): pair X + X_<Kind> where Kind is the implementation
5
+ // suffix matching the slice's folder. The slice arrays carry only spec
6
+ // stems; the corresponding impl stem is `<stem>_<implSuffix>`.
7
+ // * StateChangeSlice / StateViewSlice → Behavior / Projection
8
+ // * AutomationSlice → Automation
9
+ // * In/OutboundTranslationSlice → Translation
10
+ // - ExtensionPoints: groups by epGroup, counts mappings, selects Make variant
11
+
12
+ // Implementation-file suffix per slice kind. Used to filter impl files out
13
+ // of the slice arrays (they're paired with their spec stems via this
14
+ // suffix at codegen time) and to compute the impl module reference.
15
+ let implSuffixForStateChange = "_Behavior"
16
+ let implSuffixForStateView = "_Projection"
17
+ let implSuffixForAutomation = "_Automation"
18
+ let implSuffixForTranslation = "_Translation"
19
+ // Plan 04: AutomationSlice splits source-side concerns into a `_Mappings`
20
+ // sibling. Treated as an impl file so it doesn't surface as a slice spec.
21
+ let mappingsSuffixForAutomation = "_Mappings"
22
+
23
+ let isImplStem = (stem: string): bool =>
24
+ stem->String.endsWith(implSuffixForStateChange)
25
+ || stem->String.endsWith(implSuffixForStateView)
26
+ || stem->String.endsWith(implSuffixForAutomation)
27
+ || stem->String.endsWith(implSuffixForTranslation)
28
+ || stem->String.endsWith(mappingsSuffixForAutomation)
29
+
30
+ type aggregateDef = {spec: string, behavior: string, eventMappings: option<string>, isAsync: bool}
31
+ // readModelDef pairs a ReadModel spec with its sibling `_Projections.res`
32
+ // file. Codegen emits `Platform.ReadModel.Make(<spec>, <projections>)` and
33
+ // expects the projections file to declare `let mappings` via
34
+ // `@@reventless.mappings`. `stream: true` (the `ReadModelStream/` folder) flips
35
+ // the emitted factory to `Platform.ReadModelStream.Make` — a DynamoDB-Stream-
36
+ // backed QueryDb that drives AppSync Events (Source B) live updates. Stream and
37
+ // non-stream read models are otherwise identical downstream (resolvers, AutoUI
38
+ // manifest, pluginStructure, query field).
39
+ type readModelDef = {
40
+ readModel: string,
41
+ projections: string,
42
+ stream: bool,
43
+ }
44
+ type extensionPointDef = {group: option<string>, mappings: array<string>}
45
+
46
+ type resolved = {
47
+ stateChangeSlices: array<string>,
48
+ stateViewSlices: array<string>,
49
+ stateViewSlicesStream: array<string>,
50
+ automationSlices: array<string>,
51
+ inboundTranslationSlices: array<string>,
52
+ outboundTranslationSlices: array<string>,
53
+ // stem → declared targetName (None = not declared or explicitly None)
54
+ automationSliceTargets: Dict.t<option<string>>,
55
+ inboundTranslationSliceTargets: Dict.t<option<string>>,
56
+ outboundTranslationSliceTargets: Dict.t<option<string>>,
57
+ // StateChangeSlice stems carrying `@@reventless.async` — codegen emits
58
+ // `Platform.StateChangeSlice.MakeAsync` for these (CommandPending response).
59
+ asyncStateChangeSlices: Dict.t<bool>,
60
+ // Spec names of StateChangeSlices / StateViewSlices (incl. Stream) carrying
61
+ // `@@reventless.systemCallable` — codegen threads them as
62
+ // `~systemCallableComponents` on `Platform.Plugin.make` so the AWS provider
63
+ // emits the dual-auth `@aws_cognito_user_pools(...) @aws_iam` directive on
64
+ // their GraphQL fields (deploy-time SigV4 system callers). Sorted.
65
+ systemCallableComponents: array<string>,
66
+ aggregates: array<aggregateDef>,
67
+ readModels: array<readModelDef>,
68
+ tasks: array<string>,
69
+ extensionPoints: array<extensionPointDef>,
70
+ extensions: array<string>,
71
+ }
72
+
73
+ // Scan srcDir recursively for `<Agg>_EventMappings.res` and `<Agg>_Mappings.res`
74
+ // files and collect {AggName → mappingStem} dict. Supports the legacy flat
75
+ // layout (`srcDir/EventMappings/`), the per-entity layout
76
+ // (`srcDir/<Agg>/Aggregate/<Agg>_EventMappings.res`), AND the Phase-3-2
77
+ // post-rename form (`<Agg>_Mappings.res`). `Plugin/`, `tests/`, `lib/` are
78
+ // skipped. The new `_Mappings.res` form is only matched inside an
79
+ // `Aggregate/` (or `EventMappings/`) folder so AutomationSlice's own
80
+ // `_Mappings.res` siblings don't get scooped up.
81
+ let findEventMappings = (~srcDir: string): Dict.t<string> => {
82
+ let dict = Dict.make()
83
+ let parentDirIsAggregate = (relSegments: array<string>): bool =>
84
+ switch relSegments->Array.length {
85
+ | 0 => false
86
+ | n =>
87
+ let parent = relSegments->Array.getUnsafe(n - 1)
88
+ parent === "Aggregate" || parent === "EventMappings"
89
+ }
90
+ let rec walk = (dir: string, segments: array<string>) =>
91
+ Generator_Node.readDir(dir)->Array.forEach(entry => {
92
+ let name = entry->NodeFs.direntName
93
+ if entry->NodeFs.isDirectory {
94
+ if name !== "Plugin" && name !== "tests" && name !== "lib" {
95
+ walk(NodePath.join([dir, name]), Array.concat(segments, [name]))
96
+ }
97
+ } else if entry->NodeFs.isFile {
98
+ if name->String.endsWith("_Mappings.res") && parentDirIsAggregate(segments) {
99
+ let stem = name->String.slice(~start=0, ~end=name->String.length - 4)
100
+ // "_Mappings" = 9 chars
101
+ let aggName = stem->String.slice(~start=0, ~end=stem->String.length - 9)
102
+ Dict.set(dict, aggName, stem)
103
+ } else if name->String.endsWith("_EventMappings.res") {
104
+ let stem = name->String.slice(~start=0, ~end=name->String.length - 4)
105
+ // "_EventMappings" = 14 chars
106
+ let aggName = stem->String.slice(~start=0, ~end=stem->String.length - 14)
107
+ // Don't overwrite a `_Mappings.res` discovered earlier.
108
+ switch Dict.get(dict, aggName) {
109
+ | Some(_) => ()
110
+ | None => Dict.set(dict, aggName, stem)
111
+ }
112
+ }
113
+ }
114
+ })
115
+ if NodeFs.existsSync(srcDir) {
116
+ walk(srcDir, [])
117
+ }
118
+ dict
119
+ }
120
+
121
+ let sortedStems = (stems: array<string>): array<string> =>
122
+ stems->Array.toSorted((a, b) => if a < b {-1.0} else if a > b {1.0} else {0.0})
123
+
124
+ // Read a slice spec file and extract the value of `let targetName = ...`.
125
+ // Returns Some(name) for string literals (including inside Some("...")),
126
+ // or None for `let targetName = None` or when the line is absent.
127
+ let extractTargetName = (filePath: string): option<string> => {
128
+ try {
129
+ let content = NodeFs.readFileSync(filePath)
130
+ let result = ref(None)
131
+ content->String.split("\n")->Array.forEach(line => {
132
+ let trimmed = line->String.trimStart
133
+ if trimmed->String.startsWith("let targetName = ") {
134
+ let firstQuote = trimmed->String.indexOf("\"")
135
+ let lastQuote = trimmed->String.lastIndexOf("\"")
136
+ if firstQuote >= 0 && lastQuote > firstQuote {
137
+ result := Some(trimmed->String.slice(~start=firstQuote + 1, ~end=lastQuote))
138
+ }
139
+ // `let targetName = None` → no quotes found → result stays None
140
+ }
141
+ })
142
+ result.contents
143
+ } catch {
144
+ | _ => None
145
+ }
146
+ }
147
+
148
+ // Returns true if the spec file declares the given file-level attribute
149
+ // (e.g. `@@reventless.async`, `@@reventless.systemCallable`). Falls back to
150
+ // false on read errors so a transient FS failure can't flip a component
151
+ // into the opt-in behavior — the safe default is the attribute being absent.
152
+ let hasFileAttribute = (filePath: string, ~attr: string): bool => {
153
+ try {
154
+ let content = NodeFs.readFileSync(filePath)
155
+ let found = ref(false)
156
+ content->String.split("\n")->Array.forEach(line => {
157
+ let trimmed = line->String.trimStart
158
+ // Match the bare attribute and tolerate a payload arg in the future.
159
+ if (
160
+ trimmed->String.startsWith(attr ++ " ") ||
161
+ trimmed->String.startsWith(attr ++ "(") ||
162
+ trimmed === attr
163
+ ) {
164
+ found := true
165
+ }
166
+ })
167
+ found.contents
168
+ } catch {
169
+ | _ => false
170
+ }
171
+ }
172
+
173
+ // `@@reventless.async` — Aggregate or StateChangeSlice opt-in to async
174
+ // command dispatch (CommandPending response).
175
+ let hasAsyncAttribute = (filePath: string): bool =>
176
+ hasFileAttribute(filePath, ~attr="@@reventless.async")
177
+
178
+ // `@@reventless.systemCallable` — StateChangeSlice / StateViewSlice opt-in to
179
+ // deploy-time IAM (SigV4) invocation of the component's GraphQL fields.
180
+ let hasSystemCallableAttribute = (filePath: string): bool =>
181
+ hasFileAttribute(filePath, ~attr="@@reventless.systemCallable")
182
+
183
+ // The effective spec name of a file: the explicit `@@reventless.spec("Name")`
184
+ // payload when present, the filename stem otherwise (mirroring the PPX's name
185
+ // derivation). Needed because `systemCallableComponents` is matched against
186
+ // `Spec.name` at deploy time — a stem-only value would silently miss specs
187
+ // with an explicit name override.
188
+ let effectiveSpecName = (filePath: string, ~stem: string): string => {
189
+ try {
190
+ let content = NodeFs.readFileSync(filePath)
191
+ let name = ref(None)
192
+ content->String.split("\n")->Array.forEach(line => {
193
+ let trimmed = line->String.trimStart
194
+ if trimmed->String.startsWith("@@reventless.spec(") {
195
+ let firstQuote = trimmed->String.indexOf("\"")
196
+ let lastQuote = trimmed->String.lastIndexOf("\"")
197
+ if firstQuote >= 0 && lastQuote > firstQuote {
198
+ name := Some(trimmed->String.slice(~start=firstQuote + 1, ~end=lastQuote))
199
+ }
200
+ }
201
+ })
202
+ name.contents->Option.getOr(stem)
203
+ } catch {
204
+ | _ => stem
205
+ }
206
+ }
207
+
208
+ // Partition the ExtensionPoint mapping files into flat (ungrouped) and grouped
209
+ // `extensionPointDef`s: every mapping sharing an `epGroup` collects into one
210
+ // grouped entry; the ungrouped ones share a single flat (`group: None`) entry.
211
+ // Pure (no I/O) so the grouping is unit-testable — in particular the A8 fix:
212
+ // `Option.getOr`'s eager default previously re-created the group's array every
213
+ // iteration, so a group with ≥2 mappings collapsed to `[]` and the generator
214
+ // emitted a `Plugin.res` referencing a never-generated module.
215
+ let groupExtensionPoints = (epFiles: array<Discovery.discoveredFile>): array<extensionPointDef> => {
216
+ let epByGroup: Dict.t<array<string>> = Dict.make()
217
+ let flatEpMappings: array<string> = []
218
+
219
+ epFiles->Array.forEach(({stem, epGroup}) =>
220
+ switch epGroup {
221
+ | None => flatEpMappings->Array.push(stem)
222
+ | Some(g) =>
223
+ switch Dict.get(epByGroup, g) {
224
+ | Some(arr) => arr->Array.push(stem)
225
+ | None => Dict.set(epByGroup, g, [stem])
226
+ }
227
+ }
228
+ )
229
+
230
+ let extensionPoints: array<extensionPointDef> = []
231
+ if flatEpMappings->Array.length > 0 {
232
+ extensionPoints->Array.push({group: None, mappings: flatEpMappings->sortedStems})
233
+ }
234
+ Dict.toArray(epByGroup)->Array.forEach(((group, mappings)) =>
235
+ extensionPoints->Array.push({group: Some(group), mappings: mappings->sortedStems})
236
+ )
237
+ extensionPoints
238
+ }
239
+
240
+ let resolve = (discovered: array<Discovery.discoveredFile>, ~srcDir: string): resolved => {
241
+ let eventMappings = findEventMappings(~srcDir)
242
+
243
+ let stateChangeSlices = []
244
+ let stateViewSlices = []
245
+ let stateViewSlicesStream = []
246
+ let automationSlices = []
247
+ let inboundTranslationSlices = []
248
+ let outboundTranslationSlices = []
249
+ // relPath dicts for slices that declare targetName
250
+ let automationSliceRelPaths: Dict.t<string> = Dict.make()
251
+ let inboundTranslationSliceRelPaths: Dict.t<string> = Dict.make()
252
+ let outboundTranslationSliceRelPaths: Dict.t<string> = Dict.make()
253
+ let aggregateSpecs: array<string> = []
254
+ let aggregateSpecRelPaths: Dict.t<string> = Dict.make()
255
+ let stateChangeSliceRelPaths: Dict.t<string> = Dict.make()
256
+ // Shared by StateViewSlice and StateViewSliceStream (stems are unique).
257
+ let stateViewSliceRelPaths: Dict.t<string> = Dict.make()
258
+ let aggregateBehaviors: array<string> = []
259
+ let readModelStems: array<string> = []
260
+ // Stream-enabled read model spec stems (the `ReadModelStream/` folder).
261
+ let readModelStreamStems: array<string> = []
262
+ // projectionsByRelPath: stem → relPath mapping for projections files (shared
263
+ // by ReadModel and ReadModelStream — the projection file shape is identical).
264
+ let projectionsByRelPath: Dict.t<string> = Dict.make()
265
+ let tasks = []
266
+ let epFiles: array<Discovery.discoveredFile> = []
267
+ let extensions = []
268
+
269
+ // Classify discovered files by component type. For the five slice
270
+ // families, impl files (X_Behavior / X_Projection / X_Automation /
271
+ // X_Translation) are filtered out: they're paired with their spec stems
272
+ // at codegen time via the `_<ImplKind>` suffix convention.
273
+ discovered->Array.forEach(({stem, componentType, epGroup, relPath}) => {
274
+ switch componentType {
275
+ | StateChangeSlice =>
276
+ if !isImplStem(stem) {
277
+ stateChangeSlices->Array.push(stem)
278
+ stateChangeSliceRelPaths->Dict.set(stem, relPath)
279
+ }
280
+ | StateViewSlice =>
281
+ if !isImplStem(stem) {
282
+ stateViewSlices->Array.push(stem)
283
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
284
+ }
285
+ | StateViewSliceStream =>
286
+ if !isImplStem(stem) {
287
+ stateViewSlicesStream->Array.push(stem)
288
+ stateViewSliceRelPaths->Dict.set(stem, relPath)
289
+ }
290
+ | AutomationSlice =>
291
+ if !isImplStem(stem) {
292
+ automationSlices->Array.push(stem)
293
+ automationSliceRelPaths->Dict.set(stem, relPath)
294
+ }
295
+ | InboundTranslationSlice =>
296
+ if !isImplStem(stem) {
297
+ inboundTranslationSlices->Array.push(stem)
298
+ inboundTranslationSliceRelPaths->Dict.set(stem, relPath)
299
+ }
300
+ | OutboundTranslationSlice =>
301
+ if !isImplStem(stem) {
302
+ outboundTranslationSlices->Array.push(stem)
303
+ outboundTranslationSliceRelPaths->Dict.set(stem, relPath)
304
+ }
305
+ | Aggregate =>
306
+ if stem->String.endsWith("Behavior") {
307
+ aggregateBehaviors->Array.push(stem)
308
+ } else {
309
+ aggregateSpecs->Array.push(stem)
310
+ aggregateSpecRelPaths->Dict.set(stem, relPath)
311
+ }
312
+ | ReadModel =>
313
+ if stem->String.endsWith("Projections") {
314
+ Dict.set(projectionsByRelPath, stem, relPath)
315
+ } else {
316
+ readModelStems->Array.push(stem)
317
+ }
318
+ | ReadModelStream =>
319
+ if stem->String.endsWith("Projections") {
320
+ Dict.set(projectionsByRelPath, stem, relPath)
321
+ } else {
322
+ readModelStreamStems->Array.push(stem)
323
+ }
324
+ | Task => tasks->Array.push(stem)
325
+ | ExtensionPoint => epFiles->Array.push({stem, componentType, epGroup, relPath})
326
+ | Extension => extensions->Array.push(stem)
327
+ }
328
+ })
329
+
330
+ // ── Aggregate pairing ──────────────────────────────────────────────────────
331
+ // Tries the post-Phase-3.1 `<Spec>_Behavior` form first, then falls back
332
+ // to the legacy `<Spec>Behavior` (no underscore).
333
+ let aggregates =
334
+ aggregateSpecs
335
+ ->sortedStems
336
+ ->Array.filterMap(spec => {
337
+ let underscored = spec ++ "_Behavior"
338
+ let bare = spec ++ "Behavior"
339
+ let behaviorStem = if aggregateBehaviors->Array.includes(underscored) {
340
+ Some(underscored)
341
+ } else if aggregateBehaviors->Array.includes(bare) {
342
+ Some(bare)
343
+ } else {
344
+ None
345
+ }
346
+ switch behaviorStem {
347
+ | Some(behavior) =>
348
+ let isAsync = switch Dict.get(aggregateSpecRelPaths, spec) {
349
+ | None => false
350
+ | Some(relPath) => hasAsyncAttribute(NodePath.join([srcDir, relPath]))
351
+ }
352
+ Some({
353
+ spec,
354
+ behavior,
355
+ eventMappings: Dict.get(eventMappings, spec),
356
+ isAsync,
357
+ })
358
+ | None =>
359
+ Console.warn("Generator: Aggregate spec `" ++ spec ++ "` has no matching `" ++ underscored ++ "` or `" ++ bare ++ "` — skipping")
360
+ None
361
+ }
362
+ })
363
+
364
+ // ── ReadModel pairing ──────────────────────────────────────────────────────
365
+ // Pairs spec `<Plural>` with impl `<Plural>_Projections.res`. The legacy
366
+ // unpaired forms (`<Plural>ReadModel.res` / `<Plural>Projections.res`) are
367
+ // no longer recognised — see PR3-5 of the close-ppx-gaps-and-unify-naming
368
+ // plan for the migration; downstream codebases should rename to the
369
+ // underscored form.
370
+ let pairReadModel = (~stream: bool, rm: string): option<readModelDef> => {
371
+ // Strip optional ReadModel suffix to get the base plural name.
372
+ let baseName = if rm->String.endsWith("ReadModel") {
373
+ rm->String.slice(~start=0, ~end=rm->String.length - 9)
374
+ } else {
375
+ rm
376
+ }
377
+ let underscoredProj = baseName ++ "_Projections"
378
+ switch Dict.get(projectionsByRelPath, underscoredProj) {
379
+ | None =>
380
+ Console.warn("Generator: ReadModel `" ++ rm ++ "` has no matching `" ++ underscoredProj ++ ".res` — skipping")
381
+ None
382
+ | Some(_) =>
383
+ Some({readModel: rm, projections: underscoredProj, stream})
384
+ }
385
+ }
386
+ let readModels = Array.concat(
387
+ readModelStems->sortedStems->Array.filterMap(rm => pairReadModel(~stream=false, rm)),
388
+ readModelStreamStems->sortedStems->Array.filterMap(rm => pairReadModel(~stream=true, rm)),
389
+ )
390
+
391
+ // ── ExtensionPoint grouping ────────────────────────────────────────────────
392
+ let extensionPoints = groupExtensionPoints(epFiles)
393
+
394
+ // ── targetName extraction ──────────────────────────────────────────────────
395
+ let buildTargets = (stems: array<string>, relPaths: Dict.t<string>): Dict.t<option<string>> => {
396
+ let dict = Dict.make()
397
+ stems->Array.forEach(stem => {
398
+ let target = switch Dict.get(relPaths, stem) {
399
+ | None => None
400
+ | Some(relPath) => extractTargetName(NodePath.join([srcDir, relPath]))
401
+ }
402
+ dict->Dict.set(stem, target)
403
+ })
404
+ dict
405
+ }
406
+
407
+ {
408
+ stateChangeSlices: stateChangeSlices->sortedStems,
409
+ stateViewSlices: stateViewSlices->sortedStems,
410
+ stateViewSlicesStream: stateViewSlicesStream->sortedStems,
411
+ automationSlices: automationSlices->sortedStems,
412
+ inboundTranslationSlices: inboundTranslationSlices->sortedStems,
413
+ outboundTranslationSlices: outboundTranslationSlices->sortedStems,
414
+ automationSliceTargets: buildTargets(automationSlices, automationSliceRelPaths),
415
+ inboundTranslationSliceTargets: buildTargets(
416
+ inboundTranslationSlices,
417
+ inboundTranslationSliceRelPaths,
418
+ ),
419
+ outboundTranslationSliceTargets: buildTargets(
420
+ outboundTranslationSlices,
421
+ outboundTranslationSliceRelPaths,
422
+ ),
423
+ asyncStateChangeSlices: {
424
+ let d = Dict.make()
425
+ stateChangeSlices->Array.forEach(stem => {
426
+ switch Dict.get(stateChangeSliceRelPaths, stem) {
427
+ | None => ()
428
+ | Some(relPath) =>
429
+ if hasAsyncAttribute(NodePath.join([srcDir, relPath])) {
430
+ d->Dict.set(stem, true)
431
+ }
432
+ }
433
+ })
434
+ d
435
+ },
436
+ systemCallableComponents: {
437
+ let names = []
438
+ let collect = (stems: array<string>, relPaths: Dict.t<string>) =>
439
+ stems->Array.forEach(stem => {
440
+ switch Dict.get(relPaths, stem) {
441
+ | None => ()
442
+ | Some(relPath) =>
443
+ let filePath = NodePath.join([srcDir, relPath])
444
+ if hasSystemCallableAttribute(filePath) {
445
+ names->Array.push(effectiveSpecName(filePath, ~stem))
446
+ }
447
+ }
448
+ })
449
+ collect(stateChangeSlices, stateChangeSliceRelPaths)
450
+ collect(stateViewSlices, stateViewSliceRelPaths)
451
+ collect(stateViewSlicesStream, stateViewSliceRelPaths)
452
+ names->sortedStems
453
+ },
454
+ aggregates,
455
+ readModels,
456
+ tasks: tasks->sortedStems,
457
+ extensionPoints,
458
+ extensions: extensions->sortedStems,
459
+ }
460
+ }