@intentius/chant-lexicon-aws 0.109.0 → 0.111.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 (46) hide show
  1. package/dist/condition.d.ts +12 -1
  2. package/dist/condition.d.ts.map +1 -1
  3. package/dist/import/generator.d.ts +69 -0
  4. package/dist/import/generator.d.ts.map +1 -1
  5. package/dist/import/parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/integrity.json +4 -4
  9. package/dist/intrinsics.d.ts +58 -6
  10. package/dist/intrinsics.d.ts.map +1 -1
  11. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  12. package/dist/lint/post-synth/waw042.d.ts.map +1 -1
  13. package/dist/manifest.json +22 -1
  14. package/dist/mapping.d.ts +27 -0
  15. package/dist/mapping.d.ts.map +1 -0
  16. package/dist/parameter.d.ts +16 -1
  17. package/dist/parameter.d.ts.map +1 -1
  18. package/dist/plugin.d.ts.map +1 -1
  19. package/dist/rules/cf-refs.ts +12 -2
  20. package/dist/rules/waw042.ts +25 -4
  21. package/dist/serializer.d.ts.map +1 -1
  22. package/dist/template-sections.d.ts +44 -0
  23. package/dist/template-sections.d.ts.map +1 -0
  24. package/package.json +2 -2
  25. package/src/condition.test.ts +31 -0
  26. package/src/condition.ts +15 -1
  27. package/src/import/generator.test.ts +24 -3
  28. package/src/import/generator.ts +322 -49
  29. package/src/import/parser.test.ts +0 -1
  30. package/src/import/parser.ts +74 -21
  31. package/src/import/roundtrip-build.test.ts +322 -0
  32. package/src/import/roundtrip-fixtures.test.ts +3 -3
  33. package/src/index.ts +10 -0
  34. package/src/intrinsics.test.ts +7 -2
  35. package/src/intrinsics.ts +102 -13
  36. package/src/lint/post-synth/cf-refs.ts +12 -2
  37. package/src/lint/post-synth/waw019.test.ts +32 -0
  38. package/src/lint/post-synth/waw042.test.ts +28 -0
  39. package/src/lint/post-synth/waw042.ts +25 -4
  40. package/src/mapping.test.ts +64 -0
  41. package/src/mapping.ts +41 -0
  42. package/src/parameter.ts +22 -1
  43. package/src/plugin.test.ts +2 -1
  44. package/src/plugin.ts +3 -0
  45. package/src/serializer.ts +79 -12
  46. package/src/template-sections.ts +71 -0
@@ -127,3 +127,34 @@ describe("serializer Conditions section (#2068)", () => {
127
127
  expect(template.Outputs.RuleNameByName.Condition).toBe("DoCutover");
128
128
  });
129
129
  });
130
+
131
+ describe("a name that is not the export name (#3604)", () => {
132
+ test("a Condition's name option is its key in Conditions and in every reference to it", () => {
133
+ const param = new Parameter("String");
134
+ const cond = new Condition(Equals(Ref(param), "true"), { name: "EnableReadReplica" });
135
+ const group = new LogGroup({ LogGroupName: If(cond, "a", "b") }, { Condition: cond });
136
+ const out = stackOutput(Ref(group), { condition: cond, lexicon: "aws" });
137
+ const template = serialize(
138
+ new Map<string, Declarable>([
139
+ ["EnableReadReplica", param],
140
+ ["EnableReadReplicaCondition", cond],
141
+ ["Group", group],
142
+ ["Out", out],
143
+ ]),
144
+ );
145
+ expect(template.Parameters.EnableReadReplica).toEqual({ Type: "String" });
146
+ expect(template.Conditions).toEqual({ EnableReadReplica: { "Fn::Equals": [{ Ref: "EnableReadReplica" }, "true"] } });
147
+ expect(template.Resources.Group.Condition).toBe("EnableReadReplica");
148
+ expect(template.Resources.Group.Properties.LogGroupName).toEqual({ "Fn::If": ["EnableReadReplica", "a", "b"] });
149
+ expect(template.Outputs.Out.Condition).toBe("EnableReadReplica");
150
+ expect(JSON.parse(JSON.stringify(Not(cond)))).toEqual({ "Fn::Not": [{ Condition: "EnableReadReplica" }] });
151
+ });
152
+
153
+ test("a stackOutput's name option is its key in Outputs", () => {
154
+ const group = new LogGroup({});
155
+ const out = stackOutput(Ref(group), { name: "QueueName", lexicon: "aws" });
156
+ const template = serialize(new Map<string, Declarable>([["QueueName", group], ["QueueNameOutput", out]]));
157
+ expect(Object.keys(template.Resources)).toEqual(["QueueName"]);
158
+ expect(template.Outputs).toEqual({ QueueName: { Value: { Ref: "QueueName" } } });
159
+ });
160
+ });
package/src/condition.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { DECLARABLE_MARKER, isDeclarable, type Declarable } from "@intentius/chant/declarable";
12
12
  import { isIntrinsic, type Intrinsic } from "@intentius/chant/intrinsic";
