@intentius/chant-lexicon-aws 0.58.0 → 0.60.0
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/dist/codegen/enum-overlay.d.ts +113 -0
- package/dist/codegen/enum-overlay.d.ts.map +1 -0
- package/dist/codegen/enum-overlay.json +1843 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/components/safety.d.ts +2 -2
- package/dist/components/safety.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +1613 -0
- package/dist/okf/types/DbCluster.md +1 -1
- package/dist/okf/types/DbInstance.md +1 -1
- package/dist/okf/types/EC2Volume.md +1 -1
- package/dist/okf/types/Function.md +1 -1
- package/dist/okf/types/Instance.md +2 -2
- package/dist/okf/types/LoadBalancer.md +1 -1
- package/dist/okf/types/ScalableTarget.md +2 -2
- package/dist/okf/types/Subscription.md +1 -1
- package/dist/okf/types/Table.md +1 -1
- package/dist/okf/types/TargetGroup.md +2 -2
- package/dist/types/index.d.ts +1587 -18
- package/package.json +2 -2
- package/src/codegen/enum-overlay.json +1843 -0
- package/src/codegen/enum-overlay.test.ts +298 -0
- package/src/codegen/enum-overlay.ts +245 -0
- package/src/codegen/generate.ts +40 -2
- package/src/components/safety.test.ts +4 -4
- package/src/components/safety.ts +2 -2
- package/src/generated/index.d.ts +1587 -18
- package/src/generated/lexicon-aws.json +1613 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyEnumOverlay,
|
|
4
|
+
assertOverlayCoverage,
|
|
5
|
+
enumOverlayByType,
|
|
6
|
+
enumOverlayEntries,
|
|
7
|
+
redundantOverlayWarnings,
|
|
8
|
+
type EnumOverlayEntry,
|
|
9
|
+
} from "./enum-overlay";
|
|
10
|
+
import { generate } from "./generate";
|
|
11
|
+
|
|
12
|
+
const entry = (over: Partial<EnumOverlayEntry> = {}): EnumOverlayEntry => ({
|
|
13
|
+
type: "AWS::Test::Resource",
|
|
14
|
+
pointer: "/properties/Mode",
|
|
15
|
+
enumName: "Mode",
|
|
16
|
+
note: "test",
|
|
17
|
+
source: { kind: "docs", url: "https://example.invalid" },
|
|
18
|
+
reviewed: "2026-09-06",
|
|
19
|
+
values: ["fast", "slow"],
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const schema = (extra: Record<string, unknown> = {}) =>
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
typeName: "AWS::Test::Resource",
|
|
26
|
+
properties: { Mode: { type: "string" }, Name: { type: "string" } },
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
...extra,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("applyEnumOverlay", () => {
|
|
32
|
+
test("narrows a bare string property to a named enum definition", () => {
|
|
33
|
+
const { data, applications } = applyEnumOverlay("AWS::Test::Resource", schema(), [entry()], {
|
|
34
|
+
strict: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(applications).toEqual([{ entry: entry(), outcome: "applied" }]);
|
|
38
|
+
const doc = JSON.parse(data as string);
|
|
39
|
+
expect(doc.definitions.Mode).toEqual({ type: "string", enum: ["fast", "slow"] });
|
|
40
|
+
expect(doc.properties.Mode.$ref).toBe("#/definitions/Mode");
|
|
41
|
+
// The enum stays on the property too, because that is what the lexicon
|
|
42
|
+
// registry's propertyConstraints are read from.
|
|
43
|
+
expect(doc.properties.Mode.enum).toEqual(["fast", "slow"]);
|
|
44
|
+
expect(doc.properties.Name).toEqual({ type: "string" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("reaches a property inside a definition", () => {
|
|
48
|
+
const nested = JSON.stringify({
|
|
49
|
+
typeName: "AWS::Test::Resource",
|
|
50
|
+
properties: { Disk: { $ref: "#/definitions/Ebs" } },
|
|
51
|
+
definitions: { Ebs: { type: "object", properties: { VolumeType: { type: "string" } } } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const { data } = applyEnumOverlay(
|
|
55
|
+
"AWS::Test::Resource",
|
|
56
|
+
nested,
|
|
57
|
+
[entry({ pointer: "/definitions/Ebs/properties/VolumeType", enumName: "VolumeType", values: ["gp2", "gp3"] })],
|
|
58
|
+
{ strict: true },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const doc = JSON.parse(data as string);
|
|
62
|
+
expect(doc.definitions.VolumeType).toEqual({ type: "string", enum: ["gp2", "gp3"] });
|
|
63
|
+
expect(doc.definitions.Ebs.properties.VolumeType.$ref).toBe("#/definitions/VolumeType");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the spec wins where it already declares allowed values", () => {
|
|
67
|
+
const withEnum = schema({
|
|
68
|
+
properties: { Mode: { type: "string", enum: ["fast", "slow", "medium"] } },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const { data, applications } = applyEnumOverlay("AWS::Test::Resource", withEnum, [entry()], {
|
|
72
|
+
strict: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(applications[0].outcome).toBe("redundant");
|
|
76
|
+
expect(applications[0].upstreamValues).toEqual(["fast", "slow", "medium"]);
|
|
77
|
+
// Untouched: no definition minted, no $ref, upstream values intact.
|
|
78
|
+
expect(data).toBe(withEnum);
|
|
79
|
+
const doc = JSON.parse(data as string);
|
|
80
|
+
expect(doc.definitions).toBeUndefined();
|
|
81
|
+
expect(doc.properties.Mode.$ref).toBeUndefined();
|
|
82
|
+
expect(doc.properties.Mode.enum).toEqual(["fast", "slow", "medium"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a redundant entry produces a warning naming the entry", () => {
|
|
86
|
+
const withEnum = schema({ properties: { Mode: { type: "string", enum: ["fast"] } } });
|
|
87
|
+
const { applications } = applyEnumOverlay("AWS::Test::Resource", withEnum, [entry()], {
|
|
88
|
+
strict: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const warnings = redundantOverlayWarnings(applications);
|
|
92
|
+
expect(warnings).toHaveLength(1);
|
|
93
|
+
expect(warnings[0].file).toBe("AWS::Test::Resource/properties/Mode");
|
|
94
|
+
expect(warnings[0].error).toContain("redundant");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a strict run fails loudly on a property the schema does not declare", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
applyEnumOverlay("AWS::Test::Resource", schema(), [entry({ pointer: "/properties/Gone" })], {
|
|
100
|
+
strict: true,
|
|
101
|
+
}),
|
|
102
|
+
).toThrow(/AWS::Test::Resource\/properties\/Gone names a property the schema does not declare/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a fixture run records the same case as absent instead of failing", () => {
|
|
106
|
+
const { applications } = applyEnumOverlay(
|
|
107
|
+
"AWS::Test::Resource",
|
|
108
|
+
schema(),
|
|
109
|
+
[entry({ pointer: "/properties/Gone" })],
|
|
110
|
+
{ strict: false },
|
|
111
|
+
);
|
|
112
|
+
expect(applications[0].outcome).toBe("absent");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("fails when the enum name would shadow an existing definition", () => {
|
|
116
|
+
const withDef = schema({ definitions: { Mode: { type: "object", properties: {} } } });
|
|
117
|
+
expect(() =>
|
|
118
|
+
applyEnumOverlay("AWS::Test::Resource", withDef, [entry()], { strict: true }),
|
|
119
|
+
).toThrow(/already declares/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fails when the target property already refers to a definition", () => {
|
|
123
|
+
const withRef = schema({
|
|
124
|
+
properties: { Mode: { $ref: "#/definitions/Other" } },
|
|
125
|
+
definitions: { Other: { type: "string" } },
|
|
126
|
+
});
|
|
127
|
+
expect(() =>
|
|
128
|
+
applyEnumOverlay("AWS::Test::Resource", withRef, [entry()], { strict: true }),
|
|
129
|
+
).toThrow(/already refers to/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("leaves a schema with no entries untouched", () => {
|
|
133
|
+
const original = schema();
|
|
134
|
+
const { data, applications } = applyEnumOverlay("AWS::Other::Thing", original, []);
|
|
135
|
+
expect(data).toBe(original);
|
|
136
|
+
expect(applications).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("assertOverlayCoverage", () => {
|
|
141
|
+
test("a strict run fails on an entry whose type never appeared", () => {
|
|
142
|
+
expect(() => assertOverlayCoverage([entry()], new Set(["AWS::S3::Bucket"]), true)).toThrow(
|
|
143
|
+
/AWS::Test::Resource/,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a fixture run tolerates it", () => {
|
|
148
|
+
expect(() => assertOverlayCoverage([entry()], new Set(["AWS::S3::Bucket"]), false)).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("passes when every type was seen", () => {
|
|
152
|
+
expect(() =>
|
|
153
|
+
assertOverlayCoverage([entry()], new Set(["AWS::Test::Resource"]), true),
|
|
154
|
+
).not.toThrow();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("the shipped overlay", () => {
|
|
159
|
+
const entries = enumOverlayEntries();
|
|
160
|
+
|
|
161
|
+
test("is non-empty and grouped by type without loss", () => {
|
|
162
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
163
|
+
const grouped = [...enumOverlayByType(entries).values()].reduce((n, l) => n + l.length, 0);
|
|
164
|
+
expect(grouped).toBe(entries.length);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("addresses each property at most once", () => {
|
|
168
|
+
const keys = entries.map((e) => `${e.type}${e.pointer}`);
|
|
169
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("every entry is well formed", () => {
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
expect(e.type, `${e.type} is a CloudFormation type name`).toMatch(/^[A-Za-z0-9]+(::[A-Za-z0-9]+){2}$/);
|
|
175
|
+
expect(e.pointer, `${e.type}${e.pointer} is a properties pointer`).toMatch(
|
|
176
|
+
/^\/(properties\/[A-Za-z0-9]+|definitions\/[A-Za-z0-9]+\/properties\/[A-Za-z0-9]+)$/,
|
|
177
|
+
);
|
|
178
|
+
expect(e.enumName, `${e.type}${e.pointer} names its definition`).toMatch(/^[A-Za-z0-9]+$/);
|
|
179
|
+
expect(e.reviewed, `${e.type}${e.pointer} records when it was read`).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
180
|
+
expect(e.note.length, `${e.type}${e.pointer} says why it is here`).toBeGreaterThan(0);
|
|
181
|
+
if (e.source.kind === "botocore") {
|
|
182
|
+
expect(e.source.service).toBeTruthy();
|
|
183
|
+
expect(e.source.apiVersion).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
184
|
+
expect(e.source.shape).toBeTruthy();
|
|
185
|
+
} else {
|
|
186
|
+
expect(e.source.url).toMatch(/^https:\/\//);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("every value list is non-empty, unique and sorted", () => {
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
const where = `${e.type}${e.pointer}`;
|
|
194
|
+
expect(e.values.length, where).toBeGreaterThan(0);
|
|
195
|
+
expect(new Set(e.values).size, where).toBe(e.values.length);
|
|
196
|
+
expect(e.values, where).toEqual([...e.values].sort());
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("covers the headline property from chant #1497", () => {
|
|
201
|
+
const ec2 = entries.find(
|
|
202
|
+
(e) => e.type === "AWS::EC2::Instance" && e.pointer === "/properties/InstanceType",
|
|
203
|
+
);
|
|
204
|
+
expect(ec2).toBeDefined();
|
|
205
|
+
expect(ec2!.values).toContain("t3.micro");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("carries every value the repo's own examples and composites already write", () => {
|
|
209
|
+
const written: Array<[string, string, string]> = [
|
|
210
|
+
["AWS::Lambda::Function", "/properties/Runtime", "nodejs20.x"],
|
|
211
|
+
["AWS::Lambda::Function", "/properties/Runtime", "nodejs18.x"],
|
|
212
|
+
["AWS::Lambda::Function", "/properties/Runtime", "python3.12"],
|
|
213
|
+
["AWS::EC2::Instance", "/properties/InstanceType", "t3.micro"],
|
|
214
|
+
["AWS::ElasticLoadBalancingV2::TargetGroup", "/properties/Protocol", "HTTP"],
|
|
215
|
+
["AWS::ElasticLoadBalancingV2::TargetGroup", "/properties/Protocol", "HTTPS"],
|
|
216
|
+
["AWS::ElasticLoadBalancingV2::TargetGroup", "/properties/Protocol", "TCP"],
|
|
217
|
+
["AWS::ElasticLoadBalancingV2::TargetGroup", "/properties/TargetType", "ip"],
|
|
218
|
+
["AWS::ElasticLoadBalancingV2::LoadBalancer", "/properties/Scheme", "internet-facing"],
|
|
219
|
+
["AWS::SNS::Subscription", "/properties/Protocol", "lambda"],
|
|
220
|
+
["AWS::DynamoDB::Table", "/properties/BillingMode", "PAY_PER_REQUEST"],
|
|
221
|
+
["AWS::DynamoDB::Table", "/definitions/AttributeDefinition/properties/AttributeType", "S"],
|
|
222
|
+
["AWS::DynamoDB::Table", "/definitions/AttributeDefinition/properties/AttributeType", "N"],
|
|
223
|
+
["AWS::DynamoDB::Table", "/definitions/KeySchema/properties/KeyType", "HASH"],
|
|
224
|
+
["AWS::DynamoDB::Table", "/definitions/KeySchema/properties/KeyType", "RANGE"],
|
|
225
|
+
["AWS::DynamoDB::Table", "/definitions/Projection/properties/ProjectionType", "ALL"],
|
|
226
|
+
["AWS::DynamoDB::Table", "/definitions/Projection/properties/ProjectionType", "INCLUDE"],
|
|
227
|
+
["AWS::DynamoDB::Table", "/definitions/StreamSpecification/properties/StreamViewType", "NEW_AND_OLD_IMAGES"],
|
|
228
|
+
["AWS::DynamoDB::Table", "/definitions/StreamSpecification/properties/StreamViewType", "KEYS_ONLY"],
|
|
229
|
+
["AWS::RDS::DBInstance", "/properties/Engine", "postgres"],
|
|
230
|
+
["AWS::RDS::DBCluster", "/properties/Engine", "aurora-postgresql"],
|
|
231
|
+
["AWS::ApplicationAutoScaling::ScalableTarget", "/properties/ServiceNamespace", "ecs"],
|
|
232
|
+
["AWS::ApplicationAutoScaling::ScalableTarget", "/properties/ServiceNamespace", "dynamodb"],
|
|
233
|
+
["AWS::ApplicationAutoScaling::ScalableTarget", "/properties/ScalableDimension", "ecs:service:DesiredCount"],
|
|
234
|
+
[
|
|
235
|
+
"AWS::ApplicationAutoScaling::ScalableTarget",
|
|
236
|
+
"/properties/ScalableDimension",
|
|
237
|
+
"dynamodb:table:ReadCapacityUnits",
|
|
238
|
+
],
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const [type, pointer, value] of written) {
|
|
242
|
+
const e = entries.find((x) => x.type === type && x.pointer === pointer);
|
|
243
|
+
expect(e, `${type}${pointer} is in the overlay`).toBeDefined();
|
|
244
|
+
expect(e!.values, `${type}${pointer} accepts ${value}`).toContain(value);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("the overlay through the generation pipeline", () => {
|
|
250
|
+
test("a bare property comes out as a named union in the .d.ts", async () => {
|
|
251
|
+
const schemas = new Map<string, Buffer>([
|
|
252
|
+
[
|
|
253
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
254
|
+
Buffer.from(
|
|
255
|
+
JSON.stringify({
|
|
256
|
+
typeName: "AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
257
|
+
properties: { Name: { type: "string" }, Scheme: { type: "string" } },
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
}),
|
|
260
|
+
),
|
|
261
|
+
],
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const result = await generate({ schemaSource: schemas });
|
|
265
|
+
|
|
266
|
+
expect(result.typesDTS).toContain(
|
|
267
|
+
'export type LoadBalancer_Scheme = "internal" | "internet-facing";',
|
|
268
|
+
);
|
|
269
|
+
expect(result.typesDTS).toContain("Scheme?: LoadBalancer_Scheme;");
|
|
270
|
+
|
|
271
|
+
const lexicon = JSON.parse(result.lexiconJSON);
|
|
272
|
+
expect(lexicon["LoadBalancer"].propertyConstraints.Scheme.enum).toEqual([
|
|
273
|
+
"internal",
|
|
274
|
+
"internet-facing",
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a spec-declared enum survives the pipeline unchanged", async () => {
|
|
279
|
+
const schemas = new Map<string, Buffer>([
|
|
280
|
+
[
|
|
281
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
282
|
+
Buffer.from(
|
|
283
|
+
JSON.stringify({
|
|
284
|
+
typeName: "AWS::ElasticLoadBalancingV2::LoadBalancer",
|
|
285
|
+
properties: { Scheme: { type: "string", enum: ["internal"] } },
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
],
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const result = await generate({ schemaSource: schemas });
|
|
293
|
+
|
|
294
|
+
expect(result.typesDTS).toContain('Scheme?: "internal";');
|
|
295
|
+
expect(result.typesDTS).not.toContain("export type LoadBalancer_Scheme");
|
|
296
|
+
expect(result.warnings.some((w) => w.error.includes("redundant"))).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated enum overlay (chant #1497).
|
|
3
|
+
*
|
|
4
|
+
* The CloudFormation Registry spec declares `enum` on some properties and not
|
|
5
|
+
* on others, and the split does not follow how often a property is written:
|
|
6
|
+
* `AWS::SageMaker::NotebookInstance.InstanceType` is a union while
|
|
7
|
+
* `AWS::EC2::Instance.InstanceType` is a bare `string`. Where the spec is
|
|
8
|
+
* silent the generated type stops teaching the API, and a wrong value survives
|
|
9
|
+
* `tsc` and `chant build` to fail at deploy.
|
|
10
|
+
*
|
|
11
|
+
* This overlay is a checked-in list of `(CFN type, JSON pointer, values)`
|
|
12
|
+
* entries merged into the raw schema before parsing, so a curated enum reaches
|
|
13
|
+
* the generated `.d.ts` and the lexicon registry by exactly the path a spec
|
|
14
|
+
* enum reaches them. Nothing here is fetched at build time: the values live in
|
|
15
|
+
* `enum-overlay.json` beside this file, each with the source it was read from
|
|
16
|
+
* and the date it was read, so `scripts/refresh-enum-overlay.ts` can diff them
|
|
17
|
+
* later.
|
|
18
|
+
*
|
|
19
|
+
* Precedence: the overlay only fills gaps. An entry whose target already
|
|
20
|
+
* declares `enum` (from the Registry spec or from a cfn-lint patch) leaves the
|
|
21
|
+
* spec alone and reports itself as redundant. Upstream is refreshed on every
|
|
22
|
+
* `generate`, so it is the source that keeps tracking AWS; a hand list allowed
|
|
23
|
+
* to win would rot invisibly and would make the generated type depend on which
|
|
24
|
+
* of two sources happened to be newer. Reporting the overlap instead surfaces
|
|
25
|
+
* the entry for retirement the moment upstream catches up.
|
|
26
|
+
*
|
|
27
|
+
* An entry that matches nothing is an error, not a shrug: a renamed or removed
|
|
28
|
+
* property means the curated values are being applied to a property that no
|
|
29
|
+
* longer exists, and silence there is how an overlay goes stale.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import overlayDocument from "./enum-overlay.json" with { type: "json" };
|
|
33
|
+
|
|
34
|
+
/** Where an entry's values were read from, and enough detail to read them again. */
|
|
35
|
+
export type EnumOverlaySource =
|
|
36
|
+
| {
|
|
37
|
+
kind: "botocore";
|
|
38
|
+
/** Directory under `botocore/data`, e.g. `elbv2`. */
|
|
39
|
+
service: string;
|
|
40
|
+
/** API version directory, e.g. `2015-12-01`. */
|
|
41
|
+
apiVersion: string;
|
|
42
|
+
/** Shape name carrying the `enum`, e.g. `ProtocolEnum`. */
|
|
43
|
+
shape: string;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
kind: "docs";
|
|
47
|
+
/** Page the values were transcribed from. */
|
|
48
|
+
url: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** One curated enum. */
|
|
52
|
+
export interface EnumOverlayEntry {
|
|
53
|
+
/** CloudFormation type name, e.g. `AWS::EC2::Instance`. */
|
|
54
|
+
type: string;
|
|
55
|
+
/**
|
|
56
|
+
* JSON pointer to the property inside that type's Registry schema, either
|
|
57
|
+
* `/properties/<Name>` or `/definitions/<Def>/properties/<Name>`.
|
|
58
|
+
*/
|
|
59
|
+
pointer: string;
|
|
60
|
+
/**
|
|
61
|
+
* Name for the `definitions` entry this creates. The generator turns it into
|
|
62
|
+
* an exported type named `<class>_<enumName>`, e.g. `Function_Runtime`.
|
|
63
|
+
*/
|
|
64
|
+
enumName: string;
|
|
65
|
+
/** Why this property earned a place in the first curated set. */
|
|
66
|
+
note: string;
|
|
67
|
+
source: EnumOverlaySource;
|
|
68
|
+
/** ISO date the values were last read from `source`. */
|
|
69
|
+
reviewed: string;
|
|
70
|
+
values: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What happened to one entry during a generation run. */
|
|
74
|
+
export interface EnumOverlayApplication {
|
|
75
|
+
entry: EnumOverlayEntry;
|
|
76
|
+
/**
|
|
77
|
+
* `applied` narrowed the property; `redundant` left an upstream enum in
|
|
78
|
+
* place; `absent` means the schema in hand does not declare the property,
|
|
79
|
+
* which only a non-strict (fixture) run tolerates.
|
|
80
|
+
*/
|
|
81
|
+
outcome: "applied" | "redundant" | "absent";
|
|
82
|
+
/** For `redundant`, the values upstream already declared. */
|
|
83
|
+
upstreamValues?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The curated entries, in file order. */
|
|
87
|
+
export function enumOverlayEntries(): EnumOverlayEntry[] {
|
|
88
|
+
return overlayDocument.entries as EnumOverlayEntry[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Entries grouped by CloudFormation type name. */
|
|
92
|
+
export function enumOverlayByType(
|
|
93
|
+
entries: EnumOverlayEntry[] = enumOverlayEntries(),
|
|
94
|
+
): Map<string, EnumOverlayEntry[]> {
|
|
95
|
+
const byType = new Map<string, EnumOverlayEntry[]>();
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const list = byType.get(entry.type);
|
|
98
|
+
if (list) list.push(entry);
|
|
99
|
+
else byType.set(entry.type, [entry]);
|
|
100
|
+
}
|
|
101
|
+
return byType;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A pointer segment that is not a plain object key is not addressable. */
|
|
105
|
+
function pointerSegments(pointer: string): string[] {
|
|
106
|
+
if (!pointer.startsWith("/")) {
|
|
107
|
+
throw new Error(`enum overlay pointer must start with "/": ${pointer}`);
|
|
108
|
+
}
|
|
109
|
+
return pointer.split("/").slice(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type SchemaNode = Record<string, unknown>;
|
|
113
|
+
|
|
114
|
+
function resolvePointer(doc: SchemaNode, pointer: string): SchemaNode | undefined {
|
|
115
|
+
let node: unknown = doc;
|
|
116
|
+
for (const segment of pointerSegments(pointer)) {
|
|
117
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) return undefined;
|
|
118
|
+
node = (node as SchemaNode)[segment];
|
|
119
|
+
if (node === undefined) return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) return undefined;
|
|
122
|
+
return node as SchemaNode;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply the entries for one CloudFormation type to its raw schema bytes.
|
|
127
|
+
*
|
|
128
|
+
* Returns the (possibly rewritten) schema and one {@link EnumOverlayApplication}
|
|
129
|
+
* per entry. Throws when an entry cannot be honoured, which is always a
|
|
130
|
+
* curation bug rather than a spec quirk.
|
|
131
|
+
*
|
|
132
|
+
* `strict` is on for a run over the real schema zip and off for a run over the
|
|
133
|
+
* trimmed fixtures under `src/testdata/schemas`, which legitimately drop
|
|
134
|
+
* properties the overlay names. Only a strict run treats a missing property as
|
|
135
|
+
* an error.
|
|
136
|
+
*/
|
|
137
|
+
export function applyEnumOverlay(
|
|
138
|
+
typeName: string,
|
|
139
|
+
data: Buffer | string,
|
|
140
|
+
entries: EnumOverlayEntry[],
|
|
141
|
+
opts: { strict?: boolean } = {},
|
|
142
|
+
): { data: Buffer | string; applications: EnumOverlayApplication[] } {
|
|
143
|
+
if (entries.length === 0) return { data, applications: [] };
|
|
144
|
+
const strict = opts.strict ?? false;
|
|
145
|
+
|
|
146
|
+
const text = typeof data === "string" ? data : data.toString("utf-8");
|
|
147
|
+
const doc = JSON.parse(text) as SchemaNode;
|
|
148
|
+
const applications: EnumOverlayApplication[] = [];
|
|
149
|
+
let changed = false;
|
|
150
|
+
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const where = `${entry.type}${entry.pointer}`;
|
|
153
|
+
|
|
154
|
+
const target = resolvePointer(doc, entry.pointer);
|
|
155
|
+
if (!target) {
|
|
156
|
+
if (!strict) {
|
|
157
|
+
applications.push({ entry, outcome: "absent" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
throw new Error(
|
|
161
|
+
`enum overlay entry ${where} names a property the schema does not declare. ` +
|
|
162
|
+
`Either the property was renamed upstream or the entry is stale; fix or drop it in ` +
|
|
163
|
+
`src/codegen/enum-overlay.json.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (Array.isArray(target.enum) && target.enum.length > 0) {
|
|
168
|
+
applications.push({
|
|
169
|
+
entry,
|
|
170
|
+
outcome: "redundant",
|
|
171
|
+
upstreamValues: target.enum as string[],
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof target.$ref === "string") {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`enum overlay entry ${where} targets a property that already refers to ` +
|
|
179
|
+
`${target.$ref}; the overlay cannot own its type.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const definitions = (doc.definitions ??= {}) as SchemaNode;
|
|
184
|
+
if (entry.enumName in definitions) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`enum overlay entry ${where} would define ${entry.type}.${entry.enumName}, ` +
|
|
187
|
+
`which the schema already declares. Pick a different enumName.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The definition gives the generator a name to export; the `enum` left on
|
|
192
|
+
// the property is what `extractConstraints` copies into the lexicon
|
|
193
|
+
// registry, where the LSP and the docs build read it.
|
|
194
|
+
definitions[entry.enumName] = { type: "string", enum: [...entry.values] };
|
|
195
|
+
target.$ref = `#/definitions/${entry.enumName}`;
|
|
196
|
+
target.enum = [...entry.values];
|
|
197
|
+
changed = true;
|
|
198
|
+
applications.push({ entry, outcome: "applied" });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!changed) return { data, applications };
|
|
202
|
+
const rewritten = JSON.stringify(doc);
|
|
203
|
+
return {
|
|
204
|
+
data: typeof data === "string" ? rewritten : Buffer.from(rewritten),
|
|
205
|
+
applications,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fail on any entry the run never reached.
|
|
211
|
+
*
|
|
212
|
+
* `strict` is on for a run over the real schema zip and off for a run over a
|
|
213
|
+
* fixture subset, where most types are legitimately absent. A missing type in
|
|
214
|
+
* a full run means the resource left the Registry and the entry is dead.
|
|
215
|
+
*/
|
|
216
|
+
export function assertOverlayCoverage(
|
|
217
|
+
entries: EnumOverlayEntry[],
|
|
218
|
+
seenTypes: Set<string>,
|
|
219
|
+
strict: boolean,
|
|
220
|
+
): void {
|
|
221
|
+
if (!strict) return;
|
|
222
|
+
const missing = entries.filter((e) => !seenTypes.has(e.type));
|
|
223
|
+
if (missing.length === 0) return;
|
|
224
|
+
const names = [...new Set(missing.map((e) => e.type))].sort().join(", ");
|
|
225
|
+
throw new Error(
|
|
226
|
+
`enum overlay names ${missing.length} entr${missing.length === 1 ? "y" : "ies"} on ` +
|
|
227
|
+
`type(s) the spec does not contain: ${names}. Drop them from ` +
|
|
228
|
+
`src/codegen/enum-overlay.json or fix the type name.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** One warning line per entry upstream has caught up with. */
|
|
233
|
+
export function redundantOverlayWarnings(
|
|
234
|
+
applications: EnumOverlayApplication[],
|
|
235
|
+
): Array<{ file: string; error: string }> {
|
|
236
|
+
return applications
|
|
237
|
+
.filter((a) => a.outcome === "redundant")
|
|
238
|
+
.map((a) => ({
|
|
239
|
+
file: `${a.entry.type}${a.entry.pointer}`,
|
|
240
|
+
error:
|
|
241
|
+
`enum overlay entry is redundant: the spec already declares ` +
|
|
242
|
+
`${(a.upstreamValues ?? []).length} allowed value(s) here, and the spec wins. ` +
|
|
243
|
+
`Retire the entry from src/codegen/enum-overlay.json.`,
|
|
244
|
+
}));
|
|
245
|
+
}
|
package/src/codegen/generate.ts
CHANGED
|
@@ -18,6 +18,15 @@ import { assertPinnedSpec } from "../spec/pin";
|
|
|
18
18
|
import { parseCFNSchema, cfnShortName, type SchemaParseResult } from "../spec/parse";
|
|
19
19
|
import { fetchCfnLintPatches, applyPatches } from "./patches";
|
|
20
20
|
import { fetchCfnLintExtensions, loadExtensionSchemas, type ExtensionConstraint } from "./extensions";
|
|
21
|
+
import {
|
|
22
|
+
applyEnumOverlay,
|
|
23
|
+
assertOverlayCoverage,
|
|
24
|
+
enumOverlayByType,
|
|
25
|
+
enumOverlayEntries,
|
|
26
|
+
redundantOverlayWarnings,
|
|
27
|
+
type EnumOverlayApplication,
|
|
28
|
+
type EnumOverlayEntry,
|
|
29
|
+
} from "./enum-overlay";
|
|
21
30
|
import { samResources } from "./sam";
|
|
22
31
|
import { fallbackResources } from "./fallback";
|
|
23
32
|
import { NamingStrategy, publishedNames, propertyTypeName, extractDefName } from "./naming";
|
|
@@ -35,6 +44,18 @@ export type { GenerateOptions, GenerateResult };
|
|
|
35
44
|
let awsConstraints = new Map<string, ExtensionConstraint[]>();
|
|
36
45
|
/** chant #1459 — spec type → already-published TS name, reset per `generate()` call. */
|
|
37
46
|
let awsReservedNames: Record<string, string> = {};
|
|
47
|
+
/**
|
|
48
|
+
* Curated enum overlay state (chant #1497), reset per `generate()` call.
|
|
49
|
+
*
|
|
50
|
+
* The overlay runs in `parseSchema` rather than `augmentSchemas` on purpose:
|
|
51
|
+
* `augmentSchemas` is skipped for a caller-supplied schema set because it
|
|
52
|
+
* fetches, and the overlay fetches nothing. Running it per schema keeps a
|
|
53
|
+
* fixture-driven run and a real run on the same code path.
|
|
54
|
+
*/
|
|
55
|
+
let awsOverlay: Map<string, EnumOverlayEntry[]> = new Map();
|
|
56
|
+
let awsOverlayApplications: EnumOverlayApplication[] = [];
|
|
57
|
+
let awsOverlaySeenTypes = new Set<string>();
|
|
58
|
+
let awsOverlayStrict = false;
|
|
38
59
|
|
|
39
60
|
const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
40
61
|
fetchSchemas: async (opts) => {
|
|
@@ -46,8 +67,12 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
|
46
67
|
return schemas;
|
|
47
68
|
},
|
|
48
69
|
|
|
49
|
-
parseSchema: (
|
|
50
|
-
|
|
70
|
+
parseSchema: (typeName, data) => {
|
|
71
|
+
awsOverlaySeenTypes.add(typeName);
|
|
72
|
+
const entries = awsOverlay.get(typeName) ?? [];
|
|
73
|
+
const overlaid = applyEnumOverlay(typeName, data, entries, { strict: awsOverlayStrict });
|
|
74
|
+
awsOverlayApplications.push(...overlaid.applications);
|
|
75
|
+
const result = parseCFNSchema(overlaid.data);
|
|
51
76
|
if (!result.resource.typeName) return null;
|
|
52
77
|
return result;
|
|
53
78
|
},
|
|
@@ -110,6 +135,13 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
|
110
135
|
// Re-filter extension constraints now that we have the full type set
|
|
111
136
|
// (augmentSchemas loaded them before parsing, so we already have them)
|
|
112
137
|
|
|
138
|
+
// A curated enum that reached nothing is a stale entry (chant #1497), and
|
|
139
|
+
// the only place it can be noticed is here, once every schema has been seen.
|
|
140
|
+
assertOverlayCoverage(enumOverlayEntries(), awsOverlaySeenTypes, awsOverlayStrict);
|
|
141
|
+
const applied = awsOverlayApplications.filter((a) => a.outcome === "applied").length;
|
|
142
|
+
log(`Applied ${applied} curated enum overlay entries`);
|
|
143
|
+
warnings.push(...redundantOverlayWarnings(awsOverlayApplications));
|
|
144
|
+
|
|
113
145
|
log(`Total: ${results.length} resource schemas`);
|
|
114
146
|
return { results, warnings };
|
|
115
147
|
},
|
|
@@ -139,6 +171,12 @@ const awsPipelineConfig: GeneratePipelineConfig<SchemaParseResult> = {
|
|
|
139
171
|
export async function generate(opts: GenerateOptions = {}): Promise<GenerateResult> {
|
|
140
172
|
// Reset shared state
|
|
141
173
|
awsConstraints = new Map();
|
|
174
|
+
awsOverlay = enumOverlayByType();
|
|
175
|
+
awsOverlayApplications = [];
|
|
176
|
+
awsOverlaySeenTypes = new Set();
|
|
177
|
+
// A fixture set is a trimmed subset, so an entry it never reaches is
|
|
178
|
+
// expected; a run over the real zip has no such excuse.
|
|
179
|
+
awsOverlayStrict = !opts.schemaSource;
|
|
142
180
|
// chant #1459 — names already published keep pointing at the types that
|
|
143
181
|
// published them. Skipped for a caller-supplied schema set, exactly as
|
|
144
182
|
// `augmentSchemas` is (see the pipeline's `opts.schemaSource` guard): a
|
|
@@ -42,10 +42,10 @@ describe("rollback-previous (#557)", () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("rolls an ECS service back via the executor for the {service, cluster} shape (#990)", async () => {
|
|
45
|
-
// The ecs-fargate preset / ALB-ECS pilot
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
45
|
+
// The ecs-fargate preset / ALB-ECS pilot compose rollback-previous with an
|
|
46
|
+
// ECS service, NOT a snapshot id. This used to reach the snapshot path and
|
|
47
|
+
// throw "Cannot read properties of undefined (reading 'includes')" on the
|
|
48
|
+
// absent snapshotId.
|
|
49
49
|
const mock = createMockCloudExecutor();
|
|
50
50
|
const out = await createRollbackPreviousCapability(mock.executor).run(ctx, {
|
|
51
51
|
service: "loom-frontend-svc",
|
package/src/components/safety.ts
CHANGED
|
@@ -54,8 +54,8 @@ export interface RollbackPreviousSnapshotInput {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** Roll an ECS service back to its previously recorded task definition — the
|
|
57
|
-
* shape the `ecs-fargate` preset and the ALB/ECS pilot compose
|
|
58
|
-
*
|
|
57
|
+
* shape the `ecs-fargate` preset and the ALB/ECS pilot compose for an
|
|
58
|
+
* ALB-fronted frontend service. */
|
|
59
59
|
export interface RollbackPreviousEcsInput {
|
|
60
60
|
/** ECS service name. */
|
|
61
61
|
service: string;
|