@intentius/chant-lexicon-aws 0.0.8 → 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 (115) hide show
  1. package/dist/integrity.json +25 -10
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +5743 -896
  4. package/dist/rules/cf-refs.ts +99 -0
  5. package/dist/rules/ext001.ts +30 -21
  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 +1 -0
  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 +388 -30
  25. package/dist/types/index.d.ts +1552 -1528
  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 +2 -2
  39. package/src/codegen/docs-links.test.ts +143 -0
  40. package/src/codegen/docs.ts +247 -132
  41. package/src/codegen/generate-lexicon.ts +8 -0
  42. package/src/codegen/generate-typescript.ts +25 -1
  43. package/src/composites/composites.test.ts +442 -0
  44. package/src/composites/fargate-alb.ts +253 -0
  45. package/src/composites/index.ts +20 -0
  46. package/src/composites/lambda-api.ts +20 -0
  47. package/src/composites/lambda-dynamodb.ts +64 -0
  48. package/src/composites/lambda-eventbridge.ts +36 -0
  49. package/src/composites/lambda-function.ts +76 -0
  50. package/src/composites/lambda-s3.ts +72 -0
  51. package/src/composites/lambda-sns.ts +30 -0
  52. package/src/composites/lambda-sqs.ts +44 -0
  53. package/src/composites/scheduled-lambda.ts +37 -0
  54. package/src/composites/vpc-default.ts +148 -0
  55. package/src/default-tags.test.ts +38 -0
  56. package/src/default-tags.ts +77 -0
  57. package/src/generated/index.d.ts +1552 -1528
  58. package/src/generated/lexicon-aws.json +5743 -896
  59. package/src/import/roundtrip-fixtures.test.ts +1 -1
  60. package/src/index.ts +21 -0
  61. package/src/integration.test.ts +71 -0
  62. package/src/intrinsics.ts +24 -13
  63. package/src/lint/post-synth/cf-refs.ts +99 -0
  64. package/src/lint/post-synth/ext001.test.ts +214 -31
  65. package/src/lint/post-synth/ext001.ts +30 -21
  66. package/src/lint/post-synth/waw013.test.ts +120 -0
  67. package/src/lint/post-synth/waw014.test.ts +121 -0
  68. package/src/lint/post-synth/waw015.test.ts +147 -0
  69. package/src/lint/post-synth/waw016.test.ts +141 -0
  70. package/src/lint/post-synth/waw016.ts +86 -0
  71. package/src/lint/post-synth/waw017.test.ts +130 -0
  72. package/src/lint/post-synth/waw017.ts +53 -0
  73. package/src/lint/post-synth/waw018.test.ts +109 -0
  74. package/src/lint/post-synth/waw018.ts +71 -0
  75. package/src/lint/post-synth/waw019.test.ts +138 -0
  76. package/src/lint/post-synth/waw019.ts +82 -0
  77. package/src/lint/post-synth/waw020.test.ts +125 -0
  78. package/src/lint/post-synth/waw020.ts +64 -0
  79. package/src/lint/post-synth/waw021.test.ts +81 -0
  80. package/src/lint/post-synth/waw021.ts +53 -0
  81. package/src/lint/post-synth/waw022.test.ts +54 -0
  82. package/src/lint/post-synth/waw022.ts +43 -0
  83. package/src/lint/post-synth/waw023.test.ts +53 -0
  84. package/src/lint/post-synth/waw023.ts +47 -0
  85. package/src/lint/post-synth/waw024.test.ts +64 -0
  86. package/src/lint/post-synth/waw024.ts +54 -0
  87. package/src/lint/post-synth/waw025.test.ts +42 -0
  88. package/src/lint/post-synth/waw025.ts +43 -0
  89. package/src/lint/post-synth/waw026.test.ts +54 -0
  90. package/src/lint/post-synth/waw026.ts +46 -0
  91. package/src/lint/post-synth/waw027.test.ts +63 -0
  92. package/src/lint/post-synth/waw027.ts +50 -0
  93. package/src/lint/post-synth/waw028.test.ts +68 -0
  94. package/src/lint/post-synth/waw028.ts +47 -0
  95. package/src/lint/post-synth/waw029.test.ts +179 -0
  96. package/src/lint/post-synth/waw029.ts +62 -0
  97. package/src/lint/post-synth/waw030.test.ts +800 -0
  98. package/src/lint/post-synth/waw030.ts +246 -0
  99. package/src/lint/rules/hardcoded-region.ts +1 -0
  100. package/src/lint/rules/iam-wildcard.ts +1 -0
  101. package/src/lint/rules/s3-encryption.ts +1 -0
  102. package/src/lsp/hover.ts +15 -0
  103. package/src/nested-stack-integration.test.ts +100 -0
  104. package/src/nested-stack.ts +1 -1
  105. package/src/plugin.ts +468 -36
  106. package/src/serializer.test.ts +330 -2
  107. package/src/serializer.ts +62 -1
  108. package/src/spec/fetch.ts +10 -0
  109. package/src/spec/parse.test.ts +141 -0
  110. package/src/spec/parse.ts +40 -0
  111. package/src/taggable.ts +44 -0
  112. package/src/testdata/nested-stacks/app.ts +26 -0
  113. package/src/testdata/nested-stacks/network/outputs.ts +17 -0
  114. package/src/testdata/nested-stacks/network/security.ts +17 -0
  115. package/src/testdata/nested-stacks/network/vpc.ts +54 -0
