@intentius/chant-lexicon-aws 0.55.0 → 0.57.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.
Files changed (45) hide show
  1. package/dist/condition.d.ts +27 -0
  2. package/dist/condition.d.ts.map +1 -0
  3. package/dist/generated/index.d.ts +1 -1
  4. package/dist/generated/index.d.ts.map +1 -1
  5. package/dist/import/generator.d.ts +19 -0
  6. package/dist/import/generator.d.ts.map +1 -1
  7. package/dist/import/parser.d.ts +19 -0
  8. package/dist/import/parser.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +4 -3
  12. package/dist/intrinsics.d.ts +77 -3
  13. package/dist/intrinsics.d.ts.map +1 -1
  14. package/dist/lint/audit-catalog.d.ts.map +1 -1
  15. package/dist/lint/post-synth/index.d.ts.map +1 -1
  16. package/dist/lint/post-synth/waw069.d.ts +15 -0
  17. package/dist/lint/post-synth/waw069.d.ts.map +1 -0
  18. package/dist/manifest.json +29 -1
  19. package/dist/okf/index.md +1 -0
  20. package/dist/okf/rules/WAW069.md +11 -0
  21. package/dist/plugin.d.ts.map +1 -1
  22. package/dist/rules/waw069.ts +0 -0
  23. package/dist/serializer.d.ts.map +1 -1
  24. package/dist/types/index.d.ts +3 -3
  25. package/package.json +2 -2
  26. package/src/codegen/generate-typescript.ts +3 -3
  27. package/src/codegen/generate.ts +1 -1
  28. package/src/condition.test.ts +129 -0
  29. package/src/condition.ts +40 -0
  30. package/src/generated/index.d.ts +3 -3
  31. package/src/generated/index.ts +1 -1
  32. package/src/import/generator.ts +131 -50
  33. package/src/import/parser.test.ts +96 -0
  34. package/src/import/parser.ts +86 -1
  35. package/src/import/roundtrip-fixtures.test.ts +50 -0
  36. package/src/index.ts +12 -0
  37. package/src/intrinsics.ts +130 -5
  38. package/src/lint/audit-catalog.ts +1 -0
  39. package/src/lint/post-synth/index.ts +2 -0
  40. package/src/lint/post-synth/waw069.test.ts +94 -0
  41. package/src/lint/post-synth/waw069.ts +0 -0
  42. package/src/plugin.test.ts +5 -1
  43. package/src/plugin.ts +4 -0
  44. package/src/serializer.ts +24 -3
  45. package/src/testdata/roundtrip/with-conditions.json +16 -0
package/src/intrinsics.ts CHANGED
@@ -2,6 +2,25 @@ import { INTRINSIC_MARKER, resolveIntrinsicValue, isIntrinsic, type Intrinsic }
2
2
  import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
3
3
  import { type Declarable } from "@intentius/chant/declarable";
4
4
  import { getLogicalName } from "@intentius/chant/utils";
5
+ import { isCondition, type Condition } from "./condition";
6
+
7
+ /**
8
+ * An operand allowed where CloudFormation expects a condition (#2068): a
9
+ * nested condition intrinsic (`Equals`/`And`/`Or`/`Not`), the `Condition`
10
+ * declarable itself, or a condition name string. The latter two both emit
11
+ * the `{ "Condition": "<name>" }` reference form.
12
+ */
13
+ export type ConditionOperand = Intrinsic | Condition | string;
14
+
15
+ function resolveConditionOperand(operand: ConditionOperand): unknown {
16
+ if (typeof operand === "string") {
17
+ return { Condition: operand };
18
+ }
19
+ if (isCondition(operand)) {
20
+ return { Condition: getLogicalName(operand) };
21
+ }
22
+ return resolveIntrinsicValue(operand);
23
+ }
5
24
 
6
25
  /**
7
26
  * Fn::Sub intrinsic function implementation
@@ -99,29 +118,135 @@ export function GetAtt(logicalName: string, attribute: string): GetAttIntrinsic
99
118
  */
