@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.
- package/README.md +438 -0
- package/package.json +30 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +197 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1206 -0
- package/src/codegen/extensions.ts +171 -0
- package/src/codegen/fallback.ts +33 -0
- package/src/codegen/generate-cli.ts +17 -0
- package/src/codegen/generate-lexicon.ts +98 -0
- package/src/codegen/generate-typescript.ts +257 -0
- package/src/codegen/generate.test.ts +125 -0
- package/src/codegen/generate.ts +226 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.ts +120 -0
- package/src/codegen/package.test.ts +60 -0
- package/src/codegen/package.ts +84 -0
- package/src/codegen/patches.ts +98 -0
- package/src/codegen/rollback.test.ts +80 -0
- package/src/codegen/rollback.ts +20 -0
- package/src/codegen/sam.ts +387 -0
- package/src/codegen/snapshot.test.ts +84 -0
- package/src/codegen/typecheck.test.ts +50 -0
- package/src/codegen/typecheck.ts +4 -0
- package/src/codegen/versions.ts +37 -0
- package/src/coverage.ts +14 -0
- package/src/generated/index.d.ts +160753 -0
- package/src/generated/index.ts +14396 -0
- package/src/generated/lexicon-aws.json +114563 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +181 -0
- package/src/import/generator.ts +349 -0
- package/src/import/parser.test.ts +200 -0
- package/src/import/parser.ts +350 -0
- package/src/import/roundtrip-fixtures.test.ts +78 -0
- package/src/import/roundtrip.test.ts +195 -0
- package/src/index.ts +63 -0
- package/src/integration.test.ts +129 -0
- package/src/intrinsics.test.ts +167 -0
- package/src/intrinsics.ts +223 -0
- package/src/lint/post-synth/cf-refs.ts +91 -0
- package/src/lint/post-synth/cor020.ts +72 -0
- package/src/lint/post-synth/ext001.test.ts +68 -0
- package/src/lint/post-synth/ext001.ts +222 -0
- package/src/lint/post-synth/post-synth.test.ts +280 -0
- package/src/lint/post-synth/waw010.ts +49 -0
- package/src/lint/post-synth/waw011.ts +49 -0
- package/src/lint/post-synth/waw013.ts +45 -0
- package/src/lint/post-synth/waw014.ts +50 -0
- package/src/lint/post-synth/waw015.ts +100 -0
- package/src/lint/rules/hardcoded-region.ts +43 -0
- package/src/lint/rules/iam-wildcard.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/rules.test.ts +175 -0
- package/src/lint/rules/s3-encryption.ts +69 -0
- package/src/lsp/completions.test.ts +72 -0
- package/src/lsp/completions.ts +18 -0
- package/src/lsp/hover.test.ts +53 -0
- package/src/lsp/hover.ts +53 -0
- package/src/nested-stack.test.ts +83 -0
- package/src/nested-stack.ts +125 -0
- package/src/plugin.test.ts +316 -0
- package/src/plugin.ts +514 -0
- package/src/pseudo.test.ts +55 -0
- package/src/pseudo.ts +29 -0
- package/src/serializer.test.ts +507 -0
- package/src/serializer.ts +333 -0
- package/src/spec/fetch.test.ts +27 -0
- package/src/spec/fetch.ts +107 -0
- package/src/spec/parse.test.ts +153 -0
- package/src/spec/parse.ts +202 -0
- package/src/testdata/load-fixtures.ts +17 -0
- package/src/testdata/roundtrip/conditions.json +21 -0
- package/src/testdata/roundtrip/intrinsic-calls.json +31 -0
- package/src/testdata/roundtrip/intrinsics.json +18 -0
- package/src/testdata/roundtrip/multi-resource.json +37 -0
- package/src/testdata/roundtrip/parameters.json +23 -0
- package/src/testdata/roundtrip/simple.json +12 -0
- package/src/testdata/sam-fixtures/api.yaml +14 -0
- package/src/testdata/sam-fixtures/application.yaml +13 -0
- package/src/testdata/sam-fixtures/function.yaml +22 -0
- package/src/testdata/sam-fixtures/graphql-api.yaml +13 -0
- package/src/testdata/sam-fixtures/http-api.yaml +15 -0
- package/src/testdata/sam-fixtures/layer-version.yaml +15 -0
- package/src/testdata/sam-fixtures/multi-type-a.yaml +23 -0
- package/src/testdata/sam-fixtures/multi-type-b.yaml +29 -0
- package/src/testdata/sam-fixtures/simple-table.yaml +12 -0
- package/src/testdata/sam-fixtures/state-machine.yaml +14 -0
- package/src/testdata/schemas/aws-dynamodb-table.json +126 -0
- package/src/testdata/schemas/aws-iam-role.json +85 -0
- package/src/testdata/schemas/aws-lambda-function.json +90 -0
- package/src/testdata/schemas/aws-s3-bucket.json +83 -0
- package/src/testdata/schemas/aws-sns-topic.json +71 -0
- package/src/validate-cli.ts +19 -0
- 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
|
+
};
|