@intentius/chant-lexicon-aws 0.0.2

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 (94) hide show
  1. package/README.md +438 -0
  2. package/package.json +30 -0
  3. package/src/codegen/__snapshots__/snapshot.test.ts.snap +197 -0
  4. package/src/codegen/docs-cli.ts +3 -0
  5. package/src/codegen/docs.ts +1206 -0
  6. package/src/codegen/extensions.ts +171 -0
  7. package/src/codegen/fallback.ts +33 -0
  8. package/src/codegen/generate-cli.ts +17 -0
  9. package/src/codegen/generate-lexicon.ts +98 -0
  10. package/src/codegen/generate-typescript.ts +257 -0
  11. package/src/codegen/generate.test.ts +125 -0
  12. package/src/codegen/generate.ts +226 -0
  13. package/src/codegen/idempotency.test.ts +28 -0
  14. package/src/codegen/naming.ts +120 -0
  15. package/src/codegen/package.test.ts +60 -0
  16. package/src/codegen/package.ts +84 -0
  17. package/src/codegen/patches.ts +98 -0
  18. package/src/codegen/rollback.test.ts +80 -0
  19. package/src/codegen/rollback.ts +20 -0
  20. package/src/codegen/sam.ts +387 -0
  21. package/src/codegen/snapshot.test.ts +84 -0
  22. package/src/codegen/typecheck.test.ts +50 -0
  23. package/src/codegen/typecheck.ts +4 -0
  24. package/src/codegen/versions.ts +37 -0
  25. package/src/coverage.ts +14 -0
  26. package/src/generated/index.d.ts +160753 -0
  27. package/src/generated/index.ts +14396 -0
  28. package/src/generated/lexicon-aws.json +114563 -0
  29. package/src/generated/runtime.ts +4 -0
  30. package/src/import/generator.test.ts +181 -0
  31. package/src/import/generator.ts +349 -0
  32. package/src/import/parser.test.ts +200 -0
  33. package/src/import/parser.ts +350 -0
  34. package/src/import/roundtrip-fixtures.test.ts +78 -0
  35. package/src/import/roundtrip.test.ts +195 -0
  36. package/src/index.ts +63 -0
  37. package/src/integration.test.ts +129 -0
  38. package/src/intrinsics.test.ts +167 -0
  39. package/src/intrinsics.ts +223 -0
  40. package/src/lint/post-synth/cf-refs.ts +91 -0
  41. package/src/lint/post-synth/cor020.ts +72 -0
  42. package/src/lint/post-synth/ext001.test.ts +68 -0
  43. package/src/lint/post-synth/ext001.ts +222 -0
  44. package/src/lint/post-synth/post-synth.test.ts +280 -0
  45. package/src/lint/post-synth/waw010.ts +49 -0
  46. package/src/lint/post-synth/waw011.ts +49 -0
  47. package/src/lint/post-synth/waw013.ts +45 -0
  48. package/src/lint/post-synth/waw014.ts +50 -0
  49. package/src/lint/post-synth/waw015.ts +100 -0
  50. package/src/lint/rules/hardcoded-region.ts +43 -0
  51. package/src/lint/rules/iam-wildcard.ts +66 -0
  52. package/src/lint/rules/index.ts +7 -0
  53. package/src/lint/rules/rules.test.ts +175 -0
  54. package/src/lint/rules/s3-encryption.ts +69 -0
  55. package/src/lsp/completions.test.ts +72 -0
  56. package/src/lsp/completions.ts +18 -0
  57. package/src/lsp/hover.test.ts +53 -0
  58. package/src/lsp/hover.ts +53 -0
  59. package/src/nested-stack.test.ts +83 -0
  60. package/src/nested-stack.ts +125 -0
  61. package/src/plugin.test.ts +316 -0
  62. package/src/plugin.ts +514 -0
  63. package/src/pseudo.test.ts +55 -0
  64. package/src/pseudo.ts +29 -0
  65. package/src/serializer.test.ts +507 -0
  66. package/src/serializer.ts +333 -0
  67. package/src/spec/fetch.test.ts +27 -0
  68. package/src/spec/fetch.ts +107 -0
  69. package/src/spec/parse.test.ts +153 -0
  70. package/src/spec/parse.ts +202 -0
  71. package/src/testdata/load-fixtures.ts +17 -0
  72. package/src/testdata/roundtrip/conditions.json +21 -0
  73. package/src/testdata/roundtrip/intrinsic-calls.json +31 -0
  74. package/src/testdata/roundtrip/intrinsics.json +18 -0
  75. package/src/testdata/roundtrip/multi-resource.json +37 -0
  76. package/src/testdata/roundtrip/parameters.json +23 -0
  77. package/src/testdata/roundtrip/simple.json +12 -0
  78. package/src/testdata/sam-fixtures/api.yaml +14 -0
  79. package/src/testdata/sam-fixtures/application.yaml +13 -0
  80. package/src/testdata/sam-fixtures/function.yaml +22 -0
  81. package/src/testdata/sam-fixtures/graphql-api.yaml +13 -0
  82. package/src/testdata/sam-fixtures/http-api.yaml +15 -0
  83. package/src/testdata/sam-fixtures/layer-version.yaml +15 -0
  84. package/src/testdata/sam-fixtures/multi-type-a.yaml +23 -0
  85. package/src/testdata/sam-fixtures/multi-type-b.yaml +29 -0
  86. package/src/testdata/sam-fixtures/simple-table.yaml +12 -0
  87. package/src/testdata/sam-fixtures/state-machine.yaml +14 -0
  88. package/src/testdata/schemas/aws-dynamodb-table.json +126 -0
  89. package/src/testdata/schemas/aws-iam-role.json +85 -0
  90. package/src/testdata/schemas/aws-lambda-function.json +90 -0
  91. package/src/testdata/schemas/aws-s3-bucket.json +83 -0
  92. package/src/testdata/schemas/aws-sns-topic.json +71 -0
  93. package/src/validate-cli.ts +19 -0
  94. package/src/validate.ts +34 -0
