@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
@@ -1,68 +1,251 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
2
  import { createPostSynthContext } from "@intentius/chant-test-utils";
4
- import { ext001 } from "./ext001";
3
+ import { ext001, checkExtensionConstraints, type ExtensionConstraint } from "./ext001";
5
4
 
6
5
  function makeCtx(template: object) {
7
6
  return createPostSynthContext({ aws: template });
8
7
  }
9
8
 
9
+ /** Synthetic constraint map — no disk dependency. */
10
+ function fakeConstraints(): Map<string, ExtensionConstraint[]> {
11
+ return new Map([
12
+ ["AWS::EC2::Instance", [
13
+ {
14
+ name: "SubnetRequired",
15
+ type: "if_then",
16
+ condition: { required: ["InstanceType"] },
17
+ requirement: { required: ["SubnetId"] },
18
+ },
19
+ {
20
+ name: "SpotExcludesPlacement",
21
+ type: "dependent_excluded",
22
+ requirement: { InstanceMarketOptions: ["PlacementGroup"] },
23
+ },
24
+ ]],
25
+ ["AWS::ECS::Service", [
26
+ {
27
+ name: "LaunchTypeOrCapacity",
28
+ type: "required_xor",
29
+ requirement: ["LaunchType", "CapacityProviderStrategy"],
30
+ },
31
+ ]],
32
+ ["AWS::S3::Bucket", [
33
+ {
34
+ name: "EncryptionOrNotification",
35
+ type: "required_or",
36
+ requirement: ["BucketEncryption", "NotificationConfiguration"],
37
+ },
38
+ ]],
39
+ ]);
40
+ }
41
+
10
42
  describe("EXT001: Extension Constraint Violation", () => {
11
43
  test("check metadata", () => {
12
44
  expect(ext001.id).toBe("EXT001");
13
45
  expect(ext001.description).toContain("constraint");
14
46
  });
15
47
 
16
- test("no diagnostics on empty template", () => {
17
- const ctx = makeCtx({ Resources: {} });
18
- const diags = ext001.check(ctx);
48
+ // --- if_then ---
49
+
50
+ test("if_then: flags when condition matches and requirement missing", () => {
51
+ const ctx = makeCtx({
52
+ Resources: {
53
+ MyInstance: {
54
+ Type: "AWS::EC2::Instance",
55
+ Properties: { InstanceType: "t3.micro" },
56
+ },
57
+ },
58
+ });
59
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
60
+ expect(diags).toHaveLength(1);
61
+ expect(diags[0].checkId).toBe("EXT001");
62
+ expect(diags[0].severity).toBe("error");
63
+ expect(diags[0].message).toContain("SubnetRequired");
64
+ expect(diags[0].message).toContain("SubnetId");
65
+ expect(diags[0].entity).toBe("MyInstance");
66
+ });
67
+
68
+ test("if_then: no diagnostic when requirement satisfied", () => {
69
+ const ctx = makeCtx({
70
+ Resources: {
71
+ MyInstance: {
72
+ Type: "AWS::EC2::Instance",
73
+ Properties: { InstanceType: "t3.micro", SubnetId: "subnet-123" },
74
+ },
75
+ },
76
+ });
77
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
78
+ // SubnetRequired satisfied; SpotExcludesPlacement not triggered (no InstanceMarketOptions)
79
+ expect(diags).toHaveLength(0);
80
+ });
81
+
82
+ test("if_then: no diagnostic when condition does not match", () => {
83
+ const ctx = makeCtx({
84
+ Resources: {
85
+ MyInstance: {
86
+ Type: "AWS::EC2::Instance",
87
+ Properties: { ImageId: "ami-123" },
88
+ },
89
+ },
90
+ });
91
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
19
92
  expect(diags).toHaveLength(0);
20
93
  });
21
94
 
22
- test("no diagnostics on unknown resource type", () => {
95
+ // --- dependent_excluded ---
96
+
97
+ test("dependent_excluded: flags when both present", () => {
98
+ const ctx = makeCtx({
99
+ Resources: {
100
+ MyInstance: {
101
+ Type: "AWS::EC2::Instance",
102
+ Properties: {
103
+ InstanceMarketOptions: { MarketType: "spot" },
104
+ PlacementGroup: "my-group",
105
+ SubnetId: "subnet-123",
106
+ InstanceType: "t3.micro",
107
+ },
108
+ },
109
+ },
110
+ });
111
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
112
+ const excluded = diags.filter((d) => d.message.includes("excludes"));
113
+ expect(excluded).toHaveLength(1);
114
+ expect(excluded[0].message).toContain("InstanceMarketOptions");
115
+ expect(excluded[0].message).toContain("PlacementGroup");
116
+ });
117
+
118
+ test("dependent_excluded: no diagnostic when excluded prop absent", () => {
119
+ const ctx = makeCtx({
120
+ Resources: {
121
+ MyInstance: {
122
+ Type: "AWS::EC2::Instance",
123
+ Properties: {
124
+ InstanceMarketOptions: { MarketType: "spot" },
125
+ SubnetId: "subnet-123",
126
+ InstanceType: "t3.micro",
127
+ },
128
+ },
129
+ },
130
+ });
131
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
132
+ const excluded = diags.filter((d) => d.message.includes("excludes"));
133
+ expect(excluded).toHaveLength(0);
134
+ });
135
+
136
+ // --- required_xor ---
137
+
138
+ test("required_xor: flags when neither present", () => {
139
+ const ctx = makeCtx({
140
+ Resources: {
141
+ MySvc: {
142
+ Type: "AWS::ECS::Service",
143
+ Properties: { ServiceName: "my-svc" },
144
+ },
145
+ },
146
+ });
147
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
148
+ expect(diags).toHaveLength(1);
149
+ expect(diags[0].message).toContain("exactly one of");
150
+ expect(diags[0].message).toContain("found 0");
151
+ });
152
+
153
+ test("required_xor: flags when both present", () => {
23
154
  const ctx = makeCtx({
24
155
  Resources: {
25
- MyCustom: {
26
- Type: "Custom::MyResource",
27
- Properties: { Foo: "bar" },
156
+ MySvc: {
157
+ Type: "AWS::ECS::Service",
158
+ Properties: {
159
+ LaunchType: "FARGATE",
160
+ CapacityProviderStrategy: [{}],
161
+ },
28
162
  },
29
163
  },
30
164
  });
31
- const diags = ext001.check(ctx);
165
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
166
+ expect(diags).toHaveLength(1);
167
+ expect(diags[0].message).toContain("found 2");
168
+ });
169
+
170
+ test("required_xor: no diagnostic when exactly one present", () => {
171
+ const ctx = makeCtx({
172
+ Resources: {
173
+ MySvc: {
174
+ Type: "AWS::ECS::Service",
175
+ Properties: { LaunchType: "FARGATE" },
176
+ },
177
+ },
178
+ });
179
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
32
180
  expect(diags).toHaveLength(0);
33
181
  });
34
182
 
35
- // The following tests exercise the constraint validation logic directly
36
- // by testing the check function. Whether diagnostics fire depends on
37
- // the lexicon having constraints for the resource types used.
38
- // Since we may not have the lexicon JSON in test environments,
39
- // we verify the function at least runs without errors.
40
- test("handles resource with no properties gracefully", () => {
183
+ // --- required_or ---
184
+
185
+ test("required_or: flags when none present", () => {
186
+ const ctx = makeCtx({
187
+ Resources: {
188
+ MyBucket: {
189
+ Type: "AWS::S3::Bucket",
190
+ Properties: { BucketName: "test" },
191
+ },
192
+ },
193
+ });
194
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
195
+ expect(diags).toHaveLength(1);
196
+ expect(diags[0].message).toContain("at least one of");
197
+ });
198
+
199
+ test("required_or: no diagnostic when one present", () => {
41
200
  const ctx = makeCtx({
42
201
  Resources: {
43
202
  MyBucket: {
44
203
  Type: "AWS::S3::Bucket",
204
+ Properties: { BucketEncryption: {} },
45
205
  },
46
206
  },
47
207
  });
48
- // Should not throw, diagnostics depend on lexicon data
49
- const diags = ext001.check(ctx);
50
- expect(Array.isArray(diags)).toBe(true);
208
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
209
+ expect(diags).toHaveLength(0);
210
+ });
211
+
212
+ // --- Edge cases ---
213
+
214
+ test("no diagnostics for resource type not in constraints", () => {
215
+ const ctx = makeCtx({
216
+ Resources: {
217
+ MyRole: {
218
+ Type: "AWS::IAM::Role",
219
+ Properties: { RoleName: "test" },
220
+ },
221
+ },
222
+ });
223
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
224
+ expect(diags).toHaveLength(0);
225
+ });
226
+
227
+ test("no diagnostics on empty template", () => {
228
+ const ctx = makeCtx({ Resources: {} });
229
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
230
+ expect(diags).toHaveLength(0);
231
+ });
232
+
233
+ test("returns empty when constraint map is empty", () => {
234
+ const ctx = makeCtx({
235
+ Resources: {
236
+ MyInstance: {
237
+ Type: "AWS::EC2::Instance",
238
+ Properties: { InstanceType: "t3.micro" },
239
+ },
240
+ },
241
+ });
242
+ const diags = checkExtensionConstraints(ctx, new Map());
243
+ expect(diags).toHaveLength(0);
51
244
  });
52
245
 
53
246
  test("handles invalid JSON output gracefully", () => {
54
- const ctx: PostSynthContext = {
55
- outputs: new Map([["aws", "not json"]]),
56
- entities: new Map(),
57
- buildResult: {
58
- outputs: new Map([["aws", "not json"]]),
59
- entities: new Map(),
60
- warnings: [],
61
- errors: [],
62
- sourceFileCount: 0,
63
- },
64
- };
65
- const diags = ext001.check(ctx);
247
+ const ctx = createPostSynthContext({ aws: "not json" as unknown as object });
248
+ const diags = checkExtensionConstraints(ctx, fakeConstraints());
66
249
  expect(diags).toHaveLength(0);
67
250
  });
68
251
  });
@@ -11,10 +11,12 @@
11
11
  * - required_xor: exactly one of the listed properties must exist
12
12
  */
13
13
 
14
+ import { readFileSync } from "fs";
15
+ import { join } from "path";
14
16
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
17
  import { parseCFTemplate, type CFResource } from "./cf-refs";
16
18
 
17
- interface ExtensionConstraint {
19
+ export interface ExtensionConstraint {
18
20
  name: string;
19
21
  type: "if_then" | "dependent_excluded" | "required_or" | "required_xor";
20
22
  condition?: unknown;
@@ -23,7 +25,7 @@ interface ExtensionConstraint {
23
25
 
24
26
  interface LexiconEntry {
25
27
  kind: string;
26
- cfn?: string;
28
+ resourceType: string;
27
29
  constraints?: ExtensionConstraint[];
28
30
  [key: string]: unknown;
29
31
  }
@@ -34,10 +36,6 @@ interface LexiconEntry {
34
36
  function loadLexiconConstraints(): Map<string, ExtensionConstraint[]> {
35
37
  const map = new Map<string, ExtensionConstraint[]>();
36
38
  try {
37
- const { readFileSync } = require("fs");
38
- const { join, dirname } = require("path");
39
- const { fileURLToPath } = require("url");
40
-
41
39
  // Navigate from src/lint/post-synth/ up to the package root
42
40
  const pkgDir = join(__dirname, "..", "..", "..");
43
41
  const lexiconPath = join(pkgDir, "src", "generated", "lexicon-aws.json");
@@ -45,8 +43,8 @@ function loadLexiconConstraints(): Map<string, ExtensionConstraint[]> {
45
43
  const data = JSON.parse(content) as Record<string, LexiconEntry>;
46
44
 
47
45
  for (const [_name, entry] of Object.entries(data)) {
48
- if (entry.kind === "resource" && entry.cfn && entry.constraints && entry.constraints.length > 0) {
49
- map.set(entry.cfn, entry.constraints);
46
+ if (entry.kind === "resource" && entry.resourceType && entry.constraints && entry.constraints.length > 0) {
47
+ map.set(entry.resourceType, entry.constraints);
50
48
  }
51
49
  }
52
50
  } catch {
@@ -195,28 +193,37 @@ function validateResource(
195
193
  return diagnostics;
196
194
  }
197
195
 
198
- export const ext001: PostSynthCheck = {
199
- id: "EXT001",
200
- description: "Extension constraint violation — cross-property validation from cfn-lint extension schemas",
201
-
202
- check(ctx: PostSynthContext): PostSynthDiagnostic[] {
203
- const lexiconConstraints = loadLexiconConstraints();
204
- if (lexiconConstraints.size === 0) return [];
196
+ /**
197
+ * Core detection logic — exported for direct testing with synthetic data.
198
+ */
199
+ export function checkExtensionConstraints(
200
+ ctx: PostSynthContext,
201
+ constraintMap: Map<string, ExtensionConstraint[]>,
202
+ ): PostSynthDiagnostic[] {
203
+ if (constraintMap.size === 0) return [];
205
204
 
206
- const diagnostics: PostSynthDiagnostic[] = [];
205
+ const diagnostics: PostSynthDiagnostic[] = [];
207
206
 
208
- for (const [_lexicon, output] of ctx.outputs) {
209
- const template = parseCFTemplate(output);
210
- if (!template?.Resources) continue;
207
+ for (const [_lexicon, output] of ctx.outputs) {
208
+ const template = parseCFTemplate(output);
209
+ if (!template?.Resources) continue;
211
210
 
212
- for (const [logicalId, resource] of Object.entries(template.Resources)) {
213
- const constraints = lexiconConstraints.get(resource.Type);
214
- if (!constraints) continue;
211
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
212
+ const constraints = constraintMap.get(resource.Type);
213
+ if (!constraints) continue;
215
214
 
216
- diagnostics.push(...validateResource(logicalId, resource, constraints));
217
- }
215
+ diagnostics.push(...validateResource(logicalId, resource, constraints));
218
216
  }
217
+ }
218
+
219
+ return diagnostics;
220
+ }
219
221
 
220
- return diagnostics;
222
+ export const ext001: PostSynthCheck = {
223
+ id: "EXT001",
224
+ description: "Extension constraint violation — cross-property validation from cfn-lint extension schemas",
225
+
226
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
227
+ return checkExtensionConstraints(ctx, loadLexiconConstraints());
221
228
  },
222
229
  };
@@ -0,0 +1,120 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { CHILD_PROJECT_MARKER, type ChildProjectInstance } from "@intentius/chant/child-project";
5
+ import { STACK_OUTPUT_MARKER, type StackOutput } from "@intentius/chant/stack-output";
6
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
7
+ import { waw013 } from "./waw013";
8
+
9
+ function makeStackOutput(): StackOutput {
10
+ return {
11
+ [STACK_OUTPUT_MARKER]: true,
12
+ [DECLARABLE_MARKER]: true,
13
+ lexicon: "aws",
14
+ entityType: "chant:output",
15
+ kind: "output",
16
+ sourceRef: {} as any,
17
+ };
18
+ }
19
+
20
+ function makeChildProject(opts: {
21
+ projectPath?: string;
22
+ childEntities?: Map<string, Declarable>;
23
+ }): ChildProjectInstance {
24
+ return {
25
+ [CHILD_PROJECT_MARKER]: true,
26
+ [DECLARABLE_MARKER]: true,
27
+ lexicon: "aws",
28
+ entityType: "AWS::CloudFormation::Stack",
29
+ kind: "resource",
30
+ projectPath: opts.projectPath ?? "/tmp/child",
31
+ logicalName: "Child",
32
+ outputs: {},
33
+ options: {},
34
+ buildResult: {
35
+ outputs: new Map(),
36
+ entities: opts.childEntities ?? new Map(),
37
+ warnings: [],
38
+ errors: [],
39
+ sourceFileCount: 1,
40
+ },
41
+ };
42
+ }
43
+
44
+ function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
45
+ return {
46
+ outputs: new Map(),
47
+ entities,
48
+ buildResult: {
49
+ outputs: new Map(),
50
+ entities,
51
+ warnings: [],
52
+ errors: [],
53
+ sourceFileCount: 1,
54
+ },
55
+ };
56
+ }
57
+
58
+ describe("WAW013: Child project has no stackOutput() exports", () => {
59
+ test("check metadata", () => {
60
+ expect(waw013.id).toBe("WAW013");
61
+ expect(waw013.description).toContain("stackOutput");
62
+ });
63
+
64
+ test("flags child project with no outputs", () => {
65
+ const child = makeChildProject({ childEntities: new Map() });
66
+ const ctx = makeCtx(new Map([["Network", child]]));
67
+ const diags = waw013.check(ctx);
68
+ expect(diags).toHaveLength(1);
69
+ expect(diags[0].checkId).toBe("WAW013");
70
+ expect(diags[0].severity).toBe("error");
71
+ expect(diags[0].message).toContain("Network");
72
+ expect(diags[0].message).toContain("no stackOutput()");
73
+ expect(diags[0].entity).toBe("Network");
74
+ });
75
+
76
+ test("no diagnostic when child has stackOutput", () => {
77
+ const childEntities = new Map<string, Declarable>([
78
+ ["subnetId", makeStackOutput()],
79
+ ]);
80
+ const child = makeChildProject({ childEntities });
81
+ const ctx = makeCtx(new Map([["Network", child]]));
82
+ const diags = waw013.check(ctx);
83
+ expect(diags).toHaveLength(0);
84
+ });
85
+
86
+ test("no diagnostic when no child projects", () => {
87
+ const ctx = makeCtx(new Map());
88
+ const diags = waw013.check(ctx);
89
+ expect(diags).toHaveLength(0);
90
+ });
91
+
92
+ test("only flags children without outputs, not those with", () => {
93
+ const goodChild = makeChildProject({
94
+ projectPath: "/tmp/good",
95
+ childEntities: new Map([["out", makeStackOutput()]]),
96
+ });
97
+ const badChild = makeChildProject({
98
+ projectPath: "/tmp/bad",
99
+ childEntities: new Map(),
100
+ });
101
+ const ctx = makeCtx(new Map<string, Declarable>([
102
+ ["Good", goodChild],
103
+ ["Bad", badChild],
104
+ ]));
105
+ const diags = waw013.check(ctx);
106
+ expect(diags).toHaveLength(1);
107
+ expect(diags[0].entity).toBe("Bad");
108
+ });
109
+
110
+ test("skips non-child-project entities", () => {
111
+ const plainEntity: Declarable = {
112
+ [DECLARABLE_MARKER]: true,
113
+ lexicon: "aws",
114
+ entityType: "AWS::S3::Bucket",
115
+ };
116
+ const ctx = makeCtx(new Map([["MyBucket", plainEntity]]));
117
+ const diags = waw013.check(ctx);
118
+ expect(diags).toHaveLength(0);
119
+ });
120
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import type { Declarable } from "@intentius/chant/declarable";
4
+ import { CHILD_PROJECT_MARKER, type ChildProjectInstance } from "@intentius/chant/child-project";
5
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
6
+ import { waw014 } from "./waw014";
7
+
8
+ function makeChildProject(projectPath = "/tmp/child"): ChildProjectInstance {
9
+ return {
10
+ [CHILD_PROJECT_MARKER]: true,
11
+ [DECLARABLE_MARKER]: true,
12
+ lexicon: "aws",
13
+ entityType: "AWS::CloudFormation::Stack",
14
+ kind: "resource",
15
+ projectPath,
16
+ logicalName: "Child",
17
+ outputs: {},
18
+ options: {},
19
+ };
20
+ }
21
+
22
+ function makeCtx(
23
+ entities: Map<string, Declarable>,
24
+ templateJson: string,
25
+ ): PostSynthContext {
26
+ const outputs = new Map<string, string>([["aws", templateJson]]);
27
+ return {
28
+ outputs,
29
+ entities,
30
+ buildResult: {
31
+ outputs,
32
+ entities,
33
+ warnings: [],
34
+ errors: [],
35
+ sourceFileCount: 1,
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("WAW014: Unreferenced Nested Stack Outputs", () => {
41
+ test("check metadata", () => {
42
+ expect(waw014.id).toBe("WAW014");
43
+ expect(waw014.description).toContain("outputs");
44
+ });
45
+
46
+ test("flags child whose outputs are never referenced in parent template", () => {
47
+ const entities = new Map<string, Declarable>([
48
+ ["Network", makeChildProject()],
49
+ ]);
50
+ // Parent template doesn't reference Network via Fn::GetAtt
51
+ const template = JSON.stringify({
52
+ Resources: {
53
+ Network: {
54
+ Type: "AWS::CloudFormation::Stack",
55
+ Properties: { TemplateURL: "network.json" },
56
+ },
57
+ },
58
+ });
59
+ const ctx = makeCtx(entities, template);
60
+ const diags = waw014.check(ctx);
61
+ expect(diags).toHaveLength(1);
62
+ expect(diags[0].checkId).toBe("WAW014");
63
+ expect(diags[0].severity).toBe("warning");
64
+ expect(diags[0].message).toContain("Network");
65
+ expect(diags[0].message).toContain("never referenced");
66
+ expect(diags[0].entity).toBe("Network");
67
+ });
68
+
69
+ test("no diagnostic when child outputs are referenced via Fn::GetAtt", () => {
70
+ const entities = new Map<string, Declarable>([
71
+ ["Network", makeChildProject()],
72
+ ]);
73
+ const template = JSON.stringify({
74
+ Resources: {
75
+ Network: {
76
+ Type: "AWS::CloudFormation::Stack",
77
+ Properties: { TemplateURL: "network.json" },
78
+ },
79
+ MyFunc: {
80
+ Type: "AWS::Lambda::Function",
81
+ Properties: {
82
+ VpcConfig: {
83
+ SubnetIds: [{ "Fn::GetAtt": ["Network", "Outputs.SubnetId"] }],
84
+ },
85
+ },
86
+ },
87
+ },
88
+ });
89
+ const ctx = makeCtx(entities, template);
90
+ const diags = waw014.check(ctx);
91
+ expect(diags).toHaveLength(0);
92
+ });
93
+
94
+ test("no diagnostic when no child projects", () => {
95
+ const ctx = makeCtx(new Map(), JSON.stringify({ Resources: {} }));
96
+ const diags = waw014.check(ctx);
97
+ expect(diags).toHaveLength(0);
98
+ });
99
+
100
+ test("flags only unreferenced children, not referenced ones", () => {
101
+ const entities = new Map<string, Declarable>([
102
+ ["Network", makeChildProject("/tmp/net")],
103
+ ["Database", makeChildProject("/tmp/db")],
104
+ ]);
105
+ // Only Network is referenced
106
+ const template = JSON.stringify({
107
+ Resources: {
108
+ MyFunc: {
109
+ Type: "AWS::Lambda::Function",
110
+ Properties: {
111
+ SubnetId: { "Fn::GetAtt": ["Network", "Outputs.SubnetId"] },
112
+ },
113
+ },
114
+ },
115
+ });
116
+ const ctx = makeCtx(entities, template);
117
+ const diags = waw014.check(ctx);
118
+ expect(diags).toHaveLength(1);
119
+ expect(diags[0].entity).toBe("Database");
120
+ });
121
+ });