@intentius/chant-lexicon-aws 0.0.6 → 0.0.9

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 (128) hide show
  1. package/dist/integrity.json +25 -10
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +9444 -4597
  4. package/dist/rules/cf-refs.ts +99 -0
  5. package/dist/rules/ext001.ts +32 -25
  6. package/dist/rules/hardcoded-region.ts +1 -0
  7. package/dist/rules/iam-wildcard.ts +1 -0
  8. package/dist/rules/s3-encryption.ts +3 -3
  9. package/dist/rules/waw016.ts +86 -0
  10. package/dist/rules/waw017.ts +53 -0
  11. package/dist/rules/waw018.ts +71 -0
  12. package/dist/rules/waw019.ts +82 -0
  13. package/dist/rules/waw020.ts +64 -0
  14. package/dist/rules/waw021.ts +53 -0
  15. package/dist/rules/waw022.ts +43 -0
  16. package/dist/rules/waw023.ts +47 -0
  17. package/dist/rules/waw024.ts +54 -0
  18. package/dist/rules/waw025.ts +43 -0
  19. package/dist/rules/waw026.ts +46 -0
  20. package/dist/rules/waw027.ts +50 -0
  21. package/dist/rules/waw028.ts +47 -0
  22. package/dist/rules/waw029.ts +62 -0
  23. package/dist/rules/waw030.ts +246 -0
  24. package/dist/skills/chant-aws.md +430 -0
  25. package/dist/types/index.d.ts +58525 -58501
  26. package/package.json +2 -2
  27. package/src/actions/actions.test.ts +75 -0
  28. package/src/actions/dynamodb.ts +36 -0
  29. package/src/actions/ecr.ts +9 -0
  30. package/src/actions/ecs.ts +5 -0
  31. package/src/actions/iam.ts +3 -0
  32. package/src/actions/index.ts +9 -0
  33. package/src/actions/lambda.ts +11 -0
  34. package/src/actions/logs.ts +4 -0
  35. package/src/actions/s3.ts +34 -0
  36. package/src/actions/sns.ts +5 -0
  37. package/src/actions/sqs.ts +15 -0
  38. package/src/codegen/__snapshots__/snapshot.test.ts.snap +20 -20
  39. package/src/codegen/docs-links.test.ts +143 -0
  40. package/src/codegen/docs.ts +294 -124
  41. package/src/codegen/generate-lexicon.ts +8 -0
  42. package/src/codegen/generate-typescript.ts +25 -1
  43. package/src/codegen/generate.ts +1 -13
  44. package/src/codegen/package.ts +2 -0
  45. package/src/codegen/typecheck.test.ts +1 -1
  46. package/src/composites/composites.test.ts +442 -0
  47. package/src/composites/fargate-alb.ts +253 -0
  48. package/src/composites/index.ts +20 -0
  49. package/src/composites/lambda-api.ts +20 -0
  50. package/src/composites/lambda-dynamodb.ts +64 -0
  51. package/src/composites/lambda-eventbridge.ts +36 -0
  52. package/src/composites/lambda-function.ts +76 -0
  53. package/src/composites/lambda-s3.ts +72 -0
  54. package/src/composites/lambda-sns.ts +30 -0
  55. package/src/composites/lambda-sqs.ts +44 -0
  56. package/src/composites/scheduled-lambda.ts +37 -0
  57. package/src/composites/vpc-default.ts +148 -0
  58. package/src/default-tags.test.ts +38 -0
  59. package/src/default-tags.ts +77 -0
  60. package/src/generated/index.d.ts +58525 -58501
  61. package/src/generated/index.ts +1351 -1351
  62. package/src/generated/lexicon-aws.json +9444 -4597
  63. package/src/import/generator.test.ts +5 -5
  64. package/src/import/generator.ts +4 -4
  65. package/src/import/roundtrip-fixtures.test.ts +2 -1
  66. package/src/import/roundtrip.test.ts +5 -5
  67. package/src/index.ts +21 -0
  68. package/src/integration.test.ts +92 -21
  69. package/src/intrinsics.ts +24 -13
  70. package/src/lint/post-synth/cf-refs.ts +99 -0
  71. package/src/lint/post-synth/ext001.test.ts +214 -31
  72. package/src/lint/post-synth/ext001.ts +32 -25
  73. package/src/lint/post-synth/waw013.test.ts +120 -0
  74. package/src/lint/post-synth/waw014.test.ts +121 -0
  75. package/src/lint/post-synth/waw015.test.ts +147 -0
  76. package/src/lint/post-synth/waw016.test.ts +141 -0
  77. package/src/lint/post-synth/waw016.ts +86 -0
  78. package/src/lint/post-synth/waw017.test.ts +130 -0
  79. package/src/lint/post-synth/waw017.ts +53 -0
  80. package/src/lint/post-synth/waw018.test.ts +109 -0
  81. package/src/lint/post-synth/waw018.ts +71 -0
  82. package/src/lint/post-synth/waw019.test.ts +138 -0
  83. package/src/lint/post-synth/waw019.ts +82 -0
  84. package/src/lint/post-synth/waw020.test.ts +125 -0
  85. package/src/lint/post-synth/waw020.ts +64 -0
  86. package/src/lint/post-synth/waw021.test.ts +81 -0
  87. package/src/lint/post-synth/waw021.ts +53 -0
  88. package/src/lint/post-synth/waw022.test.ts +54 -0
  89. package/src/lint/post-synth/waw022.ts +43 -0
  90. package/src/lint/post-synth/waw023.test.ts +53 -0
  91. package/src/lint/post-synth/waw023.ts +47 -0
  92. package/src/lint/post-synth/waw024.test.ts +64 -0
  93. package/src/lint/post-synth/waw024.ts +54 -0
  94. package/src/lint/post-synth/waw025.test.ts +42 -0
  95. package/src/lint/post-synth/waw025.ts +43 -0
  96. package/src/lint/post-synth/waw026.test.ts +54 -0
  97. package/src/lint/post-synth/waw026.ts +46 -0
  98. package/src/lint/post-synth/waw027.test.ts +63 -0
  99. package/src/lint/post-synth/waw027.ts +50 -0
  100. package/src/lint/post-synth/waw028.test.ts +68 -0
  101. package/src/lint/post-synth/waw028.ts +47 -0
  102. package/src/lint/post-synth/waw029.test.ts +179 -0
  103. package/src/lint/post-synth/waw029.ts +62 -0
  104. package/src/lint/post-synth/waw030.test.ts +800 -0
  105. package/src/lint/post-synth/waw030.ts +246 -0
  106. package/src/lint/rules/hardcoded-region.ts +1 -0
  107. package/src/lint/rules/iam-wildcard.ts +1 -0
  108. package/src/lint/rules/rules.test.ts +8 -8
  109. package/src/lint/rules/s3-encryption.ts +3 -3
  110. package/src/lsp/completions.ts +2 -0
  111. package/src/lsp/hover.ts +17 -0
  112. package/src/nested-stack-integration.test.ts +100 -0
  113. package/src/nested-stack.ts +2 -2
  114. package/src/plugin.test.ts +13 -15
  115. package/src/plugin.ts +552 -114
  116. package/src/serializer.test.ts +370 -43
  117. package/src/serializer.ts +69 -17
  118. package/src/spec/fetch.ts +10 -0
  119. package/src/spec/parse.test.ts +141 -0
  120. package/src/spec/parse.ts +40 -0
  121. package/src/taggable.ts +44 -0
  122. package/src/testdata/nested-stacks/app.ts +26 -0
  123. package/src/testdata/nested-stacks/network/outputs.ts +17 -0
  124. package/src/testdata/nested-stacks/network/security.ts +17 -0
  125. package/src/testdata/nested-stacks/network/vpc.ts +54 -0
  126. package/dist/skills/aws-cloudformation.md +0 -41
  127. package/src/codegen/rollback.test.ts +0 -80
  128. package/src/codegen/rollback.ts +0 -20
@@ -11,6 +11,7 @@ import { createResource } from "@intentius/chant/runtime";
11
11
  import type { SerializerResult } from "@intentius/chant/serializer";
12
12
  import type { BuildResult } from "@intentius/chant/build";
13
13
  import { Parameter } from "./parameter";
14
+ import { defaultTags } from "./default-tags";
14
15
 
15
16
  // Mock S3 Bucket for testing
16
17
  class MockBucket implements Declarable {
@@ -18,9 +19,9 @@ class MockBucket implements Declarable {
18
19
  readonly lexicon = "aws";
19
20
  readonly entityType = "AWS::S3::Bucket";
20
21
  readonly arn: AttrRef;
21
- readonly props: { bucketName?: string; versioningConfiguration?: { status: string } };
22
+ readonly props: Record<string, unknown>;
22
23
 
23
- constructor(props: { bucketName?: string; versioningConfiguration?: { status: string } } = {}) {
24
+ constructor(props: { BucketName?: string; VersioningConfiguration?: { Status: string }; Tags?: unknown[] } = {}) {
24
25
  this.props = props;
25
26
  this.arn = new AttrRef(this, "Arn");
26
27
  }
@@ -57,7 +58,7 @@ describe("awsSerializer.serialize", () => {
57
58
 
58
59
  test("serializes resources", () => {
59
60
  const entities = new Map<string, Declarable>();
60
- entities.set("MyBucket", new MockBucket({ bucketName: "my-bucket" }));
61
+ entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
61
62
 
62
63
  const output = awsSerializer.serialize(entities);
63
64
  const template = JSON.parse(output);
@@ -86,8 +87,8 @@ describe("awsSerializer.serialize", () => {
86
87
  test("serializes nested properties", () => {
87
88
  const entities = new Map<string, Declarable>();
88
89
  entities.set("MyBucket", new MockBucket({
89
- bucketName: "my-bucket",
90
- versioningConfiguration: { status: "Enabled" },
90
+ BucketName: "my-bucket",
91
+ VersioningConfiguration: { Status: "Enabled" },
91
92
  }));
92
93
 
93
94
  const output = awsSerializer.serialize(entities);
@@ -98,21 +99,20 @@ describe("awsSerializer.serialize", () => {
98
99
  });
99
100
  });
100
101
 
101
- test("converts property names to PascalCase", () => {
102
+ test("passes through property names verbatim", () => {
102
103
  const entities = new Map<string, Declarable>();
103
- entities.set("MyBucket", new MockBucket({ bucketName: "test" }));
104
+ entities.set("MyBucket", new MockBucket({ BucketName: "test" }));
104
105
 
105
106
  const output = awsSerializer.serialize(entities);
106
107
  const template = JSON.parse(output);
107
108
 
108
109
  expect(template.Resources.MyBucket.Properties.BucketName).toBeDefined();
109
- expect(template.Resources.MyBucket.Properties.bucketName).toBeUndefined();
110
110
  });
111
111
 
112
112
  test("handles multiple resources", () => {
113
113
  const entities = new Map<string, Declarable>();
114
- entities.set("DataBucket", new MockBucket({ bucketName: "data-bucket" }));
115
- entities.set("LogsBucket", new MockBucket({ bucketName: "logs-bucket" }));
114
+ entities.set("DataBucket", new MockBucket({ BucketName: "data-bucket" }));
115
+ entities.set("LogsBucket", new MockBucket({ BucketName: "logs-bucket" }));
116
116
 
117
117
  const output = awsSerializer.serialize(entities);
118
118
  const template = JSON.parse(output);
@@ -125,7 +125,7 @@ describe("awsSerializer.serialize", () => {
125
125
  test("handles resources and parameters together", () => {
126
126
  const entities = new Map<string, Declarable>();
127
127
  entities.set("Env", new Parameter("String"));
128
- entities.set("MyBucket", new MockBucket({ bucketName: "bucket" }));
128
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
129
129
 
130
130
  const output = awsSerializer.serialize(entities);
131
131
  const template = JSON.parse(output);
@@ -154,9 +154,9 @@ class MockEncryption implements Declarable {
154
154
  readonly lexicon = "aws";
155
155
  readonly entityType = "AWS::S3::Bucket.BucketEncryption";
156
156
  readonly kind = "property" as const;
157
- readonly props: { serverSideEncryptionConfiguration: unknown[] };
157
+ readonly props: { ServerSideEncryptionConfiguration: unknown[] };
158
158
 
159
- constructor(props: { serverSideEncryptionConfiguration: unknown[] }) {
159
+ constructor(props: { ServerSideEncryptionConfiguration: unknown[] }) {
160
160
  this.props = props;
161
161
  }
162
162
  }
@@ -164,14 +164,14 @@ class MockEncryption implements Declarable {
164
164
  describe("property-kind Declarables", () => {
165
165
  test("property-kind Declarables are inlined into parent properties", () => {
166
166
  const encryption = new MockEncryption({
167
- serverSideEncryptionConfiguration: [
168
- { serverSideEncryptionByDefault: { sseAlgorithm: "AES256" } },
167
+ ServerSideEncryptionConfiguration: [
168
+ { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
169
169
  ],
170
170
  });
171
171
 
172
- const bucket = new MockBucket({ bucketName: "my-bucket" });
172
+ const bucket = new MockBucket({ BucketName: "my-bucket" });
173
173
  // Manually set encryption as a prop
174
- (bucket.props as Record<string, unknown>).encryption = encryption;
174
+ (bucket.props as Record<string, unknown>).BucketEncryption = encryption;
175
175
 
176
176
  const entities = new Map<string, Declarable>();
177
177
  entities.set("DataEncryption", encryption);
@@ -181,23 +181,23 @@ describe("property-kind Declarables", () => {
181
181
  const template = JSON.parse(output);
182
182
 
183
183
  // Encryption should be inlined, not a Ref
184
- expect(template.Resources.MyBucket.Properties.Encryption).toEqual({
184
+ expect(template.Resources.MyBucket.Properties.BucketEncryption).toEqual({
185
185
  ServerSideEncryptionConfiguration: [
186
- { ServerSideEncryptionByDefault: { SseAlgorithm: "AES256" } },
186
+ { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
187
187
  ],
188
188
  });
189
189
  });
190
190
 
191
191
  test("property-kind Declarables do NOT appear as standalone Resources", () => {
192
192
  const encryption = new MockEncryption({
193
- serverSideEncryptionConfiguration: [
194
- { serverSideEncryptionByDefault: { sseAlgorithm: "AES256" } },
193
+ ServerSideEncryptionConfiguration: [
194
+ { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
195
195
  ],
196
196
  });
197
197
 
198
198
  const entities = new Map<string, Declarable>();
199
199
  entities.set("DataEncryption", encryption);
200
- entities.set("MyBucket", new MockBucket({ bucketName: "my-bucket" }));
200
+ entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
201
201
 
202
202
  const output = awsSerializer.serialize(entities);
203
203
  const template = JSON.parse(output);
@@ -207,16 +207,16 @@ describe("property-kind Declarables", () => {
207
207
  });
208
208
 
209
209
  test("resource-kind Declarables still emit Ref when referenced", () => {
210
- const sourceBucket = new MockBucket({ bucketName: "source" });
210
+ const sourceBucket = new MockBucket({ BucketName: "source" });
211
211
 
212
212
  class MockConfig implements Declarable {
213
213
  readonly [DECLARABLE_MARKER] = true as const;
214
214
  readonly lexicon = "aws";
215
215
  readonly entityType = "AWS::S3::ReplicationDestination";
216
- readonly props: { bucket: Declarable };
216
+ readonly props: { Bucket: Declarable };
217
217
 
218
- constructor(bucket: Declarable) {
219
- this.props = { bucket };
218
+ constructor(Bucket: Declarable) {
219
+ this.props = { Bucket };
220
220
  }
221
221
  }
222
222
 
@@ -233,7 +233,7 @@ describe("property-kind Declarables", () => {
233
233
 
234
234
  describe("intrinsic serialization", () => {
235
235
  test("handles AttrRef in properties", () => {
236
- const source = new MockBucket({ bucketName: "source" });
236
+ const source = new MockBucket({ BucketName: "source" });
237
237
  // Set the logical name on the AttrRef before using it
238
238
  (source.arn as Record<string, unknown>)._setLogicalName("SourceBucket");
239
239
 
@@ -241,10 +241,10 @@ describe("intrinsic serialization", () => {
241
241
  readonly [DECLARABLE_MARKER] = true as const;
242
242
  readonly lexicon = "aws";
243
243
  readonly entityType = "AWS::S3::ReplicationConfiguration";
244
- readonly props: { sourceArn: AttrRef };
244
+ readonly props: { SourceArn: AttrRef };
245
245
 
246
- constructor(sourceArn: AttrRef) {
247
- this.props = { sourceArn };
246
+ constructor(SourceArn: AttrRef) {
247
+ this.props = { SourceArn };
248
248
  }
249
249
  }
250
250
 
@@ -256,14 +256,14 @@ describe("intrinsic serialization", () => {
256
256
  const template = JSON.parse(output);
257
257
 
258
258
  expect(template.Resources.Replication.Properties.SourceArn).toEqual({
259
- "Fn::GetAttr": ["SourceBucket", "Arn"],
259
+ "Fn::GetAtt": ["SourceBucket", "Arn"],
260
260
  });
261
261
  });
262
262
  });
263
263
 
264
264
  describe("LexiconOutput serialization", () => {
265
265
  test("generates CF Outputs section for LexiconOutputs", () => {
266
- const bucket = new MockBucket({ bucketName: "data-bucket" });
266
+ const bucket = new MockBucket({ BucketName: "data-bucket" });
267
267
  const lexiconOutput = new LexiconOutput(bucket.arn, "DataBucketArn");
268
268
  lexiconOutput._setSourceEntity("dataBucket");
269
269
 
@@ -275,13 +275,13 @@ describe("LexiconOutput serialization", () => {
275
275
 
276
276
  expect(template.Outputs).toBeDefined();
277
277
  expect(template.Outputs.DataBucketArn).toEqual({
278
- Value: { "Fn::GetAttr": ["dataBucket", "Arn"] },
278
+ Value: { "Fn::GetAtt": ["dataBucket", "Arn"] },
279
279
  });
280
280
  });
281
281
 
282
282
  test("generates multiple CF Outputs", () => {
283
- const dataBucket = new MockBucket({ bucketName: "data-bucket" });
284
- const logsBucket = new MockBucket({ bucketName: "logs-bucket" });
283
+ const dataBucket = new MockBucket({ BucketName: "data-bucket" });
284
+ const logsBucket = new MockBucket({ BucketName: "logs-bucket" });
285
285
 
286
286
  const dataOutput = new LexiconOutput(dataBucket.arn, "DataBucketArn");
287
287
  dataOutput._setSourceEntity("dataBucket");
@@ -298,16 +298,16 @@ describe("LexiconOutput serialization", () => {
298
298
  expect(template.Outputs).toBeDefined();
299
299
  expect(Object.keys(template.Outputs)).toHaveLength(2);
300
300
  expect(template.Outputs.DataBucketArn.Value).toEqual({
301
- "Fn::GetAttr": ["dataBucket", "Arn"],
301
+ "Fn::GetAtt": ["dataBucket", "Arn"],
302
302
  });
303
303
  expect(template.Outputs.LogsBucketArn.Value).toEqual({
304
- "Fn::GetAttr": ["logsBucket", "Arn"],
304
+ "Fn::GetAtt": ["logsBucket", "Arn"],
305
305
  });
306
306
  });
307
307
 
308
308
  test("omits Outputs section when no LexiconOutputs provided", () => {
309
309
  const entities = new Map<string, Declarable>();
310
- entities.set("MyBucket", new MockBucket({ bucketName: "bucket" }));
310
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
311
311
 
312
312
  const result = awsSerializer.serialize(entities);
313
313
  const template = JSON.parse(result);
@@ -317,7 +317,7 @@ describe("LexiconOutput serialization", () => {
317
317
 
318
318
  test("omits Outputs section when empty LexiconOutputs array", () => {
319
319
  const entities = new Map<string, Declarable>();
320
- entities.set("MyBucket", new MockBucket({ bucketName: "bucket" }));
320
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
321
321
 
322
322
  const result = awsSerializer.serialize(entities, []);
323
323
  const template = JSON.parse(result as string);
@@ -430,7 +430,7 @@ describe("nested stack serialization", () => {
430
430
  Resources: {},
431
431
  });
432
432
 
433
- const bucket = new MockBucket({ bucketName: "data" });
433
+ const bucket = new MockBucket({ BucketName: "data" });
434
434
 
435
435
  const entities = new Map<string, Declarable>();
436
436
  entities.set("network", stack as unknown as Declarable);
@@ -446,7 +446,7 @@ describe("nested stack serialization", () => {
446
446
 
447
447
  test("without nested stacks returns plain string", () => {
448
448
  const entities = new Map<string, Declarable>();
449
- entities.set("MyBucket", new MockBucket({ bucketName: "bucket" }));
449
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
450
450
 
451
451
  const result = awsSerializer.serialize(entities);
452
452
  expect(typeof result).toBe("string");
@@ -460,7 +460,7 @@ describe("nested stack serialization", () => {
460
460
  subnet: { Type: "AWS::EC2::Subnet" },
461
461
  },
462
462
  Outputs: {
463
- subnetId: { Value: { "Fn::GetAttr": ["subnet", "SubnetId"] } },
463
+ subnetId: { Value: { "Fn::GetAtt": ["subnet", "SubnetId"] } },
464
464
  },
465
465
  });
466
466
 
@@ -472,7 +472,7 @@ describe("nested stack serialization", () => {
472
472
  entityType: "AWS::Lambda::Function",
473
473
  kind: "resource" as const,
474
474
  props: {
475
- vpcConfig: { subnetIds: [subnetRef] },
475
+ VpcConfig: { SubnetIds: [subnetRef] },
476
476
  },
477
477
  } as unknown as Declarable;
478
478
 
@@ -490,3 +490,330 @@ describe("nested stack serialization", () => {
490
490
  });
491
491
  });
492
492
  });
493
+
494
+ // ── Resource-Level CF Attributes ──────────────────────────
495
+
496
+ // Mock resource that supports the second constructor `attributes` argument
497
+ class MockResourceWithAttrs implements Declarable {
498
+ readonly [DECLARABLE_MARKER] = true as const;
499
+ readonly lexicon = "aws";
500
+ readonly entityType: string;
501
+ readonly props: Record<string, unknown>;
502
+ readonly attributes: Record<string, unknown>;
503
+
504
+ constructor(
505
+ type: string,
506
+ props: Record<string, unknown>,
507
+ attributes: Record<string, unknown> = {},
508
+ ) {
509
+ this.entityType = type;
510
+ this.props = props;
511
+ Object.defineProperty(this, "attributes", { value: attributes, enumerable: false, configurable: true });
512
+ }
513
+ }
514
+
515
+ describe("resource-level CF attributes", () => {
516
+ function serialize(...entries: [string, Declarable][]) {
517
+ const entities = new Map<string, Declarable>(entries);
518
+ const output = awsSerializer.serialize(entities);
519
+ return JSON.parse(output as string);
520
+ }
521
+
522
+ test("DependsOn with string logical name", () => {
523
+ const res = new MockResourceWithAttrs(
524
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" }, { DependsOn: "Database" },
525
+ );
526
+ const template = serialize(["Server", res]);
527
+ expect(template.Resources.Server.DependsOn).toBe("Database");
528
+ });
529
+
530
+ test("DependsOn with Declarable reference resolves to logical name", () => {
531
+ const bucket = new MockBucket({ BucketName: "data" });
532
+ const fn = new MockResourceWithAttrs(
533
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: bucket },
534
+ );
535
+ const template = serialize(["DataBucket", bucket], ["Handler", fn]);
536
+ expect(template.Resources.Handler.DependsOn).toBe("DataBucket");
537
+ });
538
+
539
+ test("DependsOn with array of mixed strings and Declarables", () => {
540
+ const bucket = new MockBucket({ BucketName: "data" });
541
+ const fn = new MockResourceWithAttrs(
542
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" },
543
+ { DependsOn: [bucket, "ExternalResource"] },
544
+ );
545
+ const template = serialize(["DataBucket", bucket], ["Handler", fn]);
546
+ expect(template.Resources.Handler.DependsOn).toEqual(["DataBucket", "ExternalResource"]);
547
+ });
548
+
549
+ test("single DependsOn array item serializes as string, not array", () => {
550
+ const fn = new MockResourceWithAttrs(
551
+ "AWS::Lambda::Function", { Runtime: "nodejs20.x" }, { DependsOn: ["OnlyOne"] },
552
+ );
553
+ const template = serialize(["Handler", fn]);
554
+ expect(template.Resources.Handler.DependsOn).toBe("OnlyOne");
555
+ });
556
+
557
+ test("Condition attribute", () => {
558
+ const res = new MockResourceWithAttrs(
559
+ "AWS::S3::Bucket", { BucketName: "cond-bucket" }, { Condition: "CreateProdResources" },
560
+ );
561
+ const template = serialize(["MyBucket", res]);
562
+ expect(template.Resources.MyBucket.Condition).toBe("CreateProdResources");
563
+ });
564
+
565
+ test("DeletionPolicy attribute", () => {
566
+ const res = new MockResourceWithAttrs(
567
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { DeletionPolicy: "Retain" },
568
+ );
569
+ const template = serialize(["Database", res]);
570
+ expect(template.Resources.Database.DeletionPolicy).toBe("Retain");
571
+ });
572
+
573
+ test("UpdateReplacePolicy attribute", () => {
574
+ const res = new MockResourceWithAttrs(
575
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro" }, { UpdateReplacePolicy: "Snapshot" },
576
+ );
577
+ const template = serialize(["Database", res]);
578
+ expect(template.Resources.Database.UpdateReplacePolicy).toBe("Snapshot");
579
+ });
580
+
581
+ test("UpdatePolicy attribute", () => {
582
+ const policy = {
583
+ AutoScalingRollingUpdate: {
584
+ MaxBatchSize: 2,
585
+ MinInstancesInService: 1,
586
+ PauseTime: "PT5M",
587
+ WaitOnResourceSignals: true,
588
+ },
589
+ };
590
+ const res = new MockResourceWithAttrs(
591
+ "AWS::AutoScaling::AutoScalingGroup", { MinSize: "1", MaxSize: "4" },
592
+ { UpdatePolicy: policy },
593
+ );
594
+ const template = serialize(["ASG", res]);
595
+ expect(template.Resources.ASG.UpdatePolicy).toEqual(policy);
596
+ });
597
+
598
+ test("CreationPolicy attribute", () => {
599
+ const policy = { ResourceSignal: { Count: 3, Timeout: "PT15M" } };
600
+ const res = new MockResourceWithAttrs(
601
+ "AWS::AutoScaling::AutoScalingGroup", { MinSize: "3", MaxSize: "3" },
602
+ { CreationPolicy: policy },
603
+ );
604
+ const template = serialize(["ASG", res]);
605
+ expect(template.Resources.ASG.CreationPolicy).toEqual(policy);
606
+ });
607
+
608
+ test("Metadata attribute with plain object", () => {
609
+ const res = new MockResourceWithAttrs(
610
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" },
611
+ { Metadata: { "AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } } } },
612
+ );
613
+ const template = serialize(["Server", res]);
614
+ expect(template.Resources.Server.Metadata).toEqual({
615
+ "AWS::CloudFormation::Init": { config: { packages: { yum: { httpd: [] } } } },
616
+ });
617
+ });
618
+
619
+ test("Metadata with intrinsic values resolves them", () => {
620
+ const res = new MockResourceWithAttrs(
621
+ "AWS::EC2::Instance", { InstanceType: "t3.micro" },
622
+ { Metadata: { StackInfo: Sub`${AWS.StackName}-metadata` } },
623
+ );
624
+ const template = serialize(["Server", res]);
625
+ expect(template.Resources.Server.Metadata.StackInfo).toEqual({
626
+ "Fn::Sub": "${AWS::StackName}-metadata",
627
+ });
628
+ });
629
+
630
+ test("all 7 attributes on a single resource", () => {
631
+ const dependency = new MockBucket({ BucketName: "dep" });
632
+ const res = new MockResourceWithAttrs(
633
+ "AWS::RDS::DBInstance", { DBInstanceClass: "db.t3.micro", Engine: "postgres" },
634
+ {
635
+ DependsOn: dependency,
636
+ Condition: "CreateDatabase",
637
+ DeletionPolicy: "Snapshot",
638
+ UpdateReplacePolicy: "Retain",
639
+ UpdatePolicy: { AutoScalingReplacingUpdate: { WillReplace: true } },
640
+ CreationPolicy: { ResourceSignal: { Count: 1, Timeout: "PT10M" } },
641
+ Metadata: { Version: "1.0" },
642
+ },
643
+ );
644
+ const template = serialize(["DepBucket", dependency], ["Database", res]);
645
+
646
+ const db = template.Resources.Database;
647
+ expect(db.DependsOn).toBe("DepBucket");
648
+ expect(db.Condition).toBe("CreateDatabase");
649
+ expect(db.DeletionPolicy).toBe("Snapshot");
650
+ expect(db.UpdateReplacePolicy).toBe("Retain");
651
+ expect(db.UpdatePolicy).toEqual({ AutoScalingReplacingUpdate: { WillReplace: true } });
652
+ expect(db.CreationPolicy).toEqual({ ResourceSignal: { Count: 1, Timeout: "PT10M" } });
653
+ expect(db.Metadata).toEqual({ Version: "1.0" });
654
+ });
655
+
656
+ test("undefined attributes are omitted from CF template", () => {
657
+ const res = new MockResourceWithAttrs(
658
+ "AWS::S3::Bucket", { BucketName: "bucket" },
659
+ { DependsOn: undefined, Condition: undefined },
660
+ );
661
+ const template = serialize(["MyBucket", res]);
662
+ expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
663
+ expect(template.Resources.MyBucket.Condition).toBeUndefined();
664
+ });
665
+
666
+ test("empty attributes object produces no resource-level attributes", () => {
667
+ const res = new MockResourceWithAttrs("AWS::S3::Bucket", { BucketName: "bucket" }, {});
668
+ const template = serialize(["MyBucket", res]);
669
+ const r = template.Resources.MyBucket;
670
+ expect(r.DependsOn).toBeUndefined();
671
+ expect(r.Condition).toBeUndefined();
672
+ expect(r.DeletionPolicy).toBeUndefined();
673
+ expect(r.Metadata).toBeUndefined();
674
+ });
675
+
676
+ test("resource without attributes property works unchanged", () => {
677
+ // MockBucket has no `attributes` property — should still serialize fine
678
+ const bucket = new MockBucket({ BucketName: "legacy" });
679
+ const template = serialize(["MyBucket", bucket]);
680
+ expect(template.Resources.MyBucket.Type).toBe("AWS::S3::Bucket");
681
+ expect(template.Resources.MyBucket.Properties.BucketName).toBe("legacy");
682
+ expect(template.Resources.MyBucket.DependsOn).toBeUndefined();
683
+ });
684
+ });
685
+
686
+ // ── Default Tags Serialization ──────────────────────────
687
+
688
+ // Mock Lambda Permission (non-taggable) for testing
689
+ class MockPermission implements Declarable {
690
+ readonly [DECLARABLE_MARKER] = true as const;
691
+ readonly lexicon = "aws";
692
+ readonly entityType = "AWS::Lambda::Permission";
693
+ readonly props: { Action: string; FunctionName: string; Principal: string };
694
+
695
+ constructor(props: { Action: string; FunctionName: string; Principal: string }) {
696
+ this.props = props;
697
+ }
698
+ }
699
+
700
+ // Mock Lambda Function (taggable) for testing
701
+ class MockFunction implements Declarable {
702
+ readonly [DECLARABLE_MARKER] = true as const;
703
+ readonly lexicon = "aws";
704
+ readonly entityType = "AWS::Lambda::Function";
705
+ readonly props: Record<string, unknown>;
706
+
707
+ constructor(props: Record<string, unknown> = {}) {
708
+ this.props = props;
709
+ }
710
+ }
711
+
712
+ describe("default tags serialization", () => {
713
+ test("DefaultTags entity is not emitted as a CF Resource", () => {
714
+ const entities = new Map<string, Declarable>();
715
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
716
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
717
+
718
+ const output = awsSerializer.serialize(entities);
719
+ const template = JSON.parse(output as string);
720
+
721
+ expect(template.Resources.tags).toBeUndefined();
722
+ expect(template.Resources.MyBucket).toBeDefined();
723
+ });
724
+
725
+ test("taggable resource gets default tags injected", () => {
726
+ const entities = new Map<string, Declarable>();
727
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
728
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
729
+
730
+ const output = awsSerializer.serialize(entities);
731
+ const template = JSON.parse(output as string);
732
+
733
+ expect(template.Resources.MyBucket.Properties.Tags).toEqual([
734
+ { Key: "Env", Value: "prod" },
735
+ ]);
736
+ });
737
+
738
+ test("non-taggable resource does NOT get tags", () => {
739
+ const entities = new Map<string, Declarable>();
740
+ entities.set("Perm", new MockPermission({
741
+ Action: "lambda:InvokeFunction",
742
+ FunctionName: "fn",
743
+ Principal: "apigateway.amazonaws.com",
744
+ }));
745
+ entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
746
+
747
+ const output = awsSerializer.serialize(entities);
748
+ const template = JSON.parse(output as string);
749
+
750
+ expect(template.Resources.Perm.Properties.Tags).toBeUndefined();
751
+ });
752
+
753
+ test("explicit tags win over defaults on same key", () => {
754
+ const entities = new Map<string, Declarable>();
755
+ entities.set("MyBucket", new MockBucket({
756
+ BucketName: "bucket",
757
+ Tags: [{ Key: "Env", Value: "staging" }],
758
+ }));
759
+ entities.set("tags", defaultTags([
760
+ { Key: "Env", Value: "prod" },
761
+ { Key: "Team", Value: "platform" },
762
+ ]) as unknown as Declarable);
763
+
764
+ const output = awsSerializer.serialize(entities);
765
+ const template = JSON.parse(output as string);
766
+
767
+ const tags = template.Resources.MyBucket.Properties.Tags;
768
+ expect(tags).toHaveLength(2);
769
+ // Explicit "Env" wins, default "Team" is added
770
+ const envTag = tags.find((t: { Key: string }) => t.Key === "Env");
771
+ const teamTag = tags.find((t: { Key: string }) => t.Key === "Team");
772
+ expect(envTag.Value).toBe("staging");
773
+ expect(teamTag.Value).toBe("platform");
774
+ });
775
+
776
+ test("intrinsic tag values resolve correctly", () => {
777
+ const entities = new Map<string, Declarable>();
778
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
779
+ entities.set("tags", defaultTags([
780
+ { Key: "Stack", Value: Sub`${AWS.StackName}` },
781
+ ]) as unknown as Declarable);
782
+
783
+ const output = awsSerializer.serialize(entities);
784
+ const template = JSON.parse(output as string);
785
+
786
+ const tags = template.Resources.MyBucket.Properties.Tags;
787
+ expect(tags).toHaveLength(1);
788
+ expect(tags[0].Key).toBe("Stack");
789
+ expect(tags[0].Value).toEqual({ "Fn::Sub": "${AWS::StackName}" });
790
+ });
791
+
792
+ test("parameter tag values resolve to Ref", () => {
793
+ const env = new Parameter("String", { defaultValue: "dev" });
794
+ const entities = new Map<string, Declarable>();
795
+ entities.set("Env", env as unknown as Declarable);
796
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
797
+ entities.set("tags", defaultTags([
798
+ { Key: "Environment", Value: env },
799
+ ]) as unknown as Declarable);
800
+
801
+ const output = awsSerializer.serialize(entities);
802
+ const template = JSON.parse(output as string);
803
+
804
+ const tags = template.Resources.MyBucket.Properties.Tags;
805
+ expect(tags).toHaveLength(1);
806
+ expect(tags[0].Key).toBe("Environment");
807
+ expect(tags[0].Value).toEqual({ Ref: "Env" });
808
+ });
809
+
810
+ test("no defaultTags = no injection (existing behavior)", () => {
811
+ const entities = new Map<string, Declarable>();
812
+ entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
813
+
814
+ const output = awsSerializer.serialize(entities);
815
+ const template = JSON.parse(output as string);
816
+
817
+ expect(template.Resources.MyBucket.Properties.Tags).toBeUndefined();
818
+ });
819
+ });