@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,415 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
8
+
9
+ let implSuffixForStateChange = "_Behavior";
10
+
11
+ let implSuffixForStateView = "_Projection";
12
+
13
+ let implSuffixForAutomation = "_Automation";
14
+
15
+ let implSuffixForTranslation = "_Translation";
16
+
17
+ let mappingsSuffixForAutomation = "_Mappings";
18
+
19
+ function isImplStem(stem) {
20
+ if (stem.endsWith(implSuffixForStateChange) || stem.endsWith(implSuffixForStateView) || stem.endsWith(implSuffixForAutomation) || stem.endsWith(implSuffixForTranslation)) {
21
+ return true;
22
+ } else {
23
+ return stem.endsWith(mappingsSuffixForAutomation);
24
+ }
25
+ }
26
+
27
+ function findEventMappings(srcDir) {
28
+ let dict = {};
29
+ let parentDirIsAggregate = relSegments => {
30
+ let n = relSegments.length;
31
+ if (n === 0) {
32
+ return false;
33
+ }
34
+ let parent = relSegments[n - 1 | 0];
35
+ if (parent === "Aggregate") {
36
+ return true;
37
+ } else {
38
+ return parent === "EventMappings";
39
+ }
40
+ };
41
+ let walk = (dir, segments) => {
42
+ Generator_Node$Reventless.readDir(dir).forEach(entry => {
43
+ let name = entry.name;
44
+ if (entry.isDirectory()) {
45
+ if (name !== "Plugin" && name !== "tests" && name !== "lib") {
46
+ return walk(Nodepath.join(dir, name), segments.concat([name]));
47
+ } else {
48
+ return;
49
+ }
50
+ }
51
+ if (!entry.isFile()) {
52
+ return;
53
+ }
54
+ if (name.endsWith("_Mappings.res") && parentDirIsAggregate(segments)) {
55
+ let stem = name.slice(0, name.length - 4 | 0);
56
+ let aggName = stem.slice(0, stem.length - 9 | 0);
57
+ dict[aggName] = stem;
58
+ return;
59
+ }
60
+ if (!name.endsWith("_EventMappings.res")) {
61
+ return;
62
+ }
63
+ let stem$1 = name.slice(0, name.length - 4 | 0);
64
+ let aggName$1 = stem$1.slice(0, stem$1.length - 14 | 0);
65
+ let match = dict[aggName$1];
66
+ if (match !== undefined) {
67
+ return;
68
+ } else {
69
+ dict[aggName$1] = stem$1;
70
+ return;
71
+ }
72
+ });
73
+ };
74
+ if (Nodefs.existsSync(srcDir)) {
75
+ walk(srcDir, []);
76
+ }
77
+ return dict;
78
+ }
79
+
80
+ function sortedStems(stems) {
81
+ return stems.toSorted((a, b) => {
82
+ if (a < b) {
83
+ return -1.0;
84
+ } else if (a > b) {
85
+ return 1.0;
86
+ } else {
87
+ return 0.0;
88
+ }
89
+ });
90
+ }
91
+
92
+ function extractTargetName(filePath) {
93
+ try {
94
+ let content = Nodefs.readFileSync(filePath, "utf8");
95
+ let result = {
96
+ contents: undefined
97
+ };
98
+ content.split("\n").forEach(line => {
99
+ let trimmed = line.trimStart();
100
+ if (!trimmed.startsWith("let targetName = ")) {
101
+ return;
102
+ }
103
+ let firstQuote = trimmed.indexOf("\"");
104
+ let lastQuote = trimmed.lastIndexOf("\"");
105
+ if (firstQuote >= 0 && lastQuote > firstQuote) {
106
+ result.contents = trimmed.slice(firstQuote + 1 | 0, lastQuote);
107
+ return;
108
+ }
109
+ });
110
+ return result.contents;
111
+ } catch (exn) {
112
+ return;
113
+ }
114
+ }
115
+
116
+ function hasFileAttribute(filePath, attr) {
117
+ try {
118
+ let content = Nodefs.readFileSync(filePath, "utf8");
119
+ let found = {
120
+ contents: false
121
+ };
122
+ content.split("\n").forEach(line => {
123
+ let trimmed = line.trimStart();
124
+ if (trimmed.startsWith(attr + " ") || trimmed.startsWith(attr + "(") || trimmed === attr) {
125
+ found.contents = true;
126
+ return;
127
+ }
128
+ });
129
+ return found.contents;
130
+ } catch (exn) {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ function hasAsyncAttribute(filePath) {
136
+ return hasFileAttribute(filePath, "@@reventless.async");
137
+ }
138
+
139
+ function hasSystemCallableAttribute(filePath) {
140
+ return hasFileAttribute(filePath, "@@reventless.systemCallable");
141
+ }
142
+
143
+ function effectiveSpecName(filePath, stem) {
144
+ try {
145
+ let content = Nodefs.readFileSync(filePath, "utf8");
146
+ let name = {
147
+ contents: undefined
148
+ };
149
+ content.split("\n").forEach(line => {
150
+ let trimmed = line.trimStart();
151
+ if (!trimmed.startsWith("@@reventless.spec(")) {
152
+ return;
153
+ }
154
+ let firstQuote = trimmed.indexOf("\"");
155
+ let lastQuote = trimmed.lastIndexOf("\"");
156
+ if (firstQuote >= 0 && lastQuote > firstQuote) {
157
+ name.contents = trimmed.slice(firstQuote + 1 | 0, lastQuote);
158
+ return;
159
+ }
160
+ });
161
+ return Stdlib_Option.getOr(name.contents, stem);
162
+ } catch (exn) {
163
+ return stem;
164
+ }
165
+ }
166
+
167
+ function groupExtensionPoints(epFiles) {
168
+ let epByGroup = {};
169
+ let flatEpMappings = [];
170
+ epFiles.forEach(param => {
171
+ let epGroup = param.epGroup;
172
+ let stem = param.stem;
173
+ if (epGroup !== undefined) {
174
+ let arr = epByGroup[epGroup];
175
+ if (arr !== undefined) {
176
+ arr.push(stem);
177
+ } else {
178
+ epByGroup[epGroup] = [stem];
179
+ }
180
+ return;
181
+ }
182
+ flatEpMappings.push(stem);
183
+ });
184
+ let extensionPoints = [];
185
+ if (flatEpMappings.length !== 0) {
186
+ extensionPoints.push({
187
+ group: undefined,
188
+ mappings: sortedStems(flatEpMappings)
189
+ });
190
+ }
191
+ Object.entries(epByGroup).forEach(param => {
192
+ extensionPoints.push({
193
+ group: param[0],
194
+ mappings: sortedStems(param[1])
195
+ });
196
+ });
197
+ return extensionPoints;
198
+ }
199
+
200
+ function resolve(discovered, srcDir) {
201
+ let eventMappings = findEventMappings(srcDir);
202
+ let stateChangeSlices = [];
203
+ let stateViewSlices = [];
204
+ let stateViewSlicesStream = [];
205
+ let automationSlices = [];
206
+ let inboundTranslationSlices = [];
207
+ let outboundTranslationSlices = [];
208
+ let automationSliceRelPaths = {};
209
+ let inboundTranslationSliceRelPaths = {};
210
+ let outboundTranslationSliceRelPaths = {};
211
+ let aggregateSpecs = [];
212
+ let aggregateSpecRelPaths = {};
213
+ let stateChangeSliceRelPaths = {};
214
+ let stateViewSliceRelPaths = {};
215
+ let aggregateBehaviors = [];
216
+ let readModelStems = [];
217
+ let readModelStreamStems = [];
218
+ let projectionsByRelPath = {};
219
+ let tasks = [];
220
+ let epFiles = [];
221
+ let extensions = [];
222
+ discovered.forEach(param => {
223
+ let relPath = param.relPath;
224
+ let componentType = param.componentType;
225
+ let stem = param.stem;
226
+ switch (componentType) {
227
+ case "StateChangeSlice" :
228
+ if (!isImplStem(stem)) {
229
+ stateChangeSlices.push(stem);
230
+ stateChangeSliceRelPaths[stem] = relPath;
231
+ return;
232
+ } else {
233
+ return;
234
+ }
235
+ case "StateViewSlice" :
236
+ if (!isImplStem(stem)) {
237
+ stateViewSlices.push(stem);
238
+ stateViewSliceRelPaths[stem] = relPath;
239
+ return;
240
+ } else {
241
+ return;
242
+ }
243
+ case "StateViewSliceStream" :
244
+ if (!isImplStem(stem)) {
245
+ stateViewSlicesStream.push(stem);
246
+ stateViewSliceRelPaths[stem] = relPath;
247
+ return;
248
+ } else {
249
+ return;
250
+ }
251
+ case "AutomationSlice" :
252
+ if (!isImplStem(stem)) {
253
+ automationSlices.push(stem);
254
+ automationSliceRelPaths[stem] = relPath;
255
+ return;
256
+ } else {
257
+ return;
258
+ }
259
+ case "InboundTranslationSlice" :
260
+ if (!isImplStem(stem)) {
261
+ inboundTranslationSlices.push(stem);
262
+ inboundTranslationSliceRelPaths[stem] = relPath;
263
+ return;
264
+ } else {
265
+ return;
266
+ }
267
+ case "OutboundTranslationSlice" :
268
+ if (!isImplStem(stem)) {
269
+ outboundTranslationSlices.push(stem);
270
+ outboundTranslationSliceRelPaths[stem] = relPath;
271
+ return;
272
+ } else {
273
+ return;
274
+ }
275
+ case "Aggregate" :
276
+ if (stem.endsWith("Behavior")) {
277
+ aggregateBehaviors.push(stem);
278
+ } else {
279
+ aggregateSpecs.push(stem);
280
+ aggregateSpecRelPaths[stem] = relPath;
281
+ }
282
+ return;
283
+ case "ReadModel" :
284
+ if (stem.endsWith("Projections")) {
285
+ projectionsByRelPath[stem] = relPath;
286
+ } else {
287
+ readModelStems.push(stem);
288
+ }
289
+ return;
290
+ case "ReadModelStream" :
291
+ if (stem.endsWith("Projections")) {
292
+ projectionsByRelPath[stem] = relPath;
293
+ } else {
294
+ readModelStreamStems.push(stem);
295
+ }
296
+ return;
297
+ case "Task" :
298
+ tasks.push(stem);
299
+ return;
300
+ case "ExtensionPoint" :
301
+ epFiles.push({
302
+ stem: stem,
303
+ componentType: componentType,
304
+ epGroup: param.epGroup,
305
+ relPath: relPath
306
+ });
307
+ return;
308
+ case "Extension" :
309
+ extensions.push(stem);
310
+ return;
311
+ }
312
+ });
313
+ let aggregates = Stdlib_Array.filterMap(sortedStems(aggregateSpecs), spec => {
314
+ let underscored = spec + "_Behavior";
315
+ let bare = spec + "Behavior";
316
+ let behaviorStem = aggregateBehaviors.includes(underscored) ? underscored : (
317
+ aggregateBehaviors.includes(bare) ? bare : undefined
318
+ );
319
+ if (behaviorStem !== undefined) {
320
+ let relPath = aggregateSpecRelPaths[spec];
321
+ let isAsync = relPath !== undefined ? hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async") : false;
322
+ return {
323
+ spec: spec,
324
+ behavior: behaviorStem,
325
+ eventMappings: eventMappings[spec],
326
+ isAsync: isAsync
327
+ };
328
+ }
329
+ console.warn("Generator: Aggregate spec `" + spec + "` has no matching `" + underscored + "` or `" + bare + "` — skipping");
330
+ });
331
+ let pairReadModel = (stream, rm) => {
332
+ let baseName = rm.endsWith("ReadModel") ? rm.slice(0, rm.length - 9 | 0) : rm;
333
+ let underscoredProj = baseName + "_Projections";
334
+ let match = projectionsByRelPath[underscoredProj];
335
+ if (match !== undefined) {
336
+ return {
337
+ readModel: rm,
338
+ projections: underscoredProj,
339
+ stream: stream
340
+ };
341
+ } else {
342
+ console.warn("Generator: ReadModel `" + rm + "` has no matching `" + underscoredProj + ".res` — skipping");
343
+ return;
344
+ }
345
+ };
346
+ let readModels = Stdlib_Array.filterMap(sortedStems(readModelStems), rm => pairReadModel(false, rm)).concat(Stdlib_Array.filterMap(sortedStems(readModelStreamStems), rm => pairReadModel(true, rm)));
347
+ let extensionPoints = groupExtensionPoints(epFiles);
348
+ let buildTargets = (stems, relPaths) => {
349
+ let dict = {};
350
+ stems.forEach(stem => {
351
+ let relPath = relPaths[stem];
352
+ let target = relPath !== undefined ? extractTargetName(Nodepath.join(srcDir, relPath)) : undefined;
353
+ dict[stem] = target;
354
+ });
355
+ return dict;
356
+ };
357
+ let d = {};
358
+ let names = [];
359
+ let collect = (stems, relPaths) => {
360
+ stems.forEach(stem => {
361
+ let relPath = relPaths[stem];
362
+ if (relPath === undefined) {
363
+ return;
364
+ }
365
+ let filePath = Nodepath.join(srcDir, relPath);
366
+ if (hasFileAttribute(filePath, "@@reventless.systemCallable")) {
367
+ names.push(effectiveSpecName(filePath, stem));
368
+ return;
369
+ }
370
+ });
371
+ };
372
+ return {
373
+ stateChangeSlices: sortedStems(stateChangeSlices),
374
+ stateViewSlices: sortedStems(stateViewSlices),
375
+ stateViewSlicesStream: sortedStems(stateViewSlicesStream),
376
+ automationSlices: sortedStems(automationSlices),
377
+ inboundTranslationSlices: sortedStems(inboundTranslationSlices),
378
+ outboundTranslationSlices: sortedStems(outboundTranslationSlices),
379
+ automationSliceTargets: buildTargets(automationSlices, automationSliceRelPaths),
380
+ inboundTranslationSliceTargets: buildTargets(inboundTranslationSlices, inboundTranslationSliceRelPaths),
381
+ outboundTranslationSliceTargets: buildTargets(outboundTranslationSlices, outboundTranslationSliceRelPaths),
382
+ asyncStateChangeSlices: (stateChangeSlices.forEach(stem => {
383
+ let relPath = stateChangeSliceRelPaths[stem];
384
+ if (relPath !== undefined && hasFileAttribute(Nodepath.join(srcDir, relPath), "@@reventless.async")) {
385
+ d[stem] = true;
386
+ return;
387
+ }
388
+ }), d),
389
+ systemCallableComponents: (collect(stateChangeSlices, stateChangeSliceRelPaths), collect(stateViewSlices, stateViewSliceRelPaths), collect(stateViewSlicesStream, stateViewSliceRelPaths), sortedStems(names)),
390
+ aggregates: aggregates,
391
+ readModels: readModels,
392
+ tasks: sortedStems(tasks),
393
+ extensionPoints: extensionPoints,
394
+ extensions: sortedStems(extensions)
395
+ };
396
+ }
397
+
398
+ export {
399
+ implSuffixForStateChange,
400
+ implSuffixForStateView,
401
+ implSuffixForAutomation,
402
+ implSuffixForTranslation,
403
+ mappingsSuffixForAutomation,
404
+ isImplStem,
405
+ findEventMappings,
406
+ sortedStems,
407
+ extractTargetName,
408
+ hasFileAttribute,
409
+ hasAsyncAttribute,
410
+ hasSystemCallableAttribute,
411
+ effectiveSpecName,
412
+ groupExtensionPoints,
413
+ resolve,
414
+ }
415
+ /* node:fs Not a pure module */
@@ -0,0 +1,207 @@
1
+ // Pure rendering for the platform generator: union the plugins' capability
2
+ // manifests and emit the committed `PlatformCapabilities.res`.
3
+ //
4
+ // Only the derived list is generated — the platform root stays hand-written
5
+ // and reads `PlatformCapabilities.capabilities`. That keeps a requirement
6
+ // change small and entirely about capabilities in review, and it keeps the
7
+ // generator out of the business of expressing everything a real root does
8
+ // (bootstrap calls, capability handles, stack references).
9
+ //
10
+ // I/O-free so the exact bytes are testable; `PlatformGenerator` owns the file
11
+ // system.
12
+
13
+ /** One plugin's manifest, tagged with the name `deploy-manifest.yaml` lists it
14
+ under — the name provenance comments cite. */
15
+ type pluginManifest = {
16
+ pluginName: string,
17
+ manifest: CapabilityManifest.t,
18
+ }
19
+
20
+ /** One declaring site, keeping which plugin's manifest carried it. */
21
+ type provenance = {
22
+ pluginName: string,
23
+ component: string,
24
+ field: string,
25
+ annotation: option<string>,
26
+ }
27
+
28
+ /** One capability the platform must provision: the qualified `{plugin}.{store}`
29
+ key with every declaring site across every plugin. Identity is the key —
30
+ many fields, in several plugins, legitimately name one store. */
31
+ type unionEntry = {
32
+ kind: CapabilityManifest.kind,
33
+ key: string,
34
+ declaredBy: array<provenance>,
35
+ }
36
+
37
+ /** Union the manifests: entries with one key collapse to one capability, their
38
+ provenance concatenated in manifest order. Sorted by key so the rendered
39
+ file is deterministic. */
40
+ let union = (manifests: array<pluginManifest>): array<unionEntry> => {
41
+ let byKey: dict<unionEntry> = Dict.make()
42
+ manifests->Array.forEach(({pluginName, manifest}) =>
43
+ manifest.capabilities->Array.forEach(entry => {
44
+ let sites =
45
+ entry.declaredBy->Array.map(site => {
46
+ pluginName,
47
+ component: site.component,
48
+ field: site.field,
49
+ annotation: site.annotation,
50
+ })
51
+ switch byKey->Dict.get(entry.key) {
52
+ | Some(existing) =>
53
+ byKey->Dict.set(entry.key, {...existing, declaredBy: existing.declaredBy->Array.concat(sites)})
54
+ | None => byKey->Dict.set(entry.key, {kind: entry.kind, key: entry.key, declaredBy: sites})
55
+ }
56
+ })
57
+ )
58
+ byKey
59
+ ->Dict.valuesToArray
60
+ ->Array.toSorted((a, b) => String.compare(a.key, b.key))
61
+ }
62
+
63
+ let quote = (s: string): string => s->JSON.Encode.string->JSON.stringify
64
+
65
+ // A key is `{plugin}.{store}` by construction (`Plugin_Structure` qualifies
66
+ // every declaration). The split is at the first dot: the plugin's registered
67
+ // name cannot contain one, the store name could in principle.
68
+ let splitKey = (key: string): option<(string, string)> =>
69
+ key
70
+ ->String.indexOfOpt(".")
71
+ ->Option.map(i => (
72
+ key->String.slice(~start=0, ~end=i),
73
+ key->String.slice(~start=i + 1),
74
+ ))
75
+
76
+ // The provenance comment quotes the annotation as its author wrote it — taken
77
+ // from the manifest, which recorded it at the one place the owning plugin was
78
+ // unambiguous. Inferring it here is what this replaced: the manifest carries a
79
+ // deploy-manifest entry name and the key carries the registered name, and no
80
+ // rule relates the two (`platform-inspector` against `PlatformInspector` is an
81
+ // ordinary pairing), so any comparison here eventually quotes a string that is
82
+ // in no source file.
83
+ //
84
+ // A site with no recorded annotation predates the recording. It still names the
85
+ // field, which is the comment's job; it just makes no claim about the source
86
+ // text it cannot see.
87
+ let renderEntry = (entry: unionEntry): result<array<string>, string> =>
88
+ switch splitKey(entry.key) {
89
+ | None =>
90
+ Error(
91
+ `malformed capability key ${quote(entry.key)} — expected "{plugin}.{store}" (is the plugin's capabilities.json hand-edited?)`,
92
+ )
93
+ | Some((plugin, store)) => {
94
+ let comments =
95
+ entry.declaredBy->Array.map(site => {
96
+ let site_ = ` // ${site.pluginName}: ${site.component}.${site.field}`
97
+ switch site.annotation {
98
+ | Some(annotation) => `${site_} @storageRef(${quote(annotation)})`
99
+ | None => site_
100
+ }
101
+ })
102
+ let line = switch entry.kind {
103
+ | ObjectStore => ` ObjectStore({plugin: ${quote(plugin)}, store: ${quote(store)}}),`
104
+ }
105
+ Ok(comments->Array.concat([line]))
106
+ }
107
+ }
108
+
109
+ /**
110
+ Two deployables each claiming to own one plugin name.
111
+
112
+ Nothing else in the toolchain catches this. The deploy manifest's keys are
113
+ *deployable* names with no rule relating them to registered ones, and the Plugin
114
+ aggregate is keyed by the registered name — so a second plugin registering an
115
+ existing name is not rejected but read as a **new version of the first**, and
116
+ silently supersedes it in the registry.
117
+
118
+ Ownership is read from the annotation as authored, which is the one signal that
119
+ separates a duplicate from legitimate sharing: an **unqualified**
120
+ `@storageRef("productImages")` means "a store of my own plugin", so the
121
+ deployable that wrote it is the plugin the key names. A **qualified**
122
+ `@storageRef("Catalog.productImages")` points at someone else's store and says
123
+ nothing about who owns it — that is the sanctioned cross-plugin form and must not
124
+ trip this.
125
+
126
+ Partial by construction: it can only see plugins that declare a store, because
127
+ capability manifests are the only per-plugin input the generator reads. A
128
+ duplicate between two store-less plugins is invisible here and stays uncaught.
129
+ */
130
+ let duplicatePluginOwners = (entries: array<unionEntry>): array<(string, array<string>)> => {
131
+ let owners: dict<array<string>> = Dict.make()
132
+ entries->Array.forEach(entry =>
133
+ switch splitKey(entry.key) {
134
+ | None => ()
135
+ | Some((plugin, _)) =>
136
+ entry.declaredBy->Array.forEach(site =>
137
+ switch site.annotation {
138
+ // A site with no recorded annotation predates the recording, so it
139
+ // cannot be read either way and is skipped rather than guessed at.
140
+ | Some(annotation) if !(annotation->String.includes(".")) =>
141
+ let claimants = owners->Dict.get(plugin)->Option.getOr([])
142
+ if !(claimants->Array.includes(site.pluginName)) {
143
+ owners->Dict.set(plugin, Array.concat(claimants, [site.pluginName]))
144
+ }
145
+ | _ => ()
146
+ }
147
+ )
148
+ }
149
+ )
150
+ owners
151
+ ->Dict.toArray
152
+ ->Array.filter(((_, claimants)) => claimants->Array.length > 1)
153
+ ->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
154
+ }
155
+
156
+ let duplicatePluginMessage = ((plugin, claimants): (string, array<string>)): string =>
157
+ `Plugin name "${plugin}" is registered by more than one deployable: ${claimants->Array.join(
158
+ ", ",
159
+ )}.\n` ++
160
+ ` A platform keys its plugin registry by name, so the second registration is read as a new ` ++
161
+ `VERSION of the first and supersedes it.\n` ++
162
+ ` Give each deployable's plugin a distinct name (plugin.json), or — if these were meant to be ` ++
163
+ `one plugin — deploy only one of them.`
164
+
165
+ let header = [
166
+ "// AUTO-GENERATED — do not edit. Run `pnpm run generate:platform` to update.",
167
+ "//",
168
+ "// The platform's capability list, unioned from the committed",
169
+ "// `capabilities.json` manifests of the plugins deploy-manifest.yaml names.",
170
+ "// Each entry keeps its declaring fields as provenance, so when a capability",
171
+ "// disappears from this list, the diff says which field's change removed it.",
172
+ "",
173
+ ]
174
+
175
+ /** Render the complete `PlatformCapabilities.res`. Deterministic and
176
+ newline-terminated: regenerating with no manifest change is byte-identical,
177
+ so the committed file never churns. */
178
+ let render = (manifests: array<pluginManifest>): result<string, string> => {
179
+ let entries = union(manifests)
180
+ let rendered = entries->Array.map(renderEntry)
181
+ switch (
182
+ duplicatePluginOwners(entries),
183
+ rendered->Array.findMap(r =>
184
+ switch r {
185
+ | Error(e) => Some(e)
186
+ | Ok(_) => None
187
+ }
188
+ ),
189
+ ) {
190
+ // Generation is the earliest point a deployment's plugins are seen together,
191
+ // so a name two of them both claim is refused here rather than left to
192
+ // supersede one of them at runtime.
193
+ | (duplicates, _) if duplicates->Array.length > 0 =>
194
+ Error(duplicates->Array.map(duplicatePluginMessage)->Array.join("\n\n"))
195
+ | (_, Some(e)) => Error(e)
196
+ | (_, None) => {
197
+ let body = if entries->Array.length == 0 {
198
+ ["let capabilities: array<ReventlessInfra.Platform.capability> = []"]
199
+ } else {
200
+ ["let capabilities: array<ReventlessInfra.Platform.capability> = ["]
201
+ ->Array.concat(rendered->Array.flatMap(r => r->Result.getOr([])))
202
+ ->Array.concat(["]"])
203
+ }
204
+ Ok(header->Array.concat(body)->Array.join("\n") ++ "\n")
205
+ }
206
+ }
207
+ }