13
+ import { getLogicalName } from "@intentius/chant/utils";
13
14
 
14
15
  export const CONDITION_ENTITY_TYPE = "AWS::CloudFormation::Condition";
15
16
 
@@ -19,17 +20,30 @@ export class Condition implements Declarable {
19
20
  readonly entityType = CONDITION_ENTITY_TYPE;
20
21
  /** The boolean expression: an `Equals`/`And`/`Or`/`Not` intrinsic. */
21
22
  readonly expression: Intrinsic;
23
+ /** The condition's key in the template's `Conditions`, when it is not the
24
+ * export name. A template may use one name for a condition and a
25
+ * parameter; a module cannot export both under that name (#3604). */
26
+ readonly name?: string;
22
27
 
23
- constructor(expression: Intrinsic) {
28
+ constructor(expression: Intrinsic, options?: { name?: string }) {
24
29
  if (!isIntrinsic(expression)) {
25
30
  throw new Error(
26
31
  "new Condition(expression): expression must be a condition intrinsic (Equals, And, Or, Not)",
27
32
  );
28
33
  }
29
34
  this.expression = expression;
35
+ if (options?.name !== undefined) this.name = options.name;
30
36
  }
31
37
  }
32
38
 
39
+ /**
40
+ * The name a condition has in the template: its `name` option, else the
41
+ * logical name discovery gave it (the export name).
42
+ */
43
+ export function conditionName(condition: Condition, entityNames?: Map<unknown, string>): string {
44
+ return condition.name ?? entityNames?.get(condition) ?? getLogicalName(condition);
45
+ }
46
+
33
47
  /**
34
48
  * Type guard for the `Condition` declarable. Duck-typed on `entityType`
35
49
  * rather than `instanceof` so a lexicon built against a separate copy of
@@ -265,7 +265,7 @@ describe("CFGenerator", () => {
265
265
  expect(files[0].content).toContain("\\`field-name\\`");
266
266
  });
267
267
 
268
- test("generates nested GetAtt attribute with GetAtt function call", () => {
268
+ test("generates a dotted GetAtt attribute as its underscored property", () => {
269
269
  const ir: TemplateIR = {
270
270
  parameters: [],
271
271
  resources: [
@@ -286,7 +286,28 @@ describe("CFGenerator", () => {
286
286
 
287
287
  const files = generator.generate(ir);
288
288
 
289
- expect(files[0].content).toContain('GetAtt(ELB, "SourceSecurityGroup.OwnerAlias")');
290
- expect(files[0].content).toContain("import { GetAtt");
289
+ expect(files[0].content).toContain("BucketName: ELB.SourceSecurityGroup_OwnerAlias");
290
+ expect(files[0].content).not.toContain("GetAtt");
291
+ });
292
+
293
+ test("generates GetAtt by name for an attribute the class does not expose", () => {
294
+ const ir: TemplateIR = {
295
+ parameters: [],
296
+ resources: [
297
+ { logicalId: "MyQueue", type: "AWS::SQS::Queue", properties: {} },
298
+ {
299
+ logicalId: "MyBucket",
300
+ type: "AWS::S3::Bucket",
301
+ properties: {
302
+ BucketName: { __intrinsic: "GetAtt", logicalId: "MyQueue", attribute: "QueueName" },
303
+ },
304
+ },
305
+ ],
306
+ };
307
+
308
+ const files = generator.generate(ir);
309
+
310
+ expect(files[0].content).toContain('BucketName: GetAtt("MyQueue", "QueueName")');
311
+ expect(files[0].content).toMatch(/import \{[^}]*\bGetAtt\b/);
291
312
  });
292
313
  });
@@ -11,8 +11,20 @@ import { join } from "path";
11
11
  * TypeScript code generator for CloudFormation templates
12
12
  */
13
13
  export class CFGenerator implements TypeScriptGenerator {
14
+ /** One `main.ts` holds the whole template. Split files would each need the
15
+ * parameters, conditions and resources the others declare. */
16
+ readonly ownsLayout = true;
14
17
  private typeToClass: Map<string, string>;
15
18
  private allClassNames: Set<string>;
19
+ /** resourceType -> (property name on the class -> CloudFormation attribute name) */
20
+ private attrsByType: Map<string, Record<string, string>>;
21
+ /** Resource classes imported under another name, because a logical id in
22
+ * the template is the class's own name (`InternetGateway`). */
23
+ private classAliases: Map<string, string> = new Map();
24
+ /** Variable names for mappings ("m:<name>"), conditions ("c:<name>") and
25
+ * outputs ("o:<name>") whose template name is already a parameter's or
26
+ * resource's (#3604). */
27
+ private renames: Map<string, string> = new Map();
16
28
 
17
29
  constructor() {
18
30
  // Reverse lookup from the generated registry: resourceType → className.
@@ -21,10 +33,13 @@ export class CFGenerator implements TypeScriptGenerator {
21
33
  const meta = loadLexiconRegistry(join(import.meta.dirname, "../.."), "aws");
22
34
  this.typeToClass = new Map();
23
35
  this.allClassNames = new Set();
36
+ this.attrsByType = new Map();
24
37
  for (const [className, entry] of Object.entries(meta)) {
25
38
  if (entry.kind === "resource" && !className.includes("_")) {
26
39
  this.typeToClass.set(entry.resourceType, className);
27
40
  this.allClassNames.add(className);
41
+ // The aws registry also carries each class's attribute map.
42
+ this.attrsByType.set(entry.resourceType, (entry as { attrs?: Record<string, string> }).attrs ?? {});
28
43
  }
29
44
  }
30
45
  }
@@ -35,13 +50,21 @@ export class CFGenerator implements TypeScriptGenerator {
35
50
  generate(ir: TemplateIR): GeneratedFile[] {
36
51
  const lines: string[] = [];
37
52
 
53
+ this.classAliases = this.computeClassAliases(ir);
38
54
  // Collect the set of imported class names so we can detect variable name conflicts
39
55
  const importedSymbols = this.collectImportedSymbols(ir);
56
+ this.renames = this.collisionRenames(ir, importedSymbols);
40
57
 
41
58
  // Generate imports
42
59
  lines.push(this.generateImports(ir));
43
60
  lines.push("");
44
61
 
62
+ // Template-level Description and Metadata
63
+ const sections = this.generateTemplateSections(ir, importedSymbols);
64
+ if (sections.length > 0) {
65
+ lines.push(...sections, "");
66
+ }
67
+
45
68
  // Generate parameters
46
69
  for (const param of ir.parameters) {
47
70
  lines.push(this.generateParameter(param, importedSymbols));
@@ -51,6 +74,15 @@ export class CFGenerator implements TypeScriptGenerator {
51
74
  lines.push("");
52
75
  }
53
76
 
77
+ // Generate mappings
78
+ const mappings = this.mappings(ir);
79
+ for (const [name, map] of Object.entries(mappings)) {
80
+ lines.push(this.generateMapping(name, map, importedSymbols));
81
+ }
82
+ if (Object.keys(mappings).length > 0) {
83
+ lines.push("");
84
+ }
85
+
54
86
  // Generate conditions in dependency order ({ Condition: ... } references
55
87
  // point at earlier declarations) — #2069
56
88
  const conditions = ir.conditions ?? [];
@@ -92,19 +124,27 @@ export class CFGenerator implements TypeScriptGenerator {
92
124
  private collectImportedSymbols(ir: TemplateIR): Set<string> {
93
125
  const symbols = new Set<string>();
94
126
  if (ir.parameters.length > 0) symbols.add("Parameter");
127
+ if (Object.keys(this.mappings(ir)).length > 0) symbols.add("Mapping");
95
128
  if ((ir.conditions ?? []).length > 0) symbols.add("Condition");
96
129
  if ((ir.outputs ?? []).length > 0) symbols.add("stackOutput");
97
- const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "GetAtt", "Equals", "And", "Or", "Not"] as const;
130
+ const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "Equals", "And", "Or", "Not", "FindInMap", "ImportValue", "Cidr"] as const;
98
131
  for (const name of intrinsics) {
99
132
  if (irUsesIntrinsic(ir, name)) symbols.add(name);
100
133
  }
101
134
  // Outputs whose value is a Ref envelope render as Ref(<var>) (#2069)
102
135
  if ((ir.outputs ?? []).some((o) => hasIntrinsicInValue(o.value, "Ref"))) symbols.add("Ref");
136
+ // A Sub that names a parameter or resource renders it as Ref(<var>), and an
137
+ // attribute the class does not expose renders as GetAtt("<id>", "<attr>").
138
+ const uses = this.scanGeneratedIntrinsics(ir);
139
+ if (uses.ref) symbols.add("Ref");
140
+ if (uses.getAtt) symbols.add("GetAtt");
103
141
  if (this.needsAWSPseudo(ir)) symbols.add("AWS");
104
142
  for (const resource of ir.resources) {
105
143
  const parsed = this.parseResourceType(resource.type);
106
- if (parsed) symbols.add(parsed.resourceClass);
144
+ if (parsed) symbols.add(this.classAliases.get(parsed.resourceClass) ?? parsed.resourceClass);
107
145
  }
146
+ if (typeof ir.metadata?.description === "string") symbols.add("templateDescription");
147
+ if (ir.metadata?.templateMetadata !== undefined) symbols.add("templateMetadata");
108
148
  return symbols;
109
149
  }
110
150
 
@@ -115,6 +155,169 @@ export class CFGenerator implements TypeScriptGenerator {
115
155
  return importedSymbols.has(name) ? name + "_" : name;
116
156
  }
117
157
 
158
+ /**
159
+ * The property on the generated class that reads `attribute` of the
160
+ * resource `logicalId`, or undefined when the class has none. A dotted
161
+ * CloudFormation attribute (`Endpoint.Address`) maps to its underscored
162
+ * property (`Endpoint_Address`).
163
+ */
164
+ private attrProperty(logicalId: string, attribute: string, ir: TemplateIR): string | undefined {
165
+ const resource = ir.resources.find((r) => r.logicalId === logicalId);
166
+ if (!resource || !this.parseResourceType(resource.type)) return undefined;
167
+ const attrs = this.attrsByType.get(resource.type) ?? {};
168
+ const key = Object.keys(attrs).find((k) => attrs[k] === attribute);
169
+ return key !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : undefined;
170
+ }
171
+
172
+ /**
173
+ * An attribute read: `<var>.<property>` when the class exposes the
174
+ * attribute, else `GetAtt("<id>", "<attr>")`. CloudFormation accepts
175
+ * GetAtt on some writable properties (`AWS::SQS::Queue` `QueueName`) that
176
+ * the class does not list, and reading those as a property gives
177
+ * undefined.
178
+ */
179
+ private attrExpr(logicalId: string, attribute: string, ir: TemplateIR, importedSymbols: Set<string>): string {
180
+ const prop = this.attrProperty(logicalId, attribute, ir);
181
+ if (prop !== undefined) return `${this.safeVarName(logicalId, importedSymbols)}.${prop}`;
182
+ return `GetAtt(${JSON.stringify(logicalId)}, ${JSON.stringify(attribute)})`;
183
+ }
184
+
185
+ /** Whether `name` is a parameter or resource the template declares. */
186
+ private isDeclaredName(name: string, ir: TemplateIR): boolean {
187
+ return ir.parameters.some((p) => p.name === name) || ir.resources.some((r) => r.logicalId === name);
188
+ }
189
+
190
+ /**
191
+ * Which of Ref and GetAtt the generated source calls beyond the IR's own
192
+ * Ref and GetAtt envelopes: Sub interpolations of a declared name, and
193
+ * attribute reads the class does not expose.
194
+ */
195
+ private scanGeneratedIntrinsics(ir: TemplateIR): { ref: boolean; getAtt: boolean } {
196
+ const uses = { ref: false, getAtt: false };
197
+ const visit = (value: unknown): void => {
198
+ if (value === null || typeof value !== "object") return;
199
+ if (Array.isArray(value)) {
200
+ for (const item of value) visit(item);
201
+ return;
202
+ }
203
+ const obj = value as Record<string, unknown>;
204
+ if (obj.__intrinsic === "GetAtt") {
205
+ if (this.attrProperty(obj.logicalId as string, obj.attribute as string, ir) === undefined) uses.getAtt = true;
206
+ } else if (obj.__intrinsic === "Sub") {
207
+ const variables = (obj.variables ?? {}) as Record<string, unknown>;
208
+ for (const m of (obj.template as string).matchAll(/\$\{([^}]+)\}/g)) {
209
+ const expr = m[1];
210
+ if (expr.startsWith("AWS::") || expr.startsWith("!") || expr in variables) continue;
211
+ const dot = expr.indexOf(".");
212
+ if (dot < 0) uses.ref = true;
213
+ else if (this.attrProperty(expr.slice(0, dot), expr.slice(dot + 1), ir) === undefined) uses.getAtt = true;
214
+ }
215
+ }
216
+ for (const v of Object.values(obj)) visit(v);
217
+ };
218
+ for (const v of this.generatedValues(ir)) visit(v);
219
+ return uses;
220
+ }
221
+
222
+ /** Every name the template declares: parameters, resources, conditions, outputs. */
223
+ private declaredNames(ir: TemplateIR): Set<string> {
224
+ return new Set([
225
+ ...ir.parameters.map((p) => p.name),
226
+ ...ir.resources.map((r) => r.logicalId),
227
+ ...(ir.conditions ?? []).map((c) => c.name),
228
+ ...(ir.outputs ?? []).map((o) => o.name),
229
+ ...Object.keys(this.mappings(ir)),
230
+ ]);
231
+ }
232
+
233
+ /** The template's Mappings section, as the parser carried it. */
234
+ private mappings(ir: TemplateIR): Record<string, Record<string, Record<string, unknown>>> {
235
+ return (ir.metadata?.mappings ?? {}) as Record<string, Record<string, Record<string, unknown>>>;
236
+ }
237
+
238
+ /** The variable a declared mapping is exported as. */
239
+ private mappingVar(name: string, importedSymbols: Set<string>): string {
240
+ return this.renames.get(`m:${name}`) ?? this.safeVarName(name, importedSymbols);
241
+ }
242
+
243
+ /** A mapping declaration. */
244
+ private generateMapping(name: string, map: Record<string, Record<string, unknown>>, importedSymbols: Set<string>): string {
245
+ const varName = this.mappingVar(name, importedSymbols);
246
+ const nameOpt = this.renames.has(`m:${name}`) ? `, { name: ${JSON.stringify(name)} }` : "";
247
+ return `export const ${varName} = new Mapping(${JSON.stringify(map, null, 2)}${nameOpt});`;
248
+ }
249
+
250
+ /**
251
+ * A resource class whose name the template also uses for something it
252
+ * declares is imported under an alias (`InternetGateway as
253
+ * InternetGatewayResource`), so the declaration keeps its name. Suffixing
254
+ * the declaration instead would change its logical id.
255
+ */
256
+ private computeClassAliases(ir: TemplateIR): Map<string, string> {
257
+ const declared = this.declaredNames(ir);
258
+ const aliases = new Map<string, string>();
259
+ for (const resource of ir.resources) {
260
+ const cls = this.parseResourceType(resource.type)?.resourceClass;
261
+ if (!cls || !declared.has(cls) || aliases.has(cls)) continue;
262
+ let alias = `${cls}Resource`;
263
+ for (let i = 2; declared.has(alias) || this.allClassNames.has(alias); i++) alias = `${cls}Resource${i}`;
264
+ aliases.set(cls, alias);
265
+ }
266
+ return aliases;
267
+ }
268
+
269
+ /** `templateDescription(...)` and `templateMetadata(...)` for the template's own sections. */
270
+ private generateTemplateSections(ir: TemplateIR, importedSymbols: Set<string>): string[] {
271
+ const declared = this.declaredNames(ir);
272
+ const freeName = (base: string) => {
273
+ let v = base;
274
+ for (let i = 2; declared.has(v) || importedSymbols.has(v); i++) v = `${base}${i}`;
275
+ declared.add(v);
276
+ return v;
277
+ };
278
+ const lines: string[] = [];
279
+ if (typeof ir.metadata?.description === "string") {
280
+ lines.push(`export const ${freeName("description")} = templateDescription(${JSON.stringify(ir.metadata.description)});`);
281
+ }
282
+ if (ir.metadata?.templateMetadata !== undefined) {
283
+ const value = this.generateValue(ir.metadata.templateMetadata, ir, importedSymbols);
284
+ lines.push(`export const ${freeName("metadata")} = templateMetadata(${value});`);
285
+ }
286
+ return lines;
287
+ }
288
+
289
+ /**
290
+ * A condition or output whose template name is a parameter's or resource's
291
+ * cannot be exported under that name too. It gets a suffixed variable
292
+ * (`EnableReadReplicaCondition`, `QueueNameOutput`), and the generated code
293
+ * passes the template name as its `name` option.
294
+ */
295
+ private collisionRenames(ir: TemplateIR, importedSymbols: Set<string>): Map<string, string> {
296
+ const renames = new Map<string, string>();
297
+ const taken = new Set<string>();
298
+ const varOf = (name: string) => this.safeVarName(name, importedSymbols);
299
+ for (const p of ir.parameters) taken.add(varOf(p.name));
300
+ for (const r of ir.resources) taken.add(varOf(r.logicalId));
301
+ const claim = (key: string, name: string, suffix: string) => {
302
+ let v = varOf(name);
303
+ if (taken.has(v)) {
304
+ v = `${name}${suffix}`;
305
+ for (let i = 2; taken.has(v); i++) v = `${name}${suffix}${i}`;
306
+ renames.set(key, v);
307
+ }
308
+ taken.add(v);
309
+ };
310
+ for (const m of Object.keys(this.mappings(ir))) claim(`m:${m}`, m, "Mapping");
311
+ for (const c of ir.conditions ?? []) claim(`c:${c.name}`, c.name, "Condition");
312
+ for (const o of ir.outputs ?? []) claim(`o:${o.name}`, o.name, "Output");
313
+ return renames;
314
+ }
315
+
316
+ /** The variable a declared condition is exported as. */
317
+ private conditionVar(name: string, importedSymbols: Set<string>): string {
318
+ return this.renames.get(`c:${name}`) ?? this.safeVarName(name, importedSymbols);
319
+ }
320
+
118
321
  /**
119
322
  * Generate import statements
120
323
  */
@@ -122,7 +325,8 @@ export class CFGenerator implements TypeScriptGenerator {
122
325
  // Everything comes from the flat @intentius/chant-lexicon-aws package,
123
326
  // and the needed symbol set is exactly what collectImportedSymbols
124
327
  // computes for variable-name conflict detection.
125
- const allImports = [...this.collectImportedSymbols(ir)];
328
+ const aliasOf = new Map([...this.classAliases].map(([cls, alias]) => [alias, cls]));
329
+ const allImports = [...this.collectImportedSymbols(ir)].map((s) => (aliasOf.has(s) ? `${aliasOf.get(s)} as ${s}` : s));
126
330
  if (allImports.length === 0) {
127
331
  return "";
128
332
  }
@@ -147,12 +351,17 @@ export class CFGenerator implements TypeScriptGenerator {
147
351
  * Check if AWS pseudo-parameters are used
148
352
  */
149
353
  private needsAWSPseudo(ir: TemplateIR): boolean {
150
- for (const resource of ir.resources) {
151
- if (this.hasAWSPseudo(resource.properties)) {
152
- return true;
153
- }
154
- }
155
- return false;
354
+ return this.generatedValues(ir).some((v) => this.hasAWSPseudo(v));
355
+ }
356
+
357
+ /** Every IR value the generator writes as source. */
358
+ private generatedValues(ir: TemplateIR): unknown[] {
359
+ return [
360
+ ...ir.resources.flatMap((r) => [r.properties, r.metadata, r.attributes]),
361
+ ...(ir.conditions ?? []).map((c) => c.expression),
362
+ ...(ir.outputs ?? []).flatMap((o) => [o.value, o.exportName]),
363
+ ir.metadata?.templateMetadata,
364
+ ];
156
365
  }
157
366
 
158
367
  /**
@@ -193,7 +402,12 @@ export class CFGenerator implements TypeScriptGenerator {
193
402
  (r) => r.logicalId,
194
403
  (r) => {
195
404
  const extraDeps = new Set<string>();
196
- const deps = collectDependencies(r.properties, (obj) => {
405
+ // DependsOn names a resource that is generated as a variable reference.
406
+ const dependsOn = r.attributes?.DependsOn;
407
+ for (const d of Array.isArray(dependsOn) ? dependsOn : dependsOn !== undefined ? [dependsOn] : []) {
408
+ if (typeof d === "string" && resourceIds.has(d)) extraDeps.add(d);
409
+ }
410
+ const deps = collectDependencies({ properties: r.properties, metadata: r.metadata, attributes: r.attributes }, (obj) => {
197
411
  if (obj.__intrinsic === "Ref") {
198
412
  const name = obj.name as string;
199
413
  return name.startsWith("AWS::") ? null : name;
@@ -245,15 +459,18 @@ export class CFGenerator implements TypeScriptGenerator {
245
459
  */
246
460
  private conditionVarRef(name: string, ir: TemplateIR, importedSymbols: Set<string>): string {
247
461
  const declared = (ir.conditions ?? []).some((c) => c.name === name);
248
- return declared ? this.safeVarName(name, importedSymbols) : JSON.stringify(name);
462
+ return declared ? this.conditionVar(name, importedSymbols) : JSON.stringify(name);
249
463
  }
250
464
 
251
465
  /**
252
466
  * Generate a condition declaration (#2069)
253
467
  */
254
468
  private generateCondition(condition: ConditionIR, ir: TemplateIR, importedSymbols: Set<string>): string {
255
- const varName = this.safeVarName(condition.name, importedSymbols);
469
+ const varName = this.conditionVar(condition.name, importedSymbols);
256
470
  const exprStr = this.generateValue(condition.expression, ir, importedSymbols);
471
+ if (this.renames.has(`c:${condition.name}`)) {
472
+ return `export const ${varName} = new Condition(${exprStr}, { name: ${JSON.stringify(condition.name)} });`;
473
+ }
257
474
  return `export const ${varName} = new Condition(${exprStr});`;
258
475
  }
259
476
 
@@ -261,7 +478,7 @@ export class CFGenerator implements TypeScriptGenerator {
261
478
  * Generate an output declaration as stackOutput(...) (#2069)
262
479
  */
263
480
  private generateOutput(output: OutputIR, ir: TemplateIR, importedSymbols: Set<string>): string {
264
- const varName = this.safeVarName(output.name, importedSymbols);
481
+ const varName = this.renames.get(`o:${output.name}`) ?? this.safeVarName(output.name, importedSymbols);
265
482
 
266
483
  // A bare Ref envelope becomes Ref(<var>) — stackOutput takes an intrinsic
267
484
  // or attribute reference, not the resource/parameter object itself.
@@ -281,15 +498,13 @@ export class CFGenerator implements TypeScriptGenerator {
281
498
  if (output.condition) {
282
499
  opts.push(`condition: ${this.conditionVarRef(output.condition, ir, importedSymbols)}`);
283
500
  }
284
- // A literal output has no entity to derive its lexicon from.
285
- if (typeof output.value === "string") {
286
- opts.push(`lexicon: "aws"`);
287
- }
501
+ // Every output names its lexicon. A literal, or a GetAtt written by name,
502
+ // has no entity to derive it from, and an output without one is dropped
503
+ // at serialization.
504
+ opts.push(`lexicon: "aws"`);
505
+ if (this.renames.has(`o:${output.name}`)) opts.push(`name: ${JSON.stringify(output.name)}`);
288
506
 
289
- if (opts.length > 0) {
290
- return `export const ${varName} = stackOutput(${valueStr}, { ${opts.join(", ")} });`;
291
- }
292
- return `export const ${varName} = stackOutput(${valueStr});`;
507
+ return `export const ${varName} = stackOutput(${valueStr}, { ${opts.join(", ")} });`;
293
508
  }
294
509
 
295
510
  /**
@@ -300,6 +515,9 @@ export class CFGenerator implements TypeScriptGenerator {
300
515
  const opts: string[] = [];
301
516
  if (param.description) opts.push(`description: ${JSON.stringify(param.description)}`);
302
517
  if (param.defaultValue !== undefined) opts.push(`defaultValue: ${JSON.stringify(param.defaultValue)}`);
518
+ for (const [key, value] of Object.entries(param.constraints ?? {})) {
519
+ opts.push(`${key.charAt(0).toLowerCase()}${key.slice(1)}: ${JSON.stringify(value)}`);
520
+ }
303
521
  if (opts.length > 0) {
304
522
  return `export const ${varName} = new Parameter("${param.type}", { ${opts.join(", ")} });`;
305
523
  }
@@ -316,13 +534,26 @@ export class CFGenerator implements TypeScriptGenerator {
316
534
  return `// Unsupported type: ${resource.type}\nexport const ${varName} = "${resource.logicalId}";`;
317
535
  }
318
536
  const varName = this.safeVarName(resource.logicalId, importedSymbols);
319
- const { resourceClass } = parsed;
537
+ const resourceClass = this.classAliases.get(parsed.resourceClass) ?? parsed.resourceClass;
320
538
  const propsStr = this.generateProps(resource.properties, ir, importedSymbols);
321
539
 
322
- // Resource-level Condition key → the attributes argument (#2069)
540
+ // Resource attributes -> the attributes argument: Condition (#2069),
541
+ // DependsOn, the policies, and Metadata.
542
+ const attrs: string[] = [];
323
543
  if (resource.condition) {
324
- const condRef = this.conditionVarRef(resource.condition, ir, importedSymbols);
325
- return `export const ${varName} = new ${resourceClass}(${propsStr}, { Condition: ${condRef} });`;
544
+ attrs.push(`Condition: ${this.conditionVarRef(resource.condition, ir, importedSymbols)}`);
545
+ }
546
+ for (const [key, value] of Object.entries(resource.attributes ?? {})) {
547
+ const valueStr = key === "DependsOn"
548
+ ? this.generateDependsOn(value, ir, importedSymbols)
549
+ : this.generateValue(value, ir, importedSymbols);
550
+ attrs.push(`${key}: ${valueStr}`);
551
+ }
552
+ if (resource.metadata !== undefined) {
553
+ attrs.push(`Metadata: ${this.generateValue(resource.metadata, ir, importedSymbols)}`);
554
+ }
555
+ if (attrs.length > 0) {
556
+ return `export const ${varName} = new ${resourceClass}(${propsStr}, { ${attrs.join(", ")} });`;
326
557
  }
327
558
 
328
559
  if (propsStr === "{}") {
@@ -332,6 +563,19 @@ export class CFGenerator implements TypeScriptGenerator {
332
563
  return `export const ${varName} = new ${resourceClass}(${propsStr});`;
333
564
  }
334
565
 
566
+ /**
567
+ * DependsOn as the variables of the resources it names, so the generated
568
+ * source references them. A name the template does not declare as a
569
+ * supported resource stays a string.
570
+ */
571
+ private generateDependsOn(value: unknown, ir: TemplateIR, importedSymbols: Set<string>): string {
572
+ const one = (d: unknown) =>
573
+ typeof d === "string" && ir.resources.some((r) => r.logicalId === d && this.parseResourceType(r.type))
574
+ ? this.safeVarName(d, importedSymbols)
575
+ : this.generateValue(d, ir, importedSymbols);
576
+ return Array.isArray(value) ? `[${value.map(one).join(", ")}]` : one(value);
577
+ }
578
+
335
579
  /**
336
580
  * Generate property object as TypeScript
337
581
  */
@@ -389,14 +633,7 @@ export class CFGenerator implements TypeScriptGenerator {
389
633
  }
390
634
 
391
635
  if (obj.__intrinsic === "GetAtt") {
392
- const logicalId = obj.logicalId as string;
393
- const attribute = obj.attribute as string;
394
- const varName = this.safeVarName(logicalId, importedSymbols);
395
- if (attribute.includes(".")) {
396
- return `GetAtt(${varName}, "${attribute}")`;
397
- }
398
- const attrName = this.toPropName(attribute);
399
- return `${varName}.${attrName}`;
636
+ return this.attrExpr(obj.logicalId as string, obj.attribute as string, ir, importedSymbols);
400
637
  }
401
638
 
402
639
  if (obj.__intrinsic === "Sub") {
@@ -437,9 +674,12 @@ export class CFGenerator implements TypeScriptGenerator {
437
674
  }
438
675
 
439
676
  if (obj.__intrinsic === "Select") {
440
- const index = obj.index as number;
441
- const values = (obj.values as unknown[]).map((v) => this.generateValue(v, ir, importedSymbols));
442
- return `Select(${index}, [${values.join(", ")}])`;
677
+ const index = JSON.stringify(obj.index);
678
+ if (Array.isArray(obj.values)) {
679
+ const values = obj.values.map((v) => this.generateValue(v, ir, importedSymbols));
680
+ return `Select(${index}, [${values.join(", ")}])`;
681
+ }
682
+ return `Select(${index}, ${this.generateValue(obj.source, ir, importedSymbols)})`;
443
683
  }
444
684
 
445
685
  if (obj.__intrinsic === "Split") {
@@ -461,6 +701,34 @@ export class CFGenerator implements TypeScriptGenerator {
461
701
  return `GetAZs(${this.generateValue(region, ir, importedSymbols)})`;
462
702
  }
463
703
 
704
+ if (obj.__intrinsic === "FindInMap") {
705
+ const mapName = obj.mapName;
706
+ const map = typeof mapName === "string" && mapName in this.mappings(ir)
707
+ ? this.mappingVar(mapName, importedSymbols)
708
+ : this.generateValue(mapName, ir, importedSymbols);
709
+ const first = this.generateValue(obj.firstKey, ir, importedSymbols);
710
+ const second = this.generateValue(obj.secondKey, ir, importedSymbols);
711
+ return `FindInMap(${map}, ${first}, ${second})`;
712
+ }
713
+
714
+ if (obj.__intrinsic === "ImportValue") {
715
+ return `ImportValue(${this.generateValue(obj.value, ir, importedSymbols)})`;
716
+ }
717
+
718
+ if (obj.__intrinsic === "Cidr") {
719
+ const args = [obj.ipBlock, obj.count, obj.cidrBits].map((v) => this.generateValue(v, ir, importedSymbols));
720
+ return `Cidr(${args.join(", ")})`;
721
+ }
722
+
723
+ // An intrinsic the parser read but the generator has no form for
724
+ // (Fn::Transform) is refused. Written as an object literal, it would
725
+ // build into a template CloudFormation cannot read.
726
+ if (typeof obj.__intrinsic === "string") {
727
+ throw new Error(
728
+ `chant import cannot generate Fn::${obj.__intrinsic} yet; the template uses it, so it is not imported`,
729
+ );
730
+ }
731
+
464
732
  // Regular object — quote keys that aren't valid JS identifiers
465
733
  const entries = Object.entries(obj).map(([key, val]) => {
466
734
  const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
@@ -486,33 +754,38 @@ export class CFGenerator implements TypeScriptGenerator {
486
754
  const regex = /\$\{([^}]+)\}/g;
487
755
  let match;
488
756
 
757
+ let literal = "";
489
758
  while ((match = regex.exec(template)) !== null) {
490
- parts.push(template.slice(currentPos, match.index));
491
-
492
759
  const expr = match[1];
760
+ // `${!Literal}` is CloudFormation's escape for a literal `${Literal}`;
761
+ // it stays in the template text.
762
+ if (expr.startsWith("!")) {
763
+ literal += template.slice(currentPos, match.index + match[0].length);
764
+ currentPos = match.index + match[0].length;
765
+ continue;
766
+ }
767
+ parts.push(literal + template.slice(currentPos, match.index));
768
+ literal = "";
769
+
493
770
  if (expr.startsWith("AWS::")) {
494
771
  expressions.push(`AWS.${this.pseudoParamName(expr)}`);
495
772
  } else if (variables && expr in variables) {
496
773
  expressions.push(this.generateValue(variables[expr], ir, importedSymbols));
497
774
  } else if (expr.includes(".")) {
498
775
  const dotIdx = expr.indexOf(".");
499
- const logicalId = expr.slice(0, dotIdx);
500
- const attr = expr.slice(dotIdx + 1);
501
- const varName = this.safeVarName(logicalId, importedSymbols);
502
- if (attr.includes(".")) {
503
- expressions.push(`GetAtt(${varName}, "${attr}")`);
504
- } else {
505
- const attrName = this.toPropName(attr);
506
- expressions.push(`${varName}.${attrName}`);
507
- }
776
+ expressions.push(this.attrExpr(expr.slice(0, dotIdx), expr.slice(dotIdx + 1), ir, importedSymbols));
777
+ } else if (this.isDeclaredName(expr, ir)) {
778
+ // A Declarable cannot be interpolated into Sub; Ref(<var>) serializes
779
+ // as `${Name}`.
780
+ expressions.push(`Ref(${this.safeVarName(expr, importedSymbols)})`);
508
781
  } else {
509
- expressions.push(this.safeVarName(expr, importedSymbols));
782
+ expressions.push(`Ref(${JSON.stringify(expr)})`);
510
783
  }
511
784
 
512
785
  currentPos = match.index + match[0].length;
513
786
  }
514
787
 
515
- parts.push(template.slice(currentPos));
788
+ parts.push(literal + template.slice(currentPos));
516
789
 
517
790
  if (expressions.length === 0) {
518
791
  return `Sub\`${escapePart(template)}\``;
@@ -284,7 +284,6 @@ describe("CFParser conditions and outputs (#2069)", () => {
284
284
  }),
285
285
  );
286
286
  expect(ir.warnings).toEqual([
287
- 'Template section "Mappings" is not carried by import — it is dropped from the generated source',
288
287
  'Template section "Rules" is not carried by import — it is dropped from the generated source',
289
288
  ]);
290
289
  });