100
119
  export class IfIntrinsic implements Intrinsic {
101
120
  readonly [INTRINSIC_MARKER] = true as const;
102
- private conditionName: string;
121
+ private conditionName: string | Condition;
103
122
  private valueIfTrue: unknown;
104
123
  private valueIfFalse: unknown;
105
124
 
106
- constructor(conditionName: string, valueIfTrue: unknown, valueIfFalse: unknown) {
125
+ constructor(conditionName: string | Condition, valueIfTrue: unknown, valueIfFalse: unknown) {
107
126
  this.conditionName = conditionName;
108
127
  this.valueIfTrue = valueIfTrue;
109
128
  this.valueIfFalse = valueIfFalse;
110
129
  }
111
130
 
112
131
  toJSON(): { "Fn::If": [string, unknown, unknown] } {
113
- return { "Fn::If": [this.conditionName, resolveIntrinsicValue(this.valueIfTrue), resolveIntrinsicValue(this.valueIfFalse)] };
132
+ const name = typeof this.conditionName === "string" ? this.conditionName : getLogicalName(this.conditionName);
133
+ return { "Fn::If": [name, resolveIntrinsicValue(this.valueIfTrue), resolveIntrinsicValue(this.valueIfFalse)] };
114
134
  }
115
135
  }
116
136
 
117
137
 
118
138
  /**
119
- * Create an If intrinsic
139
+ * Create an If intrinsic. The condition is a name string, or the `Condition`
140
+ * declarable itself (resolved to its logical name at serialization, #2068).
120
141
  */
121
- export function If(conditionName: string, valueIfTrue: unknown, valueIfFalse: unknown): IfIntrinsic {
142
+ export function If(conditionName: string | Condition, valueIfTrue: unknown, valueIfFalse: unknown): IfIntrinsic {
122
143
  return new IfIntrinsic(conditionName, valueIfTrue, valueIfFalse);
123
144
  }
124
145
 
146
+ /**
147
+ * Fn::Equals condition intrinsic (#2068).
148
+ * Compares two values; each may be a literal or a value intrinsic (Ref etc.).
149
+ */
150
+ export class EqualsIntrinsic implements Intrinsic {
151
+ readonly [INTRINSIC_MARKER] = true as const;
152
+ private left: unknown;
153
+ private right: unknown;
154
+
155
+ constructor(left: unknown, right: unknown) {
156
+ this.left = left;
157
+ this.right = right;
158
+ }
159
+
160
+ toJSON(): { "Fn::Equals": [unknown, unknown] } {
161
+ return { "Fn::Equals": [resolveIntrinsicValue(this.left), resolveIntrinsicValue(this.right)] };
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Create an Equals condition intrinsic: `Equals(Ref(cutover), "true")`.
167
+ */
168
+ export function Equals(left: unknown, right: unknown): EqualsIntrinsic {
169
+ return new EqualsIntrinsic(left, right);
170
+ }
171
+
172
+ /**
173
+ * Fn::And condition intrinsic (#2068).
174
+ */
175
+ export class AndIntrinsic implements Intrinsic {
176
+ readonly [INTRINSIC_MARKER] = true as const;
177
+ private conditions: ConditionOperand[];
178
+
179
+ constructor(conditions: ConditionOperand[]) {
180
+ this.conditions = conditions;
181
+ }
182
+
183
+ toJSON(): { "Fn::And": unknown[] } {
184
+ return { "Fn::And": this.conditions.map(resolveConditionOperand) };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Create an And condition intrinsic over 2–10 operands, each a nested
190
+ * condition intrinsic, a `Condition` declarable, or a condition name.
191
+ */
192
+ export function And(...conditions: ConditionOperand[]): AndIntrinsic {
193
+ if (conditions.length < 2 || conditions.length > 10) {
194
+ throw new Error("And(...conditions): Fn::And takes between 2 and 10 conditions");
195
+ }
196
+ return new AndIntrinsic(conditions);
197
+ }
198
+
199
+ /**
200
+ * Fn::Or condition intrinsic (#2068).
201
+ */
202
+ export class OrIntrinsic implements Intrinsic {
203
+ readonly [INTRINSIC_MARKER] = true as const;
204
+ private conditions: ConditionOperand[];
205
+
206
+ constructor(conditions: ConditionOperand[]) {
207
+ this.conditions = conditions;
208
+ }
209
+
210
+ toJSON(): { "Fn::Or": unknown[] } {
211
+ return { "Fn::Or": this.conditions.map(resolveConditionOperand) };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Create an Or condition intrinsic over 2–10 operands, each a nested
217
+ * condition intrinsic, a `Condition` declarable, or a condition name.
218
+ */
219
+ export function Or(...conditions: ConditionOperand[]): OrIntrinsic {
220
+ if (conditions.length < 2 || conditions.length > 10) {
221
+ throw new Error("Or(...conditions): Fn::Or takes between 2 and 10 conditions");
222
+ }
223
+ return new OrIntrinsic(conditions);
224
+ }
225
+
226
+ /**
227
+ * Fn::Not condition intrinsic (#2068).
228
+ */
229
+ export class NotIntrinsic implements Intrinsic {
230
+ readonly [INTRINSIC_MARKER] = true as const;
231
+ private condition: ConditionOperand;
232
+
233
+ constructor(condition: ConditionOperand) {
234
+ this.condition = condition;
235
+ }
236
+
237
+ toJSON(): { "Fn::Not": [unknown] } {
238
+ return { "Fn::Not": [resolveConditionOperand(this.condition)] };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Create a Not condition intrinsic over a nested condition intrinsic, a
244
+ * `Condition` declarable, or a condition name.
245
+ */
246
+ export function Not(condition: ConditionOperand): NotIntrinsic {
247
+ return new NotIntrinsic(condition);
248
+ }
249
+
125
250
  /**
126
251
  * Fn::Join intrinsic function
127
252
  * Joins values with a delimiter
@@ -88,4 +88,5 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
88
88
  WAW066: auditRule("WAW066", "merge-worthy", "guidance", "Private subnet's route table has no working default route", "Add a 0.0.0.0/0 route to a NAT gateway/Transit Gateway that exists in the template.", { category: "correctness" }),
89
89
  WAW067: auditRule("WAW067", "report-only", "guidance", "Single-AZ NAT gateway serves multi-AZ private subnets", "Add one NAT gateway per Availability Zone and point each AZ's subnets at its own.", { category: "best-practice" }),
90
90
  WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
91
+ WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
91
92
  };
@@ -60,6 +60,7 @@ import { waw065 } from "./waw065";
60
60
  import { waw066 } from "./waw066";
61
61
  import { waw067 } from "./waw067";
62
62
  import { waw068 } from "./waw068";
63
+ import { waw069 } from "./waw069";
63
64
 
64
65
  export const postSynthChecks: PostSynthCheck[] = [
65
66
  cor020,
@@ -122,4 +123,5 @@ export const postSynthChecks: PostSynthCheck[] = [
122
123
  waw066,
123
124
  waw067,
124
125
  waw068,
126
+ waw069,
125
127
  ];
@@ -0,0 +1,94 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw069, checkUndeclaredConditions } from "./waw069";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW069: Template references an undeclared condition", () => {
10
+ test("check metadata", () => {
11
+ expect(waw069.id).toBe("WAW069");
12
+ expect(waw069.description).toContain("condition");
13
+ });
14
+
15
+ test("resource Condition key referencing an undeclared condition → error", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ Rule: { Type: "AWS::Logs::LogGroup", Condition: "DoCutover", Properties: { RetentionInDays: 7 } },
19
+ },
20
+ });
21
+ const diags = checkUndeclaredConditions(ctx);
22
+ expect(diags).toHaveLength(1);
23
+ expect(diags[0].checkId).toBe("WAW069");
24
+ expect(diags[0].severity).toBe("error");
25
+ expect(diags[0].message).toContain("DoCutover");
26
+ expect(diags[0].entity).toBe("Rule");
27
+ });
28
+
29
+ test("Fn::If nested in properties referencing an undeclared condition → error", () => {
30
+ const ctx = makeCtx({
31
+ Resources: {
32
+ Rule: {
33
+ Type: "AWS::Logs::LogGroup",
34
+ Properties: { LogGroupName: { "Fn::If": ["DoCutover", "/on", "/off"] } },
35
+ },
36
+ },
37
+ });
38
+ const diags = checkUndeclaredConditions(ctx);
39
+ expect(diags).toHaveLength(1);
40
+ expect(diags[0].message).toContain("Fn::If");
41
+ });
42
+
43
+ test("output Condition key and Condition reference inside Conditions → error", () => {
44
+ const ctx = makeCtx({
45
+ Conditions: { NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] } },
46
+ Resources: { Rule: { Type: "AWS::Logs::LogGroup", Properties: {} } },
47
+ Outputs: { RuleName: { Condition: "AlsoMissing", Value: { Ref: "Rule" } } },
48
+ });
49
+ const diags = checkUndeclaredConditions(ctx);
50
+ expect(diags).toHaveLength(2);
51
+ const names = diags.map((d) => d.message);
52
+ expect(names.some((m) => m.includes('"DoCutover"'))).toBe(true);
53
+ expect(names.some((m) => m.includes('"AlsoMissing"'))).toBe(true);
54
+ });
55
+
56
+ test("declared conditions are quiet", () => {
57
+ const ctx = makeCtx({
58
+ Conditions: {
59
+ DoCutover: { "Fn::Equals": [{ Ref: "Cutover" }, "true"] },
60
+ NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] },
61
+ },
62
+ Resources: {
63
+ Rule: {
64
+ Type: "AWS::Logs::LogGroup",
65
+ Condition: "DoCutover",
66
+ Properties: { LogGroupName: { "Fn::If": ["DoCutover", "/on", "/off"] } },
67
+ },
68
+ },
69
+ Outputs: { RuleName: { Condition: "DoCutover", Value: { Ref: "Rule" } } },
70
+ });
71
+ expect(checkUndeclaredConditions(ctx)).toHaveLength(0);
72
+ });
73
+
74
+ test("an IAM policy Condition block is not a condition reference", () => {
75
+ const ctx = makeCtx({
76
+ Resources: {
77
+ Role: {
78
+ Type: "AWS::IAM::Role",
79
+ Properties: {
80
+ AssumeRolePolicyDocument: {
81
+ Statement: [
82
+ {
83
+ Effect: "Allow",
84
+ Condition: { StringEquals: { "aws:SourceAccount": "123456789012" } },
85
+ },
86
+ ],
87
+ },
88
+ },
89
+ },
90
+ },
91
+ });
92
+ expect(checkUndeclaredConditions(ctx)).toHaveLength(0);
93
+ });
94
+ });
Binary file
@@ -48,11 +48,15 @@ describe("awsPlugin", () => {
48
48
 
49
49
  test("returns intrinsics", () => {
50
50
  const intrinsics = awsPlugin.intrinsics!();
51
- expect(intrinsics.length).toBe(9);
51
+ expect(intrinsics.length).toBe(13);
52
52
  const names = intrinsics.map((i) => i.name);
53
53
  expect(names).toContain("Sub");
54
54
  expect(names).toContain("Ref");
55
55
  expect(names).toContain("GetAtt");
56
+ expect(names).toContain("Equals");
57
+ expect(names).toContain("And");
58
+ expect(names).toContain("Or");
59
+ expect(names).toContain("Not");
56
60
  });
57
61
 
58
62
  test("returns pseudo-parameters", () => {
package/src/plugin.ts CHANGED
@@ -111,6 +111,10 @@ export const awsPlugin: LexiconPlugin = {
111
111
  { name: "Split", description: "Fn::Split — split string by delimiter", isTag: false, foldsAsCall: true },
112
112
  { name: "Base64", description: "Fn::Base64 — encode to Base64", isTag: false, foldsAsCall: true },
113
113
  { name: "GetAZs", description: "Fn::GetAZs — list Availability Zones", isTag: false, foldsAsCall: true },
114
+ { name: "Equals", description: "Fn::Equals — condition comparing two values", isTag: false, foldsAsCall: true },
115
+ { name: "And", description: "Fn::And — condition conjunction", isTag: false, foldsAsCall: true },
116
+ { name: "Or", description: "Fn::Or — condition disjunction", isTag: false, foldsAsCall: true },
117
+ { name: "Not", description: "Fn::Not — condition negation", isTag: false, foldsAsCall: true },
114
118
  ];
115
119
  },
116
120
 
package/src/serializer.ts CHANGED
@@ -19,8 +19,9 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
19
19
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
20
20
  import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
21
21
  import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
22
- import { isAttrRefLike } from "@intentius/chant/utils";
22
+ import { isAttrRefLike, getLogicalName } from "@intentius/chant/utils";
23
23
  import { resolveDependsOn } from "@intentius/chant/resource-attributes";
24
+ import { isCondition } from "./condition";
24
25
  import { isDefaultTags, type TagEntry } from "./default-tags";
25
26
  import { isTemplateTransform } from "./template-transform";
26
27
  import { loadTaggableResources } from "./taggable";
@@ -41,6 +42,7 @@ interface CFTemplate {
41
42
  Metadata?: Record<string, unknown>;
42
43
  Transform?: string | string[];
43
44
  Parameters?: Record<string, CFParameter>;
45
+ Conditions?: Record<string, unknown>;
44
46
  Resources: Record<string, CFResource>;
45
47
  Outputs?: Record<string, CFOutput>;
46
48
  }
@@ -347,6 +349,13 @@ function serializeToTemplate(
347
349
  }
348
350
 
349
351
  template.Parameters[name] = param;
352
+ } else if (isCondition(entity)) {
353
+ // Condition declarable → Conditions section (#2068), lifted the way
354
+ // Parameter is lifted into Parameters above.
355
+ if (!template.Conditions) {
356
+ template.Conditions = {};
357
+ }
358
+ template.Conditions[name] = toCFValue(entity.expression, entityNames);
350
359
  } else if (isChildProject(entity)) {
351
360
  // ChildProjectInstance → AWS::CloudFormation::Stack resource
352
361
  const childProject = entity as ChildProjectInstance;
@@ -396,8 +405,13 @@ function serializeToTemplate(
396
405
  resource.DependsOn = resolved.length === 1 ? resolved[0] : resolved;
397
406
  }
398
407
  }
399
- // Pass-through attributes
400
- if (attrs.Condition) resource.Condition = attrs.Condition as string;
408
+ // Pass-through attributes. Condition accepts the Condition declarable
409
+ // as well as a name string (#2068).
410
+ if (attrs.Condition) {
411
+ resource.Condition = isCondition(attrs.Condition)
412
+ ? entityNames.get(attrs.Condition) ?? getLogicalName(attrs.Condition)
413
+ : attrs.Condition as string;
414
+ }
401
415
  if (attrs.DeletionPolicy) resource.DeletionPolicy = attrs.DeletionPolicy as string;
402
416
  if (attrs.UpdateReplacePolicy) resource.UpdateReplacePolicy = attrs.UpdateReplacePolicy as string;
403
417
  if (attrs.UpdatePolicy) resource.UpdatePolicy = attrs.UpdatePolicy;
@@ -471,6 +485,13 @@ function serializeToTemplate(
471
485
  if (exportName) {
472
486
  output.Export = { Name: exportName };
473
487
  }
488
+ // Same defensive read: condition (#2068/#2069) postdates older cores.
489
+ const condition = (stackOutput as { condition?: unknown }).condition;
490
+ if (condition) {
491
+ output.Condition = isCondition(condition)
492
+ ? entityNames.get(condition) ?? getLogicalName(condition)
493
+ : condition as string;
494
+ }
474
495
  template.Outputs[name] = output;
475
496
  }
476
497
  }
@@ -0,0 +1,16 @@
1
+ {
2
+ "AWSTemplateFormatVersion": "2010-09-09",
3
+ "Parameters": { "Cutover": { "Type": "String", "Default": "false", "AllowedValues": ["true", "false"] } },
4
+ "Conditions": {
5
+ "DoCutover": { "Fn::Equals": [ { "Ref": "Cutover" }, "true" ] },
6
+ "NoCutover": { "Fn::Not": [ { "Condition": "DoCutover" } ] }
7
+ },
8
+ "Resources": {
9
+ "Rule": {
10
+ "Type": "AWS::Logs::LogGroup",
11
+ "Condition": "DoCutover",
12
+ "Properties": { "LogGroupName": { "Fn::If": [ "DoCutover", "/cut/on", "/cut/off" ] }, "RetentionInDays": 7 }
13
+ }
14
+ },
15
+ "Outputs": { "RuleName": { "Condition": "DoCutover", "Value": { "Ref": "Rule" } } }
16
+ }