@@ -5,7 +5,7 @@ import { CFParser } from "./parser";
5
5
  import { CFGenerator } from "./generator";
6
6
  import { build } from "@intentius/chant/build";
7
7
  import { awsSerializer } from "../serializer";
8
- // Dynamic import to get all export keys for validation (not a runtime barrel import)
8
+ // Dynamic import to get all export keys for validation (not a runtime re-export)
9
9
  const awsLexicon = await import("../index");
10
10
 
11
11
  const parser = new CFParser();
package/src/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // Parameter
2
2
  export { Parameter } from "./parameter";
3
3
 
4
+ // Default Tags
5
+ export { defaultTags, isDefaultTags, DEFAULT_TAGS_MARKER } from "./default-tags";
6
+ export type { DefaultTags, TagEntry } from "./default-tags";
7
+
4
8
  // Serializer
5
9
  export { awsSerializer } from "./serializer";
6
10
 
@@ -64,6 +68,23 @@ export type { CFNSchema, SchemaProperty, SchemaDefinition } from "./spec/fetch";
64
68
  export { parseCFNSchema, cfnShortName, cfnServiceName } from "./spec/parse";
65
69
  export type { SchemaParseResult, ParsedResource, ParsedProperty, ParsedAttribute, ParsedPropertyType, ParsedEnum, PropertyConstraints } from "./spec/parse";
66
70
 
71
+ // Action constants
72
+ export { S3Actions, LambdaActions, DynamoDBActions, SQSActions, SNSActions, IAMActions, ECRActions, LogsActions, ECSActions } from "./actions/index";
73
+
74
+ // Built-in composites
75
+ export {
76
+ LambdaFunction, LambdaNode, LambdaPython, NodeLambda, PythonLambda,
77
+ LambdaApi,
78
+ LambdaScheduled, ScheduledLambda,
79
+ LambdaSqs, LambdaEventBridge, LambdaDynamoDB, LambdaS3, LambdaSns,
80
+ VpcDefault, FargateAlb,
81
+ } from "./composites/index";
82
+ export type {
83
+ LambdaFunctionProps, LambdaApiProps, ScheduledLambdaProps,
84
+ LambdaSqsProps, LambdaEventBridgeProps, LambdaDynamoDBProps, LambdaS3Props, LambdaSnsProps,
85
+ VpcDefaultProps, FargateAlbProps,
86
+ } from "./composites/index";
87
+
67
88
  // Code generation pipeline
68
89
  export { generate, writeGeneratedFiles } from "./codegen/generate";
69
90
  export { packageLexicon } from "./codegen/package";
@@ -100,6 +100,77 @@ describe("AWS Integration", () => {
100
100
  });
101
101
  });
102
102
 
