@reventlessdev/reventless-spec 3.0.0-alpha.85 → 3.0.0-alpha.87
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 +16 -0
- package/package.json +1 -1
- package/src/components/CapabilityManifest.res +10 -3
- package/src/components/CapabilityManifest.res.mjs +4 -2
- package/src/components/Plugin.res +8 -0
- package/src/components/Plugin.res.mjs +2 -1
- package/src/generator/PlatformCodegen.res +20 -16
- package/src/generator/PlatformCodegen.res.mjs +11 -12
- package/src/generator/PlatformGenerator.res +29 -50
- package/src/generator/PlatformGenerator.res.mjs +30 -56
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +71 -2
- package/src/semantic/Semantic.res.mjs +54 -4
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
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.87 (2026-07-30)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **spec,core:** record the storageRef annotation instead of inferring it ([06fb5d6](https://github.com/ReventlessDev/reventless-core/commit/06fb5d671db91fb536acefd0c2db69d98671da39))
|
|
11
|
+
* **spec:** read a semantic marker through an optional field's wrapper ([abebaa9](https://github.com/ReventlessDev/reventless-core/commit/abebaa9e6c73986765bb3de589ba0414eb0d85da))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.86 (2026-07-30)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **spec,local:** resolve capability manifests beyond the example topology ([8bfadd5](https://github.com/ReventlessDev/reventless-core/commit/8bfadd5beef90c0c9f6f6f3fbb753c8b789469ed))
|
|
19
|
+
* **spec:** semantic branded scalars — Email, Url, Phone, Percent, Color, Bytes, Duration ([2e64ff9](https://github.com/ReventlessDev/reventless-core/commit/2e64ff992e913c038a71659c731c25a104e3c5a9))
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
# 3.0.0-alpha.85 (2026-07-30)
|
|
7
23
|
|
|
8
24
|
### Features
|
package/package.json
CHANGED
|
@@ -17,9 +17,14 @@ emits the platform's capability list from them.
|
|
|
17
17
|
type kind = ObjectStore
|
|
18
18
|
|
|
19
19
|
/** The declaration site: the component's spec name and the field carrying the
|
|
20
|
-
`@storageRef` annotation.
|
|
20
|
+
`@storageRef` annotation, plus the store exactly as that field spells it.
|
|
21
|
+
|
|
22
|
+
`annotation` is optional only to keep reading a manifest emitted before it
|
|
23
|
+
existed: a plugin built earlier still parses, and a reader that cannot say
|
|
24
|
+
what the source says omits the claim rather than inventing one. Every
|
|
25
|
+
manifest emitted now carries it. */
|
|
21
26
|
@schema
|
|
22
|
-
type provenance = {component: string, field: string}
|
|
27
|
+
type provenance = {component: string, field: string, annotation?: string}
|
|
23
28
|
|
|
24
29
|
@schema
|
|
25
30
|
type entry = {
|
|
@@ -50,7 +55,9 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
|
50
55
|
kind: ObjectStore,
|
|
51
56
|
key,
|
|
52
57
|
declaredBy: declarations->Array.filterMap(d =>
|
|
53
|
-
d.store == key
|
|
58
|
+
d.store == key
|
|
59
|
+
? Some({component: d.component, field: d.field, annotation: d.annotation})
|
|
60
|
+
: None
|
|
54
61
|
),
|
|
55
62
|
}),
|
|
56
63
|
}
|
|
@@ -8,7 +8,8 @@ let kindSchema = S.literal("ObjectStore");
|
|
|
8
8
|
|
|
9
9
|
let provenanceSchema = S.schema(s => ({
|
|
10
10
|
component: s.m(S.string),
|
|
11
|
-
field: s.m(S.string)
|
|
11
|
+
field: s.m(S.string),
|
|
12
|
+
annotation: s.m(S.option(S.string))
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
15
|
let entrySchema = S.schema(s => ({
|
|
@@ -31,7 +32,8 @@ function fromStructure(structure) {
|
|
|
31
32
|
if (d.store === key) {
|
|
32
33
|
return {
|
|
33
34
|
component: d.component,
|
|
34
|
-
field: d.field
|
|
35
|
+
field: d.field,
|
|
36
|
+
annotation: d.annotation
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
})
|
|
@@ -386,12 +386,20 @@ One field's store requirement, with its provenance.
|
|
|
386
386
|
carries; `component` and `field` name the declaration site. The site matters
|
|
387
387
|
because a requirement only ever changes by editing a field — when a rename
|
|
388
388
|
removes a store from the manifest, the diff has to say which field caused it.
|
|
389
|
+
|
|
390
|
+
`annotation` is the store exactly as the field spells it — bare for a store
|
|
391
|
+
the declaring plugin owns, qualified for a foreign one. It is recorded rather
|
|
392
|
+
than reconstructed: only here is the owning plugin unambiguous, so anything
|
|
393
|
+
downstream would have to infer it by comparing a registered plugin name with
|
|
394
|
+
whatever name a deploy manifest happened to use, and those were never required
|
|
395
|
+
to match.
|
|
389
396
|
*/
|
|
390
397
|
@schema
|
|
391
398
|
type requiredStoreDeclaration = {
|
|
392
399
|
store: string,
|
|
393
400
|
component: string,
|
|
394
401
|
field: string,
|
|
402
|
+
annotation: string,
|
|
395
403
|
}
|
|
396
404
|
|
|
397
405
|
let requiredStoreDeclarationArrayOptionSchema = _jsNullable(
|
|
@@ -181,7 +181,8 @@ let extensionPointDefArrayOptionSchema = SuryResMjs.js_nullable(S.array(extensio
|
|
|
181
181
|
let requiredStoreDeclarationSchema = S.schema(s => ({
|
|
182
182
|
store: s.m(S.string),
|
|
183
183
|
component: s.m(S.string),
|
|
184
|
-
field: s.m(S.string)
|
|
184
|
+
field: s.m(S.string),
|
|
185
|
+
annotation: s.m(S.string)
|
|
185
186
|
}));
|
|
186
187
|
|
|
187
188
|
let requiredStoreDeclarationArrayOptionSchema = SuryResMjs.js_nullable(S.array(requiredStoreDeclarationSchema));
|
|
@@ -22,6 +22,7 @@ type provenance = {
|
|
|
22
22
|
pluginName: string,
|
|
23
23
|
component: string,
|
|
24
24
|
field: string,
|
|
25
|
+
annotation: option<string>,
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** One capability the platform must provision: the qualified `{plugin}.{store}`
|
|
@@ -45,6 +46,7 @@ let union = (manifests: array<pluginManifest>): array<unionEntry> => {
|
|
|
45
46
|
pluginName,
|
|
46
47
|
component: site.component,
|
|
47
48
|
field: site.field,
|
|
49
|
+
annotation: site.annotation,
|
|
48
50
|
})
|
|
49
51
|
switch byKey->Dict.get(entry.key) {
|
|
50
52
|
| Some(existing) =>
|
|
@@ -71,17 +73,17 @@ let splitKey = (key: string): option<(string, string)> =>
|
|
|
71
73
|
key->String.slice(~start=i + 1),
|
|
72
74
|
))
|
|
73
75
|
|
|
74
|
-
// The provenance comment quotes the annotation as its author wrote it
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
85
87
|
let renderEntry = (entry: unionEntry): result<array<string>, string> =>
|
|
86
88
|
switch splitKey(entry.key) {
|
|
87
89
|
| None =>
|
|
@@ -90,11 +92,13 @@ let renderEntry = (entry: unionEntry): result<array<string>, string> =>
|
|
|
90
92
|
)
|
|
91
93
|
| Some((plugin, store)) => {
|
|
92
94
|
let comments =
|
|
93
|
-
entry.declaredBy->Array.map(site =>
|
|
94
|
-
` // ${site.pluginName}: ${site.component}.${site.field}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
})
|
|
98
102
|
let line = switch entry.kind {
|
|
99
103
|
| ObjectStore => ` ObjectStore({plugin: ${quote(plugin)}, store: ${quote(store)}}),`
|
|
100
104
|
}
|
|
@@ -14,7 +14,8 @@ function union(manifests) {
|
|
|
14
14
|
let sites = entry.declaredBy.map(site => ({
|
|
15
15
|
pluginName: pluginName,
|
|
16
16
|
component: site.component,
|
|
17
|
-
field: site.field
|
|
17
|
+
field: site.field,
|
|
18
|
+
annotation: site.annotation
|
|
18
19
|
}));
|
|
19
20
|
let existing = byKey[entry.key];
|
|
20
21
|
if (existing !== undefined) {
|
|
@@ -46,15 +47,6 @@ function splitKey(key) {
|
|
|
46
47
|
]);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function annotationStore(pluginName, key) {
|
|
50
|
-
let match = splitKey(key);
|
|
51
|
-
if (match !== undefined && match[0].toLowerCase() === pluginName.toLowerCase()) {
|
|
52
|
-
return match[1];
|
|
53
|
-
} else {
|
|
54
|
-
return key;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
50
|
function renderEntry(entry) {
|
|
59
51
|
let match = splitKey(entry.key);
|
|
60
52
|
if (match === undefined) {
|
|
@@ -63,7 +55,15 @@ function renderEntry(entry) {
|
|
|
63
55
|
_0: `malformed capability key ` + JSON.stringify(entry.key) + ` — expected "{plugin}.{store}" (is the plugin's capabilities.json hand-edited?)`
|
|
64
56
|
};
|
|
65
57
|
}
|
|
66
|
-
let comments = entry.declaredBy.map(site =>
|
|
58
|
+
let comments = entry.declaredBy.map(site => {
|
|
59
|
+
let site_ = ` // ` + site.pluginName + `: ` + site.component + `.` + site.field;
|
|
60
|
+
let annotation = site.annotation;
|
|
61
|
+
if (annotation !== undefined) {
|
|
62
|
+
return site_ + ` @storageRef(` + JSON.stringify(annotation) + `)`;
|
|
63
|
+
} else {
|
|
64
|
+
return site_;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
67
|
let line = ` ObjectStore({plugin: ` + JSON.stringify(match[0]) + `, store: ` + JSON.stringify(match[1]) + `}),`;
|
|
68
68
|
return {
|
|
69
69
|
TAG: "Ok",
|
|
@@ -108,7 +108,6 @@ export {
|
|
|
108
108
|
union,
|
|
109
109
|
quote,
|
|
110
110
|
splitKey,
|
|
111
|
-
annotationStore,
|
|
112
111
|
renderEntry,
|
|
113
112
|
header,
|
|
114
113
|
render,
|
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
// `PlatformCapabilities.res` (see PlatformCodegen). The platform root stays
|
|
7
7
|
// hand-written and reads `PlatformCapabilities.capabilities`.
|
|
8
8
|
//
|
|
9
|
-
// A
|
|
10
|
-
// manifest
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// read from there rather than from a second, hand-typed spelling of the path.
|
|
9
|
+
// A deploy manifest enumerates *deployables*, not plugins, and a plugin's
|
|
10
|
+
// manifest can sit in any of three places. `PlatformManifests` owns both facts —
|
|
11
|
+
// its head comment states the resolution order and why each arm exists; add a
|
|
12
|
+
// fourth arm there, not here.
|
|
14
13
|
|
|
15
14
|
@val external processExit: int => unit = "process.exit"
|
|
16
15
|
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
@@ -24,42 +23,6 @@ let asObject = (json: JSON.t): option<dict<JSON.t>> => json->JSON.Decode.object
|
|
|
24
23
|
let stringAt = (obj: dict<JSON.t>, field: string): option<string> =>
|
|
25
24
|
obj->Dict.get(field)->Option.flatMap(JSON.Decode.string)
|
|
26
25
|
|
|
27
|
-
// Where a plugin keeps its capability manifest. The direct arm covers a plugin
|
|
28
|
-
// path that is itself a composition package; the delegated arm follows the
|
|
29
|
-
// `-aws` root's `generate-plugin --aws <Namespace> <srcDir>` script to the
|
|
30
|
-
// composition `src/` it is generated from.
|
|
31
|
-
let manifestPathFor = (~pluginDir: string): option<string> => {
|
|
32
|
-
let direct = Generator_Node.join([pluginDir, "src", "capabilities.json"])
|
|
33
|
-
if Generator_Node.existsSync(direct) {
|
|
34
|
-
Some(direct)
|
|
35
|
-
} else {
|
|
36
|
-
let packageJsonPath = Generator_Node.join([pluginDir, "package.json"])
|
|
37
|
-
if !Generator_Node.existsSync(packageJsonPath) {
|
|
38
|
-
None
|
|
39
|
-
} else {
|
|
40
|
-
let generateScript =
|
|
41
|
-
try Generator_Node.readFileSync(packageJsonPath)
|
|
42
|
-
->JSON.parseOrThrow
|
|
43
|
-
->asObject
|
|
44
|
-
->Option.flatMap(pkg => pkg->Dict.get("scripts"))
|
|
45
|
-
->Option.flatMap(asObject)
|
|
46
|
-
->Option.flatMap(scripts => scripts->stringAt("generate")) catch {
|
|
47
|
-
| _ => None
|
|
48
|
-
}
|
|
49
|
-
generateScript
|
|
50
|
-
->Option.flatMap(script => {
|
|
51
|
-
let tokens = script->String.split(" ")->Array.filter(t => t != "")
|
|
52
|
-
switch tokens {
|
|
53
|
-
| ["generate-plugin", "--aws", _namespace, srcDir] =>
|
|
54
|
-
Some(Generator_Node.resolve([pluginDir, srcDir, "capabilities.json"]))
|
|
55
|
-
| _ => None
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
->Option.filter(Generator_Node.existsSync)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
26
|
let () = {
|
|
64
27
|
let manifestArg = Generator_Node.argv->Array.get(2)->Option.getOr("")
|
|
65
28
|
if manifestArg == "" {
|
|
@@ -104,27 +67,43 @@ let () = {
|
|
|
104
67
|
|
|
105
68
|
switch (platformPath, plugins) {
|
|
106
69
|
| (Some(platformPath), Some(plugins)) => {
|
|
107
|
-
let pluginManifests = plugins->Array.
|
|
70
|
+
let pluginManifests = plugins->Array.flatMap(((pluginName, pluginPath)) => {
|
|
108
71
|
let pluginDir = Generator_Node.resolve([manifestDir, pluginPath])
|
|
109
|
-
|
|
110
|
-
|
|
72
|
+
if !Generator_Node.existsSync(pluginDir) {
|
|
73
|
+
fail(`plugin "${pluginName}" points at ${pluginDir}, which does not exist`)
|
|
74
|
+
}
|
|
75
|
+
switch PlatformManifests.resolve(~pluginDir) {
|
|
76
|
+
// A stack whose package graph holds no plugin composition — an
|
|
77
|
+
// SdkService, an SPA, a standalone Lambda — has no capabilities to
|
|
78
|
+
// contribute, which is not an error. Said out loud so a plugin
|
|
79
|
+
// skipped by mistake is visible in the generator's output.
|
|
80
|
+
| NotAPlugin => {
|
|
81
|
+
Console.log(`Skipped: ${pluginName} (${pluginDir}) — no plugin composition`)
|
|
82
|
+
[]
|
|
83
|
+
}
|
|
84
|
+
| Unbuilt({evidence, expected}) => {
|
|
111
85
|
fail(
|
|
112
86
|
`no capabilities.json found for plugin "${pluginName}" (${pluginDir}) — ` ++
|
|
113
|
-
`build the plugin first; its build emits src/capabilities.json beside Plugin.res
|
|
87
|
+
`build the plugin first; its build emits src/capabilities.json beside Plugin.res ` ++
|
|
88
|
+
`(${evidence}; expected ${expected})`,
|
|
114
89
|
)
|
|
115
|
-
|
|
90
|
+
[]
|
|
116
91
|
}
|
|
117
|
-
|
|
|
118
|
-
|
|
92
|
+
| Manifests(manifests) =>
|
|
93
|
+
manifests->Array.map(({path, via}) => {
|
|
94
|
+
Console.log(
|
|
95
|
+
`Read: ${pluginName} — ${path} (via ${PlatformManifests.describeVia(via)})`,
|
|
96
|
+
)
|
|
97
|
+
let manifest = try Generator_Node.readFileSync(path)
|
|
119
98
|
->JSON.parseOrThrow
|
|
120
99
|
->S.parseOrThrow(CapabilityManifest.schema) catch {
|
|
121
100
|
| _ => {
|
|
122
|
-
fail(`could not parse ${
|
|
101
|
+
fail(`could not parse ${path} as a capability manifest`)
|
|
123
102
|
({capabilities: []}: CapabilityManifest.t)
|
|
124
103
|
}
|
|
125
104
|
}
|
|
126
105
|
({pluginName, manifest}: PlatformCodegen.pluginManifest)
|
|
127
|
-
}
|
|
106
|
+
})
|
|
128
107
|
}
|
|
129
108
|
})
|
|
130
109
|
|
|
@@ -8,6 +8,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
8
8
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
9
9
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
10
10
|
import * as PlatformCodegen$Reventless from "./PlatformCodegen.res.mjs";
|
|
11
|
+
import * as PlatformManifests$Reventless from "./PlatformManifests.res.mjs";
|
|
11
12
|
import * as CapabilityManifest$Reventless from "../components/CapabilityManifest.res.mjs";
|
|
12
13
|
|
|
13
14
|
function fail(message) {
|
|
@@ -23,39 +24,6 @@ function stringAt(obj, field) {
|
|
|
23
24
|
return Stdlib_Option.flatMap(obj[field], Stdlib_JSON.Decode.string);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function manifestPathFor(pluginDir) {
|
|
27
|
-
let direct = Nodepath.join(pluginDir, "src", "capabilities.json");
|
|
28
|
-
if (Nodefs.existsSync(direct)) {
|
|
29
|
-
return direct;
|
|
30
|
-
}
|
|
31
|
-
let packageJsonPath = Nodepath.join(pluginDir, "package.json");
|
|
32
|
-
if (!Nodefs.existsSync(packageJsonPath)) {
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
let generateScript;
|
|
36
|
-
try {
|
|
37
|
-
generateScript = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(packageJsonPath, "utf8"))), pkg => pkg["scripts"]), asObject), scripts => stringAt(scripts, "generate"));
|
|
38
|
-
} catch (exn) {
|
|
39
|
-
generateScript = undefined;
|
|
40
|
-
}
|
|
41
|
-
return Stdlib_Option.filter(Stdlib_Option.flatMap(generateScript, script => {
|
|
42
|
-
let tokens = script.split(" ").filter(t => t !== "");
|
|
43
|
-
if (tokens.length !== 4) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
let match = tokens[0];
|
|
47
|
-
if (match !== "generate-plugin") {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
let match$1 = tokens[1];
|
|
51
|
-
if (match$1 !== "--aws") {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
let srcDir = tokens[3];
|
|
55
|
-
return Nodepath.resolve(pluginDir, srcDir, "capabilities.json");
|
|
56
|
-
}), prim => Nodefs.existsSync(prim));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
27
|
let manifestArg = Stdlib_Option.getOr(process.argv[2], "");
|
|
60
28
|
|
|
61
29
|
if (manifestArg === "") {
|
|
@@ -88,32 +56,39 @@ if (manifestArg === "") {
|
|
|
88
56
|
}));
|
|
89
57
|
if (platformPath !== undefined) {
|
|
90
58
|
if (plugins !== undefined) {
|
|
91
|
-
let pluginManifests = plugins.
|
|
59
|
+
let pluginManifests = plugins.flatMap(param => {
|
|
92
60
|
let pluginName = param[0];
|
|
93
61
|
let pluginDir = Nodepath.resolve(manifestDir, param[1]);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
62
|
+
if (!Nodefs.existsSync(pluginDir)) {
|
|
63
|
+
fail(`plugin "` + pluginName + `" points at ` + pluginDir + `, which does not exist`);
|
|
64
|
+
}
|
|
65
|
+
let manifests = PlatformManifests$Reventless.resolve(undefined, pluginDir);
|
|
66
|
+
if (typeof manifests !== "object") {
|
|
67
|
+
console.log(`Skipped: ` + pluginName + ` (` + pluginDir + `) — no plugin composition`);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
if (manifests.TAG === "Manifests") {
|
|
71
|
+
return manifests._0.map(param => {
|
|
72
|
+
let path = param.path;
|
|
73
|
+
console.log(`Read: ` + pluginName + ` — ` + path + ` (via ` + PlatformManifests$Reventless.describeVia(param.via) + `)`);
|
|
74
|
+
let manifest;
|
|
75
|
+
try {
|
|
76
|
+
manifest = S.parseOrThrow(JSON.parse(Nodefs.readFileSync(path, "utf8")), CapabilityManifest$Reventless.schema);
|
|
77
|
+
} catch (exn) {
|
|
78
|
+
fail(`could not parse ` + path + ` as a capability manifest`);
|
|
79
|
+
manifest = {
|
|
80
|
+
capabilities: []
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
pluginName: pluginName,
|
|
85
|
+
manifest: manifest
|
|
103
86
|
};
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
pluginName: pluginName,
|
|
107
|
-
manifest: manifest
|
|
108
|
-
};
|
|
87
|
+
});
|
|
109
88
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
manifest: {
|
|
114
|
-
capabilities: []
|
|
115
|
-
}
|
|
116
|
-
};
|
|
89
|
+
let match = manifests._0;
|
|
90
|
+
fail(`no capabilities.json found for plugin "` + pluginName + `" (` + pluginDir + `) — build the plugin first; its build emits src/capabilities.json beside Plugin.res ` + (`(` + match.evidence + `; expected ` + match.expected + `)`));
|
|
91
|
+
return [];
|
|
117
92
|
});
|
|
118
93
|
let message = PlatformCodegen$Reventless.render(pluginManifests);
|
|
119
94
|
if (message.TAG === "Ok") {
|
|
@@ -135,6 +110,5 @@ export {
|
|
|
135
110
|
fail,
|
|
136
111
|
asObject,
|
|
137
112
|
stringAt,
|
|
138
|
-
manifestPathFor,
|
|
139
113
|
}
|
|
140
114
|
/* manifestArg Not a pure module */
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Where a deploy-manifest entry's capability manifests live — and, when it
|
|
2
|
+
// contributes none, whether that is because the entry is not a plugin at all.
|
|
3
|
+
//
|
|
4
|
+
// A deploy manifest enumerates *deployables*, not plugins. An SdkService stack,
|
|
5
|
+
// a static SPA, a standalone ingester Lambda are all legitimate entries with no
|
|
6
|
+
// `pluginStructure` to derive capabilities from; contributing nothing is the
|
|
7
|
+
// right answer for those. A plugin whose manifest is merely unbuilt is the
|
|
8
|
+
// opposite case, and silently skipping it would deprovision the stores it
|
|
9
|
+
// declared. The two are told apart by evidence — something in the package graph
|
|
10
|
+
// that emits a capability manifest — never by falling through the arms below.
|
|
11
|
+
//
|
|
12
|
+
// Resolution order, first arm that yields manifests wins:
|
|
13
|
+
//
|
|
14
|
+
// 1. `<dir>/src/capabilities.json` — the entry is itself a composition
|
|
15
|
+
// package, so its manifest sits beside its composition root.
|
|
16
|
+
// 2. `<dir>`'s own `generate: generate-plugin --aws <Namespace> <srcDir>`
|
|
17
|
+
// script — an `-aws` root generated from a composition `src/` elsewhere in
|
|
18
|
+
// the same repo. The script is the single existing record of that
|
|
19
|
+
// delegation, so no second hand-typed spelling of the path is introduced.
|
|
20
|
+
// 3. `<dir>`'s dependencies — an `-aws` root that *installs* a published
|
|
21
|
+
// composition package, which is the shape the framework recommends and the
|
|
22
|
+
// one arms 1 and 2 both miss. Every plugin dependency contributes: a root
|
|
23
|
+
// deploying two plugins legitimately needs both manifests.
|
|
24
|
+
// 4. Nothing in the package graph emits a manifest — not a plugin stack.
|
|
25
|
+
//
|
|
26
|
+
// I/O goes through `fileSystem` so the arms are testable without a fixture
|
|
27
|
+
// tree; `nodeFileSystem` is what the generator passes.
|
|
28
|
+
|
|
29
|
+
/** How a manifest was found — reported so the resolving arm never has to be
|
|
30
|
+
reconstructed from the output. */
|
|
31
|
+
type via =
|
|
32
|
+
| Composition
|
|
33
|
+
| Generated(string)
|
|
34
|
+
| Dependency(string)
|
|
35
|
+
|
|
36
|
+
type manifest = {path: string, via: via}
|
|
37
|
+
|
|
38
|
+
/** Why the entry counted as a plugin, and where its manifest was looked for. */
|
|
39
|
+
type unbuilt = {evidence: string, expected: string}
|
|
40
|
+
|
|
41
|
+
type t =
|
|
42
|
+
| Manifests(array<manifest>)
|
|
43
|
+
/** Something here emits a capability manifest, but the manifest is not on
|
|
44
|
+
disk: a plugin that has not been built. */
|
|
45
|
+
| Unbuilt(unbuilt)
|
|
46
|
+
/** No plugin composition anywhere in the entry's package graph. */
|
|
47
|
+
| NotAPlugin
|
|
48
|
+
|
|
49
|
+
type fileSystem = {
|
|
50
|
+
exists: string => bool,
|
|
51
|
+
readJson: string => option<JSON.t>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let nodeFileSystem: fileSystem = {
|
|
55
|
+
exists: Generator_Node.existsSync,
|
|
56
|
+
readJson: path =>
|
|
57
|
+
try Some(Generator_Node.readFileSync(path)->JSON.parseOrThrow) catch {
|
|
58
|
+
| _ => None
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let describeVia = (via: via): string =>
|
|
63
|
+
switch via {
|
|
64
|
+
| Composition => "its own src/"
|
|
65
|
+
| Generated(srcDir) => `its \`generate\` script's ${srcDir}`
|
|
66
|
+
| Dependency(name) => `dependency ${name}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let asObject = (json: JSON.t): option<dict<JSON.t>> => json->JSON.Decode.object
|
|
70
|
+
|
|
71
|
+
let packageJsonAt = (~fs: fileSystem, ~dir: string): option<dict<JSON.t>> =>
|
|
72
|
+
fs.readJson(Generator_Node.join([dir, "package.json"]))->Option.flatMap(asObject)
|
|
73
|
+
|
|
74
|
+
let scriptsOf = (pkg: dict<JSON.t>): array<string> =>
|
|
75
|
+
pkg
|
|
76
|
+
->Dict.get("scripts")
|
|
77
|
+
->Option.flatMap(asObject)
|
|
78
|
+
->Option.mapOr([], scripts =>
|
|
79
|
+
scripts->Dict.valuesToArray->Array.filterMap(JSON.Decode.string)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// The composition `src/` an `-aws` root is generated from, read off the script
|
|
83
|
+
// that performs the generation.
|
|
84
|
+
let generatedSrcDir = (pkg: dict<JSON.t>): option<string> =>
|
|
85
|
+
pkg
|
|
86
|
+
->Dict.get("scripts")
|
|
87
|
+
->Option.flatMap(asObject)
|
|
88
|
+
->Option.flatMap(scripts => scripts->Dict.get("generate"))
|
|
89
|
+
->Option.flatMap(JSON.Decode.string)
|
|
90
|
+
->Option.flatMap(script => {
|
|
91
|
+
let tokens = script->String.split(" ")->Array.filter(t => t != "")
|
|
92
|
+
switch tokens {
|
|
93
|
+
| ["generate-plugin", "--aws", _namespace, srcDir] => Some(srcDir)
|
|
94
|
+
| _ => None
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// A package that runs `emit-capabilities` as part of its build is a plugin
|
|
99
|
+
// composition package, whether or not it has been built yet. That is the whole
|
|
100
|
+
// signal G1 needs: it is stated once, by the package that owns the fact, and it
|
|
101
|
+
// is already there in every plugin package — unlike a `kind:` on the deploy
|
|
102
|
+
// manifest, which would restate it a second time.
|
|
103
|
+
//
|
|
104
|
+
// `reventless-local` *provides* the bin under `bin`, and so is correctly not
|
|
105
|
+
// matched here.
|
|
106
|
+
let emitsCapabilities = (pkg: dict<JSON.t>): bool =>
|
|
107
|
+
pkg->scriptsOf->Array.some(script => script->String.includes("emit-capabilities"))
|
|
108
|
+
|
|
109
|
+
let dependencyNames = (pkg: dict<JSON.t>): array<string> => {
|
|
110
|
+
let seen = Dict.make()
|
|
111
|
+
["dependencies", "devDependencies"]
|
|
112
|
+
->Array.flatMap(field =>
|
|
113
|
+
pkg->Dict.get(field)->Option.flatMap(asObject)->Option.mapOr([], Dict.keysToArray)
|
|
114
|
+
)
|
|
115
|
+
->Array.filter(name =>
|
|
116
|
+
switch seen->Dict.get(name) {
|
|
117
|
+
| Some(_) => false
|
|
118
|
+
| None => {
|
|
119
|
+
seen->Dict.set(name, true)
|
|
120
|
+
true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Node's directory resolution: `<dir>/node_modules/<name>`, then each parent's,
|
|
127
|
+
up to the filesystem root. Walking it rather than binding `require.resolve`
|
|
128
|
+
keeps the lookup on the package directory itself — a package's entry point
|
|
129
|
+
may be anywhere, and it is `src/capabilities.json` that is wanted. */
|
|
130
|
+
let resolvePackageDir = (~fs: fileSystem, ~fromDir: string, ~name: string): option<string> => {
|
|
131
|
+
let rec search = dir => {
|
|
132
|
+
let candidate = Generator_Node.join([dir, "node_modules", name])
|
|
133
|
+
if fs.exists(Generator_Node.join([candidate, "package.json"])) {
|
|
134
|
+
Some(candidate)
|
|
135
|
+
} else {
|
|
136
|
+
let parent = Generator_Node.dirname(dir)
|
|
137
|
+
parent == dir ? None : search(parent)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
search(fromDir)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Arm 3. Unbuilt wins over what was found: a root deploying two plugins needs
|
|
144
|
+
// both manifests, so one of them missing is a failure even though the other
|
|
145
|
+
// resolved.
|
|
146
|
+
let fromDependencies = (~fs: fileSystem, ~pluginDir: string, ~pkg: dict<JSON.t>): t => {
|
|
147
|
+
let found = []
|
|
148
|
+
let unbuilt = ref(None)
|
|
149
|
+
pkg
|
|
150
|
+
->dependencyNames
|
|
151
|
+
->Array.forEach(name =>
|
|
152
|
+
switch resolvePackageDir(~fs, ~fromDir=pluginDir, ~name) {
|
|
153
|
+
| None => ()
|
|
154
|
+
| Some(packageDir) => {
|
|
155
|
+
let candidate = Generator_Node.join([packageDir, "src", "capabilities.json"])
|
|
156
|
+
if fs.exists(candidate) {
|
|
157
|
+
found->Array.push({path: candidate, via: Dependency(name)})
|
|
158
|
+
} else if packageJsonAt(~fs, ~dir=packageDir)->Option.mapOr(false, emitsCapabilities) {
|
|
159
|
+
switch unbuilt.contents {
|
|
160
|
+
| Some(_) => ()
|
|
161
|
+
| None =>
|
|
162
|
+
unbuilt :=
|
|
163
|
+
Some({
|
|
164
|
+
evidence: `dependency ${name} emits a capability manifest`,
|
|
165
|
+
expected: candidate,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
switch (unbuilt.contents, found) {
|
|
173
|
+
| (Some({evidence, expected}), _) => Unbuilt({evidence, expected})
|
|
174
|
+
| (None, []) => NotAPlugin
|
|
175
|
+
| (None, manifests) => Manifests(manifests)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let resolve = (~fs: fileSystem=nodeFileSystem, ~pluginDir: string): t => {
|
|
180
|
+
let direct = Generator_Node.join([pluginDir, "src", "capabilities.json"])
|
|
181
|
+
if fs.exists(direct) {
|
|
182
|
+
Manifests([{path: direct, via: Composition}])
|
|
183
|
+
} else {
|
|
184
|
+
switch packageJsonAt(~fs, ~dir=pluginDir) {
|
|
185
|
+
| None => NotAPlugin
|
|
186
|
+
| Some(pkg) =>
|
|
187
|
+
switch generatedSrcDir(pkg) {
|
|
188
|
+
| Some(srcDir) => {
|
|
189
|
+
let expected = Generator_Node.resolve([pluginDir, srcDir, "capabilities.json"])
|
|
190
|
+
if fs.exists(expected) {
|
|
191
|
+
Manifests([{path: expected, via: Generated(srcDir)}])
|
|
192
|
+
} else {
|
|
193
|
+
Unbuilt({
|
|
194
|
+
evidence: `its \`generate\` script composes ${srcDir}`,
|
|
195
|
+
expected,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
| None => fromDependencies(~fs, ~pluginDir, ~pkg)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|