@@ -0,0 +1,223 @@
1
+ import { INTRINSIC_MARKER, resolveIntrinsicValue, type Intrinsic } from "@intentius/chant/intrinsic";
2
+ import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
3
+
4
+ /**
5
+ * Fn::Sub intrinsic function implementation
6
+ * Supports template string interpolation with AttrRefs, Declarables, and pseudo-parameters
7
+ */
8
+ export class SubIntrinsic implements Intrinsic {
9
+ readonly [INTRINSIC_MARKER] = true as const;
10
+ private templateParts: string[];
11
+ private values: unknown[];
12
+
13
+ constructor(templateParts: string[], values: unknown[]) {
14
+ this.templateParts = templateParts;
15
+ this.values = values;
16
+ }
17
+
18
+ toJSON(): { "Fn::Sub": string } {
19
+ const serialize = defaultInterpolationSerializer(
20
+ (name, attr) => `\${${name}.${attr}}`,
21
+ (ref) => `\${${ref}}`,
22
+ );
23
+ return { "Fn::Sub": buildInterpolatedString(this.templateParts, this.values, serialize) };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Tagged template function for creating Fn::Sub intrinsics
29
+ * Usage: Sub`${AWS.StackName}-bucket` or Sub`${bucket.arn}`
30
+ */
31
+ export function Sub(
32
+ templateParts: TemplateStringsArray,
33
+ ...values: unknown[]
34
+ ): SubIntrinsic {
35
+ return new SubIntrinsic([...templateParts], values);
36
+ }
37
+
38
+ /**
39
+ * Ref intrinsic function
40
+ * References a parameter or resource by logical name
41
+ */
42
+ export class RefIntrinsic implements Intrinsic {
43
+ readonly [INTRINSIC_MARKER] = true as const;
44
+ private name: string;
45
+
46
+ constructor(name: string) {
47
+ this.name = name;
48
+ }
49
+
50
+ toJSON(): { Ref: string } {
51
+ return { Ref: this.name };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Create a Ref intrinsic
57
+ */
58
+ export function Ref(name: string): RefIntrinsic {
59
+ return new RefIntrinsic(name);
60
+ }
61
+
62
+ /**
63
+ * Fn::GetAtt intrinsic function
64
+ * Gets an attribute from a resource
65
+ */
66
+ export class GetAttIntrinsic implements Intrinsic {
67
+ readonly [INTRINSIC_MARKER] = true as const;
68
+ private logicalName: string;
69
+ private attribute: string;
70
+
71
+ constructor(logicalName: string, attribute: string) {
72
+ this.logicalName = logicalName;
73
+ this.attribute = attribute;
74
+ }
75
+
76
+ toJSON(): { "Fn::GetAtt": [string, string] } {
77
+ return { "Fn::GetAtt": [this.logicalName, this.attribute] };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Create a GetAtt intrinsic
83
+ */
84
+ export function GetAtt(logicalName: string, attribute: string): GetAttIntrinsic {
85
+ return new GetAttIntrinsic(logicalName, attribute);
86
+ }
87
+
88
+ /**
89
+ * Fn::If intrinsic function
90
+ * Conditional value based on a condition
91
+ */
92
+ export class IfIntrinsic implements Intrinsic {
93
+ readonly [INTRINSIC_MARKER] = true as const;
94
+ private conditionName: string;
95
+ private valueIfTrue: unknown;
96
+ private valueIfFalse: unknown;
97
+
98
+ constructor(conditionName: string, valueIfTrue: unknown, valueIfFalse: unknown) {
99
+ this.conditionName = conditionName;
100
+ this.valueIfTrue = valueIfTrue;
101
+ this.valueIfFalse = valueIfFalse;
102
+ }
103
+
104
+ toJSON(): { "Fn::If": [string, unknown, unknown] } {
105
+ return { "Fn::If": [this.conditionName, resolveIntrinsicValue(this.valueIfTrue), resolveIntrinsicValue(this.valueIfFalse)] };
106
+ }
107
+ }
108
+
109
+
110
+ /**
111
+ * Create an If intrinsic
112
+ */
113
+ export function If(conditionName: string, valueIfTrue: unknown, valueIfFalse: unknown): IfIntrinsic {
114
+ return new IfIntrinsic(conditionName, valueIfTrue, valueIfFalse);
115
+ }
116
+
117
+ /**
118
+ * Fn::Join intrinsic function
119
+ * Joins values with a delimiter
120
+ */
121
+ export class JoinIntrinsic implements Intrinsic {
122
+ readonly [INTRINSIC_MARKER] = true as const;
123
+ private delimiter: string;
124
+ private values: unknown[];
125
+
126
+ constructor(delimiter: string, values: unknown[]) {
127
+ this.delimiter = delimiter;
128
+ this.values = values;
129
+ }
130
+
131
+ toJSON(): { "Fn::Join": [string, unknown[]] } {
132
+ return { "Fn::Join": [this.delimiter, this.values.map(resolveIntrinsicValue)] };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Create a Join intrinsic
138
+ */
139
+ export function Join(delimiter: string, values: unknown[]): JoinIntrinsic {
140
+ return new JoinIntrinsic(delimiter, values);
141
+ }
142
+
143
+ /**
144
+ * Fn::Select intrinsic function
145
+ * Selects a value from a list by index
146
+ */
147
+ export class SelectIntrinsic implements Intrinsic {
148
+ readonly [INTRINSIC_MARKER] = true as const;
149
+ private index: number;
150
+ private values: unknown[];
151
+
152
+ constructor(index: number, values: unknown[]) {
153
+ this.index = index;
154
+ this.values = values;
155
+ }
156
+
157
+ toJSON(): { "Fn::Select": [string, unknown[]] } {
158
+ return { "Fn::Select": [String(this.index), this.values.map(resolveIntrinsicValue)] };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Create a Select intrinsic
164
+ */
165
+ export function Select(index: number, values: unknown[]): SelectIntrinsic {
166
+ return new SelectIntrinsic(index, values);
167
+ }
168
+
169
+ /**
170
+ * Fn::Split intrinsic function
171
+ * Splits a string by delimiter
172
+ */
173
+ export class SplitIntrinsic implements Intrinsic {
174
+ readonly [INTRINSIC_MARKER] = true as const;
175
+ private delimiter: string;
176
+ private source: string | Intrinsic;
177
+
178
+ constructor(delimiter: string, source: string | Intrinsic) {
179
+ this.delimiter = delimiter;
180
+ this.source = source;
181
+ }
182
+
183
+ toJSON(): { "Fn::Split": [string, unknown] } {
184
+ const sourceValue = typeof this.source === "string"
185
+ ? this.source
186
+ : (this.source as Intrinsic & { toJSON(): unknown }).toJSON();
187
+ return { "Fn::Split": [this.delimiter, sourceValue] };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Create a Split intrinsic
193
+ */
194
+ export function Split(delimiter: string, source: string | Intrinsic): SplitIntrinsic {
195
+ return new SplitIntrinsic(delimiter, source);
196
+ }
197
+
198
+ /**
199
+ * Fn::Base64 intrinsic function
200
+ * Encodes a string to Base64
201
+ */
202
+ export class Base64Intrinsic implements Intrinsic {
203
+ readonly [INTRINSIC_MARKER] = true as const;
204
+ private value: string | Intrinsic;
205
+
206
+ constructor(value: string | Intrinsic) {
207
+ this.value = value;
208
+ }
209
+
210
+ toJSON(): { "Fn::Base64": unknown } {
211
+ const innerValue = typeof this.value === "string"
212
+ ? this.value
213
+ : (this.value as Intrinsic & { toJSON(): unknown }).toJSON();
214
+ return { "Fn::Base64": innerValue };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Create a Base64 intrinsic
220
+ */
221
+ export function Base64(value: string | Intrinsic): Base64Intrinsic {
222
+ return new Base64Intrinsic(value);
223
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Shared utility for extracting CloudFormation resource references
3
+ * from template properties.
4
+ *
5
+ * Used by WAW010 (redundant DependsOn) and COR020 (circular deps).
6
+ */
7
+
8
+ /**
9
+ * Parsed CloudFormation template structure.
10
+ */
11
+ export interface CFTemplate {
12
+ AWSTemplateFormatVersion?: string;
13
+ Resources?: Record<string, CFResource>;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ export interface CFResource {
18
+ Type: string;
19
+ Properties?: Record<string, unknown>;
20
+ DependsOn?: string | string[];
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ /**
25
+ * Parse a serialized CloudFormation template from build output.
26
+ * Accepts either a raw string or a SerializerResult (extracts primary).
27
+ */
28
+ export function parseCFTemplate(output: string | { primary: string }): CFTemplate | null {
29
+ const raw = typeof output === "string" ? output : output.primary;
30
+ try {
31
+ const parsed = JSON.parse(raw);
32
+ if (typeof parsed === "object" && parsed !== null) {
33
+ return parsed as CFTemplate;
34
+ }
35
+ } catch {
36
+ // Not valid JSON
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Recursively walk a CloudFormation property value and extract all logical IDs
43
+ * referenced via Ref and Fn::GetAtt.
44
+ *
45
+ * Skips pseudo-parameters (those starting with "AWS::").
46
+ */
47
+ export function findResourceRefs(value: unknown): Set<string> {
48
+ const refs = new Set<string>();
49
+ walkValue(value, refs);
50
+ return refs;
51
+ }
52
+
53
+ function walkValue(value: unknown, refs: Set<string>): void {
54
+ if (value === null || value === undefined) return;
55
+ if (typeof value !== "object") return;
56
+
57
+ if (Array.isArray(value)) {
58
+ for (const item of value) {
59
+ walkValue(item, refs);
60
+ }
61
+ return;
62
+ }
63
+
64
+ const obj = value as Record<string, unknown>;
65
+
66
+ // Check for Ref
67
+ if ("Ref" in obj && typeof obj.Ref === "string") {
68
+ if (!obj.Ref.startsWith("AWS::")) {
69
+ refs.add(obj.Ref);
70
+ }
71
+ }
72
+
73
+ // Check for Fn::GetAtt
74
+ if ("Fn::GetAtt" in obj) {
75
+ const getAtt = obj["Fn::GetAtt"];
76
+ if (Array.isArray(getAtt) && getAtt.length >= 1 && typeof getAtt[0] === "string") {
77
+ refs.add(getAtt[0]);
78
+ } else if (typeof getAtt === "string") {
79
+ // Dot-delimited form: "LogicalId.Attribute"
80
+ const logicalId = getAtt.split(".")[0];
81
+ if (logicalId) refs.add(logicalId);
82
+ }
83
+ }
84
+
85
+ // Recurse into all object values (including intrinsic function arguments)
86
+ for (const val of Object.values(obj)) {
87
+ if (typeof val === "object" && val !== null) {
88
+ walkValue(val, refs);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * COR020: Circular Resource Dependencies
3
+ *
4
+ * Builds a directed dependency graph from Ref, Fn::GetAtt, and DependsOn
5
+ * entries in the synthesized CloudFormation template. Detects cycles using
6
+ * DFS with three-color marking (white/gray/black).
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { detectCycles } from "@intentius/chant/discovery/cycles";
11
+ import { parseCFTemplate, findResourceRefs } from "./cf-refs";
12
+
13
+ export const cor020: PostSynthCheck = {
14
+ id: "COR020",
15
+ description: "Circular resource dependency — detects cycles in the resource dependency graph",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [_lexicon, output] of ctx.outputs) {
21
+ const template = parseCFTemplate(output);
22
+ if (!template?.Resources) continue;
23
+
24
+ const resourceIds = new Set(Object.keys(template.Resources));
25
+
26
+ // Build adjacency list: resource → set of resources it depends on
27
+ const graph = new Map<string, Set<string>>();
28
+
29
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
30
+ const deps = new Set<string>();
31
+
32
+ // Refs from Properties
33
+ const propertyRefs = findResourceRefs(resource.Properties);
34
+ for (const ref of propertyRefs) {
35
+ if (resourceIds.has(ref) && ref !== logicalId) {
36
+ deps.add(ref);
37
+ }
38
+ }
39
+
40
+ // Explicit DependsOn
41
+ if (resource.DependsOn) {
42
+ const dependsOn = Array.isArray(resource.DependsOn)
43
+ ? resource.DependsOn
44
+ : [resource.DependsOn];
45
+ for (const target of dependsOn) {
46
+ if (resourceIds.has(target) && target !== logicalId) {
47
+ deps.add(target);
48
+ }
49
+ }
50
+ }
51
+
52
+ graph.set(logicalId, deps);
53
+ }
54
+
55
+ // Detect cycles
56
+ const cycles = detectCycles(graph);
57
+ for (const cycle of cycles) {
58
+ // Add trailing node for display: "A -> B -> C -> A"
59
+ const chain = [...cycle, cycle[0]].join(" -> ");
60
+ diagnostics.push({
61
+ checkId: "COR020",
62
+ severity: "error",
63
+ message: `Circular resource dependency: ${chain}`,
64
+ entity: cycle[0],
65
+ lexicon: "aws",
66
+ });
67
+ }
68
+ }
69
+
70
+ return diagnostics;
71
+ },
72
+ };
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
4
+ import { ext001 } from "./ext001";
5
+
6
+ function makeCtx(template: object) {
7
+ return createPostSynthContext({ aws: template });
8
+ }
9
+
10
+ describe("EXT001: Extension Constraint Violation", () => {
11
+ test("check metadata", () => {
12
+ expect(ext001.id).toBe("EXT001");
13
+ expect(ext001.description).toContain("constraint");
14
+ });
15
+
16
+ test("no diagnostics on empty template", () => {
17
+ const ctx = makeCtx({ Resources: {} });
18
+ const diags = ext001.check(ctx);
19
+ expect(diags).toHaveLength(0);
20
+ });
21
+
22
+ test("no diagnostics on unknown resource type", () => {
23
+ const ctx = makeCtx({
24
+ Resources: {
25
+ MyCustom: {
26
+ Type: "Custom::MyResource",
27
+ Properties: { Foo: "bar" },
28
+ },
29
+ },
30
+ });
31
+ const diags = ext001.check(ctx);
32
+ expect(diags).toHaveLength(0);
33
+ });
34
+
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", () => {
41
+ const ctx = makeCtx({
42
+ Resources: {
43
+ MyBucket: {
44
+ Type: "AWS::S3::Bucket",
45
+ },
46
+ },
47
+ });
48
+ // Should not throw, diagnostics depend on lexicon data
49
+ const diags = ext001.check(ctx);
50
+ expect(Array.isArray(diags)).toBe(true);
51
+ });
52
+
53
+ 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);
66
+ expect(diags).toHaveLength(0);
67
+ });
68
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * EXT001: Extension Constraint Violation
3
+ *
4
+ * Validates CloudFormation resource properties against cross-property
5
+ * constraints from cfn-lint extension schemas.
6
+ *
7
+ * Constraint types:
8
+ * - if_then: if condition properties match, then requirement must hold
9
+ * - dependent_excluded: if property A exists, property B must not
10
+ * - required_or: at least one of the listed properties must exist
11
+ * - required_xor: exactly one of the listed properties must exist
12
+ */
13
+
14
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { parseCFTemplate, type CFResource } from "./cf-refs";
16
+
17
+ interface ExtensionConstraint {
18
+ name: string;
19
+ type: "if_then" | "dependent_excluded" | "required_or" | "required_xor";
20
+ condition?: unknown;
21
+ requirement?: unknown;
22
+ }
23
+
24
+ interface LexiconEntry {
25
+ kind: string;
26
+ cfn?: string;
27
+ constraints?: ExtensionConstraint[];
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ /**
32
+ * Load lexicon JSON to get constraints per resource type.
33
+ */
34
+ function loadLexiconConstraints(): Map<string, ExtensionConstraint[]> {
35
+ const map = new Map<string, ExtensionConstraint[]>();
36
+ try {
37
+ const { readFileSync } = require("fs");
38
+ const { join, dirname } = require("path");
39
+ const { fileURLToPath } = require("url");
40
+
41
+ // Navigate from src/lint/post-synth/ up to the package root
42
+ const pkgDir = join(__dirname, "..", "..", "..");
43
+ const lexiconPath = join(pkgDir, "src", "generated", "lexicon-aws.json");
44
+ const content = readFileSync(lexiconPath, "utf-8");
45
+ const data = JSON.parse(content) as Record<string, LexiconEntry>;
46
+
47
+ 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);
50
+ }
51
+ }
52
+ } catch {
53
+ // Lexicon not available — skip constraint checking
54
+ }
55
+ return map;
56
+ }
57
+
58
+ /**
59
+ * Check if a JSON schema "if" condition matches resource properties.
60
+ */
61
+ function matchesCondition(condition: unknown, properties: Record<string, unknown>): boolean {
62
+ if (!condition || typeof condition !== "object") return false;
63
+ const cond = condition as Record<string, unknown>;
64
+
65
+ // { properties: { PropName: { const: value } } }
66
+ if (cond.properties && typeof cond.properties === "object") {
67
+ const condProps = cond.properties as Record<string, unknown>;
68
+ for (const [propName, schema] of Object.entries(condProps)) {
69
+ if (!schema || typeof schema !== "object") continue;
70
+ const s = schema as Record<string, unknown>;
71
+
72
+ if ("const" in s) {
73
+ if (properties[propName] !== s.const) return false;
74
+ }
75
+ if ("enum" in s && Array.isArray(s.enum)) {
76
+ if (!s.enum.includes(properties[propName])) return false;
77
+ }
78
+ }
79
+ }
80
+
81
+ // { required: ["PropName"] } — check the properties exist
82
+ if (Array.isArray(cond.required)) {
83
+ for (const req of cond.required) {
84
+ if (!(req in properties)) return false;
85
+ }
86
+ }
87
+
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Check if a JSON schema "then" requirement holds for resource properties.
93
+ */
94
+ function checkRequirement(requirement: unknown, properties: Record<string, unknown>): string | null {
95
+ if (!requirement || typeof requirement !== "object") return null;
96
+ const req = requirement as Record<string, unknown>;
97
+
98
+ // { required: ["PropName"] }
99
+ if (Array.isArray(req.required)) {
100
+ const missing = req.required.filter((r: string) => !(r in properties));
101
+ if (missing.length > 0) {
102
+ return `missing required properties: ${missing.join(", ")}`;
103
+ }
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ function validateResource(
110
+ logicalId: string,
111
+ resource: CFResource,
112
+ constraints: ExtensionConstraint[],
113
+ ): PostSynthDiagnostic[] {
114
+ const diagnostics: PostSynthDiagnostic[] = [];
115
+ const props = resource.Properties ?? {};
116
+
117
+ for (const constraint of constraints) {
118
+ switch (constraint.type) {
119
+ case "if_then": {
120
+ if (matchesCondition(constraint.condition, props)) {
121
+ const error = checkRequirement(constraint.requirement, props);
122
+ if (error) {
123
+ diagnostics.push({
124
+ checkId: "EXT001",
125
+ severity: "error",
126
+ message: `Resource "${logicalId}" (${resource.Type}): constraint "${constraint.name}" violated — ${error}`,
127
+ entity: logicalId,
128
+ lexicon: "aws",
129
+ });
130
+ }
131
+ }
132
+ break;
133
+ }
134
+
135
+ case "dependent_excluded": {
136
+ // { PropA: ["PropB", "PropC"] } — if PropA exists, PropB and PropC must not
137
+ const req = constraint.requirement as Record<string, string[]> | undefined;
138
+ if (req) {
139
+ for (const [propName, excluded] of Object.entries(req)) {
140
+ if (propName in props) {
141
+ const present = excluded.filter((e) => e in props);
142
+ if (present.length > 0) {
143
+ diagnostics.push({
144
+ checkId: "EXT001",
145
+ severity: "error",
146
+ message: `Resource "${logicalId}" (${resource.Type}): "${propName}" excludes [${present.join(", ")}] but both are present`,
147
+ entity: logicalId,
148
+ lexicon: "aws",
149
+ });
150
+ }
151
+ }
152
+ }
153
+ }
154
+ break;
155
+ }
156
+
157
+ case "required_or": {
158
+ // ["PropA", "PropB"] — at least one must exist
159
+ const required = constraint.requirement as string[] | undefined;
160
+ if (required && Array.isArray(required)) {
161
+ const present = required.filter((r) => r in props);
162
+ if (present.length === 0) {
163
+ diagnostics.push({
164
+ checkId: "EXT001",
165
+ severity: "error",
166
+ message: `Resource "${logicalId}" (${resource.Type}): at least one of [${required.join(", ")}] must be specified`,
167
+ entity: logicalId,
168
+ lexicon: "aws",
169
+ });
170
+ }
171
+ }
172
+ break;
173
+ }
174
+
175
+ case "required_xor": {
176
+ // ["PropA", "PropB"] — exactly one must exist
177
+ const required = constraint.requirement as string[] | undefined;
178
+ if (required && Array.isArray(required)) {
179
+ const present = required.filter((r) => r in props);
180
+ if (present.length !== 1) {
181
+ diagnostics.push({
182
+ checkId: "EXT001",
183
+ severity: "error",
184
+ message: `Resource "${logicalId}" (${resource.Type}): exactly one of [${required.join(", ")}] must be specified (found ${present.length})`,
185
+ entity: logicalId,
186
+ lexicon: "aws",
187
+ });
188
+ }
189
+ }
190
+ break;
191
+ }
192
+ }
193
+ }
194
+
195
+ return diagnostics;
196
+ }
197
+
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 [];
205
+
206
+ const diagnostics: PostSynthDiagnostic[] = [];
207
+
208
+ for (const [_lexicon, output] of ctx.outputs) {
209
+ const template = parseCFTemplate(output);
210
+ if (!template?.Resources) continue;
211
+
212
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
213
+ const constraints = lexiconConstraints.get(resource.Type);
214
+ if (!constraints) continue;
215
+
216
+ diagnostics.push(...validateResource(logicalId, resource, constraints));
217
+ }
218
+ }
219
+
220
+ return diagnostics;
221
+ },
222
+ };