103
+ describe("Resource-level attributes (second constructor arg)", () => {
104
+ test("DependsOn with Declarable reference in real generated class", () => {
105
+ const bucket = new (Bucket as any)({ BucketName: "data" });
106
+ const fn = new (Function as any)(
107
+ {
108
+ Runtime: "nodejs20.x",
109
+ Handler: "index.handler",
110
+ Code: { S3Bucket: "my-bucket", S3Key: "code.zip" },
111
+ Role: "arn:aws:iam::123456789012:role/role",
112
+ },
113
+ { DependsOn: [bucket] },
114
+ );
115
+
116
+ expect(fn.attributes).toBeDefined();
117
+ expect(fn.attributes.DependsOn).toEqual([bucket]);
118
+
119
+ const entities = new Map<string, Declarable>();
120
+ entities.set("DataBucket", bucket);
121
+ entities.set("Handler", fn);
122
+
123
+ const output = awsSerializer.serialize(entities);
124
+ const template = JSON.parse(output);
125
+ expect(template.Resources.Handler.DependsOn).toBe("DataBucket");
126
+ });
127
+
128
+ test("DeletionPolicy and Condition on real generated class", () => {
129
+ const bucket = new (Bucket as any)(
130
+ { BucketName: "important-data" },
131
+ { DeletionPolicy: "Retain", Condition: "CreateBucket" },
132
+ );
133
+
134
+ const entities = new Map<string, Declarable>();
135
+ entities.set("DataBucket", bucket);
136
+
137
+ const output = awsSerializer.serialize(entities);
138
+ const template = JSON.parse(output);
139
+
140
+ expect(template.Resources.DataBucket.DeletionPolicy).toBe("Retain");
141
+ expect(template.Resources.DataBucket.Condition).toBe("CreateBucket");
142
+ });
143
+
144
+ test("resource without attributes works unchanged", () => {
145
+ const bucket = new (Bucket as any)({ BucketName: "simple" });
146
+ expect(bucket.attributes).toEqual({});
147
+
148
+ const entities = new Map<string, Declarable>();
149
+ entities.set("SimpleBucket", bucket);
150
+
151
+ const output = awsSerializer.serialize(entities);
152
+ const template = JSON.parse(output);
153
+ expect(template.Resources.SimpleBucket.DeletionPolicy).toBeUndefined();
154
+ expect(template.Resources.SimpleBucket.Condition).toBeUndefined();
155
+ });
156
+
157
+ test("Metadata with intrinsics resolves correctly", () => {
158
+ const bucket = new (Bucket as any)(
159
+ { BucketName: "meta" },
160
+ { Metadata: { DeployedWith: Sub`${AWS.StackName}-chant` } },
161
+ );
162
+
163
+ const entities = new Map<string, Declarable>();
164
+ entities.set("MetaBucket", bucket);
165
+
166
+ const output = awsSerializer.serialize(entities);
167
+ const template = JSON.parse(output);
168
+ expect(template.Resources.MetaBucket.Metadata.DeployedWith).toEqual({
169
+ "Fn::Sub": "${AWS::StackName}-chant",
170
+ });
171
+ });
172
+ });
173
+
103
174
  describe("AttrRefs", () => {
104
175
  test("Bucket has expected AttrRefs", () => {
105
176
  const bucket = new (Bucket as any)({});
package/src/intrinsics.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { INTRINSIC_MARKER, resolveIntrinsicValue, type Intrinsic } from "@intentius/chant/intrinsic";
2
2
  import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
3
+ import { type Declarable } from "@intentius/chant/declarable";
4
+ import { getLogicalName } from "@intentius/chant/utils";
3
5
 
4
6
  /**
5
7
  * Fn::Sub intrinsic function implementation
@@ -37,26 +39,32 @@ export function Sub(
37
39
 
38
40
  /**
39
41
  * Ref intrinsic function
40
- * References a parameter or resource by logical name
42
+ * References a parameter or resource by logical name.
43
+ * Accepts either a string name or a Declarable entity (e.g. Parameter).
44
+ * When given a Declarable, the logical name is resolved at serialization time.
41
45
  */
42
46
  export class RefIntrinsic implements Intrinsic {
43
47
  readonly [INTRINSIC_MARKER] = true as const;
44
- private name: string;
48
+ private target: string | Declarable;
45
49
 
46
- constructor(name: string) {
47
- this.name = name;
50
+ constructor(target: string | Declarable) {
51
+ this.target = target;
48
52
  }
49
53
 
50
54
  toJSON(): { Ref: string } {
51
- return { Ref: this.name };
55
+ if (typeof this.target === "string") {
56
+ return { Ref: this.target };
57
+ }
58
+ return { Ref: getLogicalName(this.target) };
52
59
  }
53
60
  }
54
61
 
55
62
  /**
56
- * Create a Ref intrinsic
63
+ * Create a Ref intrinsic.
64
+ * Pass a string for direct parameter/resource names, or a Declarable (e.g. Parameter) for type-safe references.
57
65
  */
58
- export function Ref(name: string): RefIntrinsic {
59
- return new RefIntrinsic(name);
66
+ export function Ref(target: string | Declarable): RefIntrinsic {
67
+ return new RefIntrinsic(target);
60
68
  }
61
69
 
62
70
  /**
@@ -147,22 +155,25 @@ export function Join(delimiter: string, values: unknown[]): JoinIntrinsic {
147
155
  export class SelectIntrinsic implements Intrinsic {
148
156
  readonly [INTRINSIC_MARKER] = true as const;
149
157
  private index: number;
150
- private values: unknown[];
158
+ private values: unknown[] | Intrinsic;
151
159
 
152
- constructor(index: number, values: unknown[]) {
160
+ constructor(index: number, values: unknown[] | Intrinsic) {
153
161
  this.index = index;
154
162
  this.values = values;
155
163
  }
156
164
 
157
- toJSON(): { "Fn::Select": [string, unknown[]] } {
158
- return { "Fn::Select": [String(this.index), this.values.map(resolveIntrinsicValue)] };
165
+ toJSON(): { "Fn::Select": [string, unknown] } {
166
+ const resolvedValues = Array.isArray(this.values)
167
+ ? this.values.map(resolveIntrinsicValue)
168
+ : (this.values as Intrinsic & { toJSON(): unknown }).toJSON();
169
+ return { "Fn::Select": [String(this.index), resolvedValues] };
159
170
  }
160
171
  }
161
172
 
162
173
  /**
163
174
  * Create a Select intrinsic
164
175
  */
165
- export function Select(index: number, values: unknown[]): SelectIntrinsic {
176
+ export function Select(index: number, values: unknown[] | Intrinsic): SelectIntrinsic {
166
177
  return new SelectIntrinsic(index, values);
167
178
  }
168
179
 
@@ -50,6 +50,105 @@ export function findResourceRefs(value: unknown): Set<string> {
50
50
  return refs;
51
51
  }
52
52
 
53
+ /**
54
+ * Check if a value is a CloudFormation intrinsic function (Ref, Fn::*, etc.)
55
+ * that cannot be statically evaluated.
56
+ */
57
+ export function isIntrinsic(value: unknown): boolean {
58
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
59
+ const obj = value as Record<string, unknown>;
60
+ return "Ref" in obj || Object.keys(obj).some((k) => k.startsWith("Fn::"));
61
+ }
62
+
63
+ /**
64
+ * Walk IAM policy statements from a resource's properties.
65
+ * Handles IAM::Policy, IAM::Role, and IAM::ManagedPolicy layouts.
66
+ */
67
+ export function walkPolicyStatements(
68
+ resource: CFResource,
69
+ ): Array<Record<string, unknown>> {
70
+ const statements: Array<Record<string, unknown>> = [];
71
+ const props = resource.Properties ?? {};
72
+
73
+ // PolicyDocument.Statement (IAM::Policy, IAM::ManagedPolicy)
74
+ collectStatements(props.PolicyDocument, statements);
75
+
76
+ // AssumeRolePolicyDocument.Statement (IAM::Role)
77
+ collectStatements(props.AssumeRolePolicyDocument, statements);
78
+
79
+ // Policies[].PolicyDocument.Statement (IAM::Role inline policies)
80
+ if (Array.isArray(props.Policies)) {
81
+ for (const policy of props.Policies) {
82
+ if (typeof policy === "object" && policy !== null) {
83
+ collectStatements((policy as Record<string, unknown>).PolicyDocument, statements);
84
+ }
85
+ }
86
+ }
87
+
88
+ return statements;
89
+ }
90
+
91
+ function collectStatements(
92
+ policyDoc: unknown,
93
+ out: Array<Record<string, unknown>>,
94
+ ): void {
95
+ if (typeof policyDoc !== "object" || policyDoc === null) return;
96
+ const doc = policyDoc as Record<string, unknown>;
97
+ if (Array.isArray(doc.Statement)) {
98
+ for (const stmt of doc.Statement) {
99
+ if (typeof stmt === "object" && stmt !== null) {
100
+ out.push(stmt as Record<string, unknown>);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Normalize security group ingress rules from inline SecurityGroupIngress
108
+ * property and standalone SecurityGroupIngress resources.
109
+ */
110
+ export function getSecurityGroupIngress(
111
+ resource: CFResource,
112
+ ): Array<Record<string, unknown>> {
113
+ const rules: Array<Record<string, unknown>> = [];
114
+ const props = resource.Properties ?? {};
115
+
116
+ if (Array.isArray(props.SecurityGroupIngress)) {
117
+ for (const rule of props.SecurityGroupIngress) {
118
+ if (typeof rule === "object" && rule !== null) {
119
+ rules.push(rule as Record<string, unknown>);
120
+ }
121
+ }
122
+ }
123
+
124
+ return rules;
125
+ }
126
+
127
+ /**
128
+ * Check if a port range [fromPort, toPort] contains any of the sensitive ports.
129
+ */
130
+ export function portRangeContainsSensitive(
131
+ fromPort: unknown,
132
+ toPort: unknown,
133
+ sensitivePorts: number[],
134
+ ): boolean {
135
+ // Missing ports means all ports
136
+ if (fromPort === undefined && toPort === undefined) return true;
137
+
138
+ const from = typeof fromPort === "number" ? fromPort : -1;
139
+ const to = typeof toPort === "number" ? toPort : -1;
140
+
141
+ // If either is an intrinsic, we can't statically verify
142
+ if (isIntrinsic(fromPort) || isIntrinsic(toPort)) return false;
143
+
144
+ if (from === -1 && to === -1) return true;
145
+
146
+ for (const port of sensitivePorts) {
147
+ if (from <= port && port <= to) return true;
148
+ }
149
+ return false;
150
+ }
151
+
53
152
  function walkValue(value: unknown, refs: Set<string>): void {
54
153
  if (value === null || value === undefined) return;
55
154
  if (typeof value !== "object") return;
@@ -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
  });
@@ -16,7 +16,7 @@ import { join } from "path";
16
16
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
17
17
  import { parseCFTemplate, type CFResource } from "./cf-refs";
18
18
 
19
- interface ExtensionConstraint {
19
+ export interface ExtensionConstraint {
20
20
  name: string;
21
21
  type: "if_then" | "dependent_excluded" | "required_or" | "required_xor";
22
22
  condition?: unknown;
@@ -25,7 +25,7 @@ interface ExtensionConstraint {
25
25
 
26
26
  interface LexiconEntry {
27
27
  kind: string;
28
- cfn?: string;
28
+ resourceType: string;
29
29
  constraints?: ExtensionConstraint[];
30
30
  [key: string]: unknown;
31
31
  }
@@ -43,8 +43,8 @@ function loadLexiconConstraints(): Map<string, ExtensionConstraint[]> {
43
43
  const data = JSON.parse(content) as Record<string, LexiconEntry>;
44
44
 
45
45
  for (const [_name, entry] of Object.entries(data)) {
46
- if (entry.kind === "resource" && entry.cfn && entry.constraints && entry.constraints.length > 0) {
47
- 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);
48
48
  }
49
49
  }
50
50
  } catch {
@@ -193,28 +193,37 @@ function validateResource(
193
193
  return diagnostics;
194
194
  }
195
195
 
196
- export const ext001: PostSynthCheck = {
197
- id: "EXT001",
198
- description: "Extension constraint violation — cross-property validation from cfn-lint extension schemas",
199
-
200
- check(ctx: PostSynthContext): PostSynthDiagnostic[] {
201
- const lexiconConstraints = loadLexiconConstraints();
202
- 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 [];
203
204
 
204
- const diagnostics: PostSynthDiagnostic[] = [];
205
+ const diagnostics: PostSynthDiagnostic[] = [];
205
206
 
206
- for (const [_lexicon, output] of ctx.outputs) {
207
- const template = parseCFTemplate(output);
208
- if (!template?.Resources) continue;
207
+ for (const [_lexicon, output] of ctx.outputs) {
208
+ const template = parseCFTemplate(output);
209
+ if (!template?.Resources) continue;
209
210
 
210
- for (const [logicalId, resource] of Object.entries(template.Resources)) {
211
- const constraints = lexiconConstraints.get(resource.Type);
212
- if (!constraints) continue;
211
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
212
+ const constraints = constraintMap.get(resource.Type);
213
+ if (!constraints) continue;
213
214
 
214
- diagnostics.push(...validateResource(logicalId, resource, constraints));
215
- }
215
+ diagnostics.push(...validateResource(logicalId, resource, constraints));
216
216
  }
217
+ }
217
218
 
218
- return diagnostics;
219
+ return diagnostics;
220
+ }
221
+
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());
219
228
  },
220
229
  };