@intentius/chant-lexicon-aws 0.56.0 → 0.58.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 (61) hide show
  1. package/dist/agentcore/trace-fetch.d.ts +5 -4
  2. package/dist/agentcore/trace-fetch.d.ts.map +1 -1
  3. package/dist/condition.d.ts +27 -0
  4. package/dist/condition.d.ts.map +1 -0
  5. package/dist/generated/index.d.ts +1 -1
  6. package/dist/generated/index.d.ts.map +1 -1
  7. package/dist/import/generator.d.ts +19 -0
  8. package/dist/import/generator.d.ts.map +1 -1
  9. package/dist/import/parser.d.ts +19 -0
  10. package/dist/import/parser.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/integrity.json +4 -3
  14. package/dist/intrinsics.d.ts +77 -3
  15. package/dist/intrinsics.d.ts.map +1 -1
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/audit-lineage.d.ts +10 -0
  18. package/dist/lint/audit-lineage.d.ts.map +1 -0
  19. package/dist/lint/post-synth/index.d.ts.map +1 -1
  20. package/dist/lint/post-synth/waw069.d.ts +15 -0
  21. package/dist/lint/post-synth/waw069.d.ts.map +1 -0
  22. package/dist/manifest.json +29 -1
  23. package/dist/okf/index.md +1 -0
  24. package/dist/okf/rules/WAW069.md +11 -0
  25. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  26. package/dist/op/activities/floci.d.ts +3 -2
  27. package/dist/op/activities/floci.d.ts.map +1 -1
  28. package/dist/op/builders.d.ts +4 -4
  29. package/dist/plugin.d.ts.map +1 -1
  30. package/dist/rules/waw069.ts +0 -0
  31. package/dist/serializer.d.ts.map +1 -1
  32. package/dist/types/index.d.ts +3 -3
  33. package/package.json +2 -2
  34. package/src/agentcore/trace-fetch.ts +5 -4
  35. package/src/codegen/docs.ts +3 -3
  36. package/src/codegen/generate-typescript.ts +3 -3
  37. package/src/codegen/generate.ts +1 -1
  38. package/src/condition.test.ts +129 -0
  39. package/src/condition.ts +40 -0
  40. package/src/deep-observe.test.ts +23 -15
  41. package/src/deep-topology.test.ts +7 -3
  42. package/src/generated/index.d.ts +3 -3
  43. package/src/generated/index.ts +1 -1
  44. package/src/import/generator.ts +131 -50
  45. package/src/import/parser.test.ts +96 -0
  46. package/src/import/parser.ts +86 -1
  47. package/src/import/roundtrip-fixtures.test.ts +50 -0
  48. package/src/index.ts +15 -3
  49. package/src/intrinsics.ts +130 -5
  50. package/src/lint/audit-catalog.ts +6 -1
  51. package/src/lint/audit-lineage.ts +159 -0
  52. package/src/lint/post-synth/index.ts +2 -0
  53. package/src/lint/post-synth/waw069.test.ts +94 -0
  54. package/src/lint/post-synth/waw069.ts +0 -0
  55. package/src/op/activities/aws-apply.ts +1 -3
  56. package/src/op/activities/floci.ts +3 -2
  57. package/src/op/builders.ts +4 -4
  58. package/src/plugin.test.ts +5 -1
  59. package/src/plugin.ts +4 -0
  60. package/src/serializer.ts +24 -3
  61. package/src/testdata/roundtrip/with-conditions.json +16 -0
@@ -0,0 +1,129 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { Condition, isCondition, CONDITION_ENTITY_TYPE } from "./condition";
3
+ import { Equals, And, Or, Not, If, Ref } from "./intrinsics";
4
+ import { Parameter } from "./parameter";
5
+ import { awsSerializer } from "./serializer";
6
+ import { stackOutput } from "@intentius/chant/stack-output";
7
+ import { createResource } from "@intentius/chant/runtime";
8
+ import { resolveAttrRefs } from "@intentius/chant/discovery/resolve";
9
+ import type { Declarable } from "@intentius/chant/declarable";
10
+
11
+ const LogGroup = createResource("AWS::Logs::LogGroup", "aws", {});
12
+
13
+ function serialize(entities: Map<string, Declarable>): Record<string, any> {
14
+ resolveAttrRefs(entities);
15
+ return JSON.parse(awsSerializer.serialize(entities) as string);
16
+ }
17
+
18
+ describe("Condition declarable", () => {
19
+ test("carries the aws lexicon and the condition entity type", () => {
20
+ const cond = new Condition(Equals("a", "b"));
21
+ expect(cond.lexicon).toBe("aws");
22
+ expect(cond.entityType).toBe(CONDITION_ENTITY_TYPE);
23
+ expect(isCondition(cond)).toBe(true);
24
+ expect(isCondition(new Parameter("String"))).toBe(false);
25
+ expect(isCondition("DoCutover")).toBe(false);
26
+ });
27
+
28
+ test("rejects a non-intrinsic expression", () => {
29
+ expect(() => new Condition("true" as never)).toThrow(/condition intrinsic/);
30
+ });
31
+ });
32
+
33
+ describe("condition intrinsics", () => {
34
+ test("Equals resolves value intrinsics", () => {
35
+ const param = new Parameter("String");
36
+ const entities = new Map<string, Declarable>([["Cutover", param]]);
37
+ resolveAttrRefs(entities);
38
+ expect(JSON.parse(JSON.stringify(Equals(Ref(param), "true")))).toEqual({
39
+ "Fn::Equals": [{ Ref: "Cutover" }, "true"],
40
+ });
41
+ });
42
+
43
+ test("Not over a Condition declarable emits the Condition reference form", () => {
44
+ const cond = new Condition(Equals("a", "b"));
45
+ const entities = new Map<string, Declarable>([["DoCutover", cond]]);
46
+ resolveAttrRefs(entities);
47
+ expect(JSON.parse(JSON.stringify(Not(cond)))).toEqual({
48
+ "Fn::Not": [{ Condition: "DoCutover" }],
49
+ });
50
+ });
51
+
52
+ test("And/Or accept names, declarables, and nested intrinsics", () => {
53
+ expect(JSON.parse(JSON.stringify(And("A", Equals("x", "y"))))).toEqual({
54
+ "Fn::And": [{ Condition: "A" }, { "Fn::Equals": ["x", "y"] }],
55
+ });
56
+ expect(JSON.parse(JSON.stringify(Or("A", Not("B"))))).toEqual({
57
+ "Fn::Or": [{ Condition: "A" }, { "Fn::Not": [{ Condition: "B" }] }],
58
+ });
59
+ });
60
+
61
+ test("And/Or enforce CloudFormation's 2–10 operand bounds", () => {
62
+ expect(() => And("A")).toThrow(/between 2 and 10/);
63
+ expect(() => Or("A")).toThrow(/between 2 and 10/);
64
+ const eleven = Array.from({ length: 11 }, (_, i) => `C${i}`);
65
+ expect(() => And(...eleven)).toThrow(/between 2 and 10/);
66
+ });
67
+
68
+ test("If accepts the Condition declarable as the condition", () => {
69
+ const cond = new Condition(Equals("a", "b"));
70
+ const entities = new Map<string, Declarable>([["DoCutover", cond]]);
71
+ resolveAttrRefs(entities);
72
+ expect(JSON.parse(JSON.stringify(If(cond, "on", "off")))).toEqual({
73
+ "Fn::If": ["DoCutover", "on", "off"],
74
+ });
75
+ });
76
+ });
77
+
78
+ describe("serializer Conditions section (#2068)", () => {
79
+ test("Condition declarables are lifted into Conditions", () => {
80
+ const param = new Parameter("String", { defaultValue: "false" });
81
+ const doCutover = new Condition(Equals(Ref(param), "true"));
82
+ const noCutover = new Condition(Not(doCutover));
83
+ const template = serialize(
84
+ new Map<string, Declarable>([
85
+ ["Cutover", param],
86
+ ["DoCutover", doCutover],
87
+ ["NoCutover", noCutover],
88
+ ]),
89
+ );
90
+ expect(template.Conditions).toEqual({
91
+ DoCutover: { "Fn::Equals": [{ Ref: "Cutover" }, "true"] },
92
+ NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] },
93
+ });
94
+ // Never emitted as a Resource
95
+ expect(template.Resources).toEqual({});
96
+ });
97
+
98
+ test("resource-level Condition accepts the declarable as well as a string", () => {
99
+ const doCutover = new Condition(Equals("a", "b"));
100
+ const byRef = new LogGroup({ RetentionInDays: 7 }, { Condition: doCutover });
101
+ const byName = new LogGroup({ RetentionInDays: 7 }, { Condition: "DoCutover" });
102
+ const template = serialize(
103
+ new Map<string, Declarable>([
104
+ ["DoCutover", doCutover],
105
+ ["ByRef", byRef],
106
+ ["ByName", byName],
107
+ ]),
108
+ );
109
+ expect(template.Resources.ByRef.Condition).toBe("DoCutover");
110
+ expect(template.Resources.ByName.Condition).toBe("DoCutover");
111
+ });
112
+
113
+ test("stackOutput condition emits the output-level Condition key", () => {
114
+ const doCutover = new Condition(Equals("a", "b"));
115
+ const group = new LogGroup({ RetentionInDays: 7 }, { Condition: doCutover });
116
+ const out = stackOutput(Ref(group), { condition: doCutover });
117
+ const outByName = stackOutput(Ref(group), { condition: "DoCutover" });
118
+ const template = serialize(
119
+ new Map<string, Declarable>([
120
+ ["DoCutover", doCutover],
121
+ ["Rule", group],
122
+ ["RuleName", out],
123
+ ["RuleNameByName", outByName],
124
+ ]),
125
+ );
126
+ expect(template.Outputs.RuleName).toEqual({ Value: { Ref: "Rule" }, Condition: "DoCutover" });
127
+ expect(template.Outputs.RuleNameByName.Condition).toBe("DoCutover");
128
+ });
129
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CloudFormation template condition (#2068).
3
+ *
4
+ * A `Condition` declarable is lifted into the template's `Conditions`
5
+ * section by the serializer, the way `Parameter` is lifted into
6
+ * `Parameters`. Reference it from a resource's `Condition` attribute, an
7
+ * output's `condition` option, `If(...)`, or inside another condition via
8
+ * `And`/`Or`/`Not`.
9
+ */
10
+
11
+ import { DECLARABLE_MARKER, isDeclarable, type Declarable } from "@intentius/chant/declarable";
12
+ import { isIntrinsic, type Intrinsic } from "@intentius/chant/intrinsic";
13
+
14
+ export const CONDITION_ENTITY_TYPE = "AWS::CloudFormation::Condition";
15
+
16
+ export class Condition implements Declarable {
17
+ readonly [DECLARABLE_MARKER] = true as const;
18
+ readonly lexicon = "aws";
19
+ readonly entityType = CONDITION_ENTITY_TYPE;
20
+ /** The boolean expression: an `Equals`/`And`/`Or`/`Not` intrinsic. */
21
+ readonly expression: Intrinsic;
22
+
23
+ constructor(expression: Intrinsic) {
24
+ if (!isIntrinsic(expression)) {
25
+ throw new Error(
26
+ "new Condition(expression): expression must be a condition intrinsic (Equals, And, Or, Not)",
27
+ );
28
+ }
29
+ this.expression = expression;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Type guard for the `Condition` declarable. Duck-typed on `entityType`
35
+ * rather than `instanceof` so a lexicon built against a separate copy of
36
+ * `@intentius/chant` still matches (the #1137 convention).
37
+ */
38
+ export function isCondition(value: unknown): value is Condition {
39
+ return isDeclarable(value) && value.entityType === CONDITION_ENTITY_TYPE;
40
+ }
@@ -949,12 +949,15 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
949
949
  // all subtracted.
950
950
  expect(result.unchanged).toEqual(["AppRole"]);
951
951
 
952
- // The platform team's tag is accepted, so it is reported as suppressed
953
- // rather than as drift.
954
- expect(result.accepted.map((e) => e.name)).toEqual(["Assets"]);
955
- expect(result.accepted[0].changes.map((c) => c.path)).toEqual([
956
- "Tags[#cost-center].Key",
957
- "Tags[#cost-center].Value",
952
+ // The platform team's tag is on a path source never declared, so since
953
+ // #2160 the claim answers before the baseline gets a chance to: it is held
954
+ // elsewhere, at its live value, and it is not drift. AWS records no field
955
+ // manager, so the claimed-field set is the only source that can answer.
956
+ expect(result.accepted).toEqual([]);
957
+ expect(result.unclaimed.map((e) => e.name)).toEqual(["Assets"]);
958
+ expect(result.unclaimed[0].fields).toEqual([
959
+ { path: "Tags[#cost-center].Key", live: "cost-center", source: "claimed-fields", baseline: "cost-center" },
960
+ { path: "Tags[#cost-center].Value", live: "platform", source: "claimed-fields", baseline: "platform" },
958
961
  ]);
959
962
 
960
963
  // An unreadable deep read is a hole with a reason — never silence, never
@@ -976,7 +979,10 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
976
979
  ]);
977
980
  });
978
981
 
979
- test("without the baseline the platform tag is drift, and accepting it is what silences it", async () => {
982
+ test("the claim silences the platform tag with no baseline at all (#2160)", async () => {
983
+ // Before #2160 this tag was drift until somebody accepted it. The
984
+ // declaration is the table AWS cannot provide, and it answers on the first
985
+ // read, with nothing recorded anywhere.
980
986
  wireMocks();
981
987
  const result = await deepDiffForLexicon(awsPlugin, {
982
988
  environment: "prod",
@@ -984,15 +990,16 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
984
990
  entities: declared,
985
991
  });
986
992
  const assets = result.drifted.find((d) => d.name === "Assets");
987
- expect(assets?.changes.map((c) => c.path).sort()).toEqual([
993
+ expect(assets?.changes.map((c) => c.path)).toEqual(["VersioningConfiguration.Status"]);
994
+ expect(result.unclaimed[0].fields.map((f) => f.path)).toEqual([
988
995
  "Tags[#cost-center].Key",
989
996
  "Tags[#cost-center].Value",
990
- "VersioningConfiguration.Status",
991
997
  ]);
992
998
  expect(result.accepted).toEqual([]);
993
999
  });
994
1000
 
995
1001
  test("an accepted value that later changes is drift again, with all three axes", async () => {
1002
+ // The baseline's value-bound rule, on the path that is actually declared.
996
1003
  wireMocks();
997
1004
  const result = await deepDiffForLexicon(awsPlugin, {
998
1005
  environment: "prod",
@@ -1000,18 +1007,19 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
1000
1007
  entities: declared,
1001
1008
  baseline: {
1002
1009
  Assets: {
1003
- accepted: [{ path: "Tags[#cost-center].Value", value: "someone-elses-team" }],
1010
+ accepted: [{ path: "VersioningConfiguration.Status", value: "Enabled" }],
1004
1011
  },
1005
1012
  },
1006
1013
  });
1007
1014
  const change = result.drifted
1008
1015
  .find((d) => d.name === "Assets")
1009
- ?.changes.find((c) => c.path === "Tags[#cost-center].Value");
1016
+ ?.changes.find((c) => c.path === "VersioningConfiguration.Status");
1010
1017
  expect(change).toEqual({
1011
- path: "Tags[#cost-center].Value",
1012
- kind: "undeclared",
1013
- live: "platform",
1014
- baseline: "someone-elses-team",
1018
+ path: "VersioningConfiguration.Status",
1019
+ kind: "changed",
1020
+ declared: "Enabled",
1021
+ live: "Suspended",
1022
+ baseline: "Enabled",
1015
1023
  });
1016
1024
  });
1017
1025
 
@@ -360,7 +360,7 @@ describe("the fold answers (#1269)", () => {
360
360
  ]);
361
361
  });
362
362
 
363
- test("an out-of-band MapPublicIpOnLaunch flip reports; the service default does not", async () => {
363
+ test("an out-of-band MapPublicIpOnLaunch flip reports as unclaimed; the service default does not", async () => {
364
364
  const subnetRow = (mapPublic: boolean) => ({
365
365
  Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: mapPublic }],
366
366
  });
@@ -381,11 +381,15 @@ describe("the fold answers (#1269)", () => {
381
381
 
382
382
  ec2Describes({ "describe-subnets": subnetRow(true) });
383
383
  const diff = diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks);
384
- expect(diff.drifted).toEqual([
384
+ // Source never set MapPublicIpOnLaunch, so the flip is somebody else's
385
+ // (#2160): reported with its value, not counted as drift, and never
386
+ // proposed for an update.
387
+ expect(diff.drifted).toEqual([]);
388
+ expect(diff.unclaimed).toEqual([
385
389
  {
386
390
  name: "PublicSubnet",
387
391
  type: "AWS::EC2::Subnet",
388
- changes: [{ path: "MapPublicIpOnLaunch", kind: "undeclared", live: true }],
392
+ fields: [{ path: "MapPublicIpOnLaunch", live: true, source: "claimed-fields" }],
389
393
  },
390
394
  ]);
391
395
  });
@@ -135131,14 +135131,14 @@ export type ZonalAutoshiftConfiguration_ZonalAutoshiftStatus = "ENABLED";
135131
135131
  // --- Intrinsic functions ---
135132
135132
 
135133
135133
  export declare function And(conditions: unknown[]): any;
135134
+ export declare function And(...conditions: unknown[]): any;
135134
135135
  export declare function Base64(value: unknown): any;
135135
135136
  export declare function Cidr(ipBlock: unknown, count: unknown, cidrBits?: unknown): any;
135136
- export declare function Condition(condition: string): any;
135137
135137
  export declare function Equals(a: unknown, b: unknown): any;
135138
135138
  export declare function FindInMap(mapName: string, firstKey: unknown, secondKey: unknown): any;
135139
135139
  export declare function GetAtt(logicalName: string, attribute: string): any;
135140
135140
  export declare function GetAZs(region?: string): any;
135141
- export declare function If(condition: string, valueIfTrue: unknown, valueIfFalse: unknown): any;
135141
+ export declare function If(condition: string | Declarable, valueIfTrue: unknown, valueIfFalse: unknown): any;
135142
135142
  export declare function ImportValue(sharedValue: unknown): any;
135143
135143
  export declare function Join(delimiter: string, values: unknown[]): any;
135144
135144
  export declare function Not(condition: unknown): any;
@@ -135170,7 +135170,7 @@ export interface Declarable {
135170
135170
 
135171
135171
  export interface CFResourceAttributes {
135172
135172
  DependsOn?: Declarable | Declarable[] | string | string[];
135173
- Condition?: string;
135173
+ Condition?: string | Declarable;
135174
135174
  DeletionPolicy?: "Delete" | "Retain" | "RetainExceptOnCreate" | "Snapshot";
135175
135175
  UpdateReplacePolicy?: "Delete" | "Retain" | "Snapshot";
135176
135176
  UpdatePolicy?: {
@@ -10765,5 +10765,5 @@ export const ZonalShiftConfig = createProperty("AWS::EKS::Cluster.ZonalShiftConf
10765
10765
  export const ZookeeperAccess = createProperty("AWS::MSK::Cluster.ZookeeperAccess", "aws");
10766
10766
 
10767
10767
  // Re-exports for convenience
10768
- export { Sub, Ref, GetAtt, If, Join, Select, Split, Base64 } from "../intrinsics";
10768
+ export { Sub, Ref, GetAtt, If, Join, Select, Split, Base64, Equals, Not } from "../intrinsics";
10769
10769
  export { AWS, StackName, Region, AccountId, StackId, URLSuffix, NoValue, NotificationARNs } from "../pseudo";
@@ -1,6 +1,6 @@
1
1
  import { loadLexiconRegistry } from "@intentius/chant/codegen/registry";
2
2
  import { createRequire } from "module";
3
- import type { TemplateIR, ResourceIR, ParameterIR } from "@intentius/chant/import/parser";
3
+ import type { TemplateIR, ResourceIR, ParameterIR, ConditionIR, OutputIR } from "@intentius/chant/import/parser";
4
4
  const require = createRequire(import.meta.url);
5
5
  import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
6
6
  import { topoSort } from "@intentius/chant/codegen/topo-sort";
@@ -51,12 +51,33 @@ export class CFGenerator implements TypeScriptGenerator {
51
51
  lines.push("");
52
52
  }
53
53
 
54
+ // Generate conditions in dependency order ({ Condition: ... } references
55
+ // point at earlier declarations) — #2069
56
+ const conditions = ir.conditions ?? [];
57
+ const sortedConditions = this.sortConditions(conditions);
58
+ for (const condition of sortedConditions) {
59
+ lines.push(this.generateCondition(condition, ir, importedSymbols));
60
+ }
61
+
62
+ if (conditions.length > 0) {
63
+ lines.push("");
64
+ }
65
+
54
66
  // Generate resources in dependency order
55
67
  const sortedResources = this.sortByDependencies(ir.resources);
56
68
  for (const resource of sortedResources) {
57
69
  lines.push(this.generateResource(resource, ir, importedSymbols));
58
70
  }
59
71
 
72
+ // Generate outputs (#2069)
73
+ const outputs = ir.outputs ?? [];
74
+ if (outputs.length > 0) {
75
+ lines.push("");
76
+ for (const output of outputs) {
77
+ lines.push(this.generateOutput(output, ir, importedSymbols));
78
+ }
79
+ }
80
+
60
81
  return [
61
82
  {
62
83
  path: "main.ts",
@@ -71,10 +92,14 @@ export class CFGenerator implements TypeScriptGenerator {
71
92
  private collectImportedSymbols(ir: TemplateIR): Set<string> {
72
93
  const symbols = new Set<string>();
73
94
  if (ir.parameters.length > 0) symbols.add("Parameter");
74
- const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "GetAtt"] as const;
95
+ if ((ir.conditions ?? []).length > 0) symbols.add("Condition");
96
+ 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;
75
98
  for (const name of intrinsics) {
76
99
  if (irUsesIntrinsic(ir, name)) symbols.add(name);
77
100
  }
101
+ // Outputs whose value is a Ref envelope render as Ref(<var>) (#2069)
102
+ if ((ir.outputs ?? []).some((o) => hasIntrinsicInValue(o.value, "Ref"))) symbols.add("Ref");
78
103
  if (this.needsAWSPseudo(ir)) symbols.add("AWS");
79
104
  for (const resource of ir.resources) {
80
105
  const parsed = this.parseResourceType(resource.type);
@@ -94,54 +119,14 @@ export class CFGenerator implements TypeScriptGenerator {
94
119
  * Generate import statements
95
120
  */
96
121
  private generateImports(ir: TemplateIR): string {
97
- const imports: Set<string> = new Set();
98
- const serviceImports: Map<string, Set<string>> = new Map();
99
-
100
- // Collect what we need to import
101
- const needsParameter = ir.parameters.length > 0;
102
- if (needsParameter) {
103
- imports.add("Parameter");
104
- }
105
-
106
- // Check for intrinsics
107
- const intrinsics = ["Sub", "Ref", "If", "Join", "Select", "Split", "Base64", "GetAZs", "GetAtt"] as const;
108
- for (const name of intrinsics) {
109
- if (irUsesIntrinsic(ir, name)) imports.add(name);
110
- }
111
-
112
- // Check for AWS pseudo-parameters
113
- if (this.needsAWSPseudo(ir)) {
114
- imports.add("AWS");
115
- }
116
-
117
- // Collect service imports (skip unknown resource types)
118
- for (const resource of ir.resources) {
119
- const parsed = this.parseResourceType(resource.type);
120
- if (!parsed) continue;
121
- const { service, resourceClass } = parsed;
122
- if (!serviceImports.has(service)) {
123
- serviceImports.set(service, new Set());
124
- }
125
- serviceImports.get(service)!.add(resourceClass);
126
- }
127
-
128
- // Build import lines
129
- const importLines: string[] = [];
130
-
131
- // Merge service resource imports into the core imports set
132
- for (const [_service, resources] of serviceImports) {
133
- for (const r of resources) {
134
- imports.add(r);
135
- }
122
+ // Everything comes from the flat @intentius/chant-lexicon-aws package,
123
+ // and the needed symbol set is exactly what collectImportedSymbols
124
+ // computes for variable-name conflict detection.
125
+ const allImports = [...this.collectImportedSymbols(ir)];
126
+ if (allImports.length === 0) {
127
+ return "";
136
128
  }
137
-
138
- // All imports come from the flat @intentius/chant-lexicon-aws package
139
- const allImports = [...imports];
140
- if (allImports.length > 0) {
141
- importLines.push(`import { ${allImports.join(", ")} } from "@intentius/chant-lexicon-aws";`);
142
- }
143
-
144
- return importLines.join("\n");
129
+ return `import { ${allImports.join(", ")} } from "@intentius/chant-lexicon-aws";`;
145
130
  }
146
131
 
147
132
  /**
@@ -237,6 +222,76 @@ export class CFGenerator implements TypeScriptGenerator {
237
222
  );
238
223
  }
239
224
 
225
+ /**
226
+ * Sort conditions so `{ Condition: ... }` references point at earlier
227
+ * declarations (#2069).
228
+ */
229
+ private sortConditions(conditions: ConditionIR[]): ConditionIR[] {
230
+ return topoSort(
231
+ conditions,
232
+ (c) => c.name,
233
+ (c) => [
234
+ ...collectDependencies(c.expression, (obj) =>
235
+ obj.__intrinsic === "ConditionRef" ? (obj.name as string) : null,
236
+ ),
237
+ ],
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Render a condition reference as the condition's variable when the
243
+ * template declares it, or as a literal name string when it doesn't (an
244
+ * undeclared reference stays visible rather than breaking generation).
245
+ */
246
+ private conditionVarRef(name: string, ir: TemplateIR, importedSymbols: Set<string>): string {
247
+ const declared = (ir.conditions ?? []).some((c) => c.name === name);
248
+ return declared ? this.safeVarName(name, importedSymbols) : JSON.stringify(name);
249
+ }
250
+
251
+ /**
252
+ * Generate a condition declaration (#2069)
253
+ */
254
+ private generateCondition(condition: ConditionIR, ir: TemplateIR, importedSymbols: Set<string>): string {
255
+ const varName = this.safeVarName(condition.name, importedSymbols);
256
+ const exprStr = this.generateValue(condition.expression, ir, importedSymbols);
257
+ return `export const ${varName} = new Condition(${exprStr});`;
258
+ }
259
+
260
+ /**
261
+ * Generate an output declaration as stackOutput(...) (#2069)
262
+ */
263
+ private generateOutput(output: OutputIR, ir: TemplateIR, importedSymbols: Set<string>): string {
264
+ const varName = this.safeVarName(output.name, importedSymbols);
265
+
266
+ // A bare Ref envelope becomes Ref(<var>) — stackOutput takes an intrinsic
267
+ // or attribute reference, not the resource/parameter object itself.
268
+ let valueStr: string;
269
+ const value = output.value as Record<string, unknown> | null;
270
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && value.__intrinsic === "Ref" && !(value.name as string).startsWith("AWS::")) {
271
+ valueStr = `Ref(${this.safeVarName(value.name as string, importedSymbols)})`;
272
+ } else {
273
+ valueStr = this.generateValue(output.value, ir, importedSymbols);
274
+ }
275
+
276
+ const opts: string[] = [];
277
+ if (output.description) opts.push(`description: ${JSON.stringify(output.description)}`);
278
+ if (output.exportName !== undefined) {
279
+ opts.push(`exportName: ${this.generateValue(output.exportName, ir, importedSymbols)}`);
280
+ }
281
+ if (output.condition) {
282
+ opts.push(`condition: ${this.conditionVarRef(output.condition, ir, importedSymbols)}`);
283
+ }
284
+ // A literal output has no entity to derive its lexicon from.
285
+ if (typeof output.value === "string") {
286
+ opts.push(`lexicon: "aws"`);
287
+ }
288
+
289
+ if (opts.length > 0) {
290
+ return `export const ${varName} = stackOutput(${valueStr}, { ${opts.join(", ")} });`;
291
+ }
292
+ return `export const ${varName} = stackOutput(${valueStr});`;
293
+ }
294
+
240
295
  /**
241
296
  * Generate a parameter declaration
242
297
  */
@@ -264,6 +319,12 @@ export class CFGenerator implements TypeScriptGenerator {
264
319
  const { resourceClass } = parsed;
265
320
  const propsStr = this.generateProps(resource.properties, ir, importedSymbols);
266
321
 
322
+ // Resource-level Condition key → the attributes argument (#2069)
323
+ if (resource.condition) {
324
+ const condRef = this.conditionVarRef(resource.condition, ir, importedSymbols);
325
+ return `export const ${varName} = new ${resourceClass}(${propsStr}, { Condition: ${condRef} });`;
326
+ }
327
+
267
328
  if (propsStr === "{}") {
268
329
  return `export const ${varName} = new ${resourceClass}();`;
269
330
  }
@@ -344,9 +405,29 @@ export class CFGenerator implements TypeScriptGenerator {
344
405
 
345
406
  if (obj.__intrinsic === "If") {
346
407
  const condition = obj.condition as string;
408
+ const condRef = this.conditionVarRef(condition, ir, importedSymbols);
347
409
  const trueVal = this.generateValue(obj.valueIfTrue, ir, importedSymbols);
348
410
  const falseVal = this.generateValue(obj.valueIfFalse, ir, importedSymbols);
349
- return `If("${condition}", ${trueVal}, ${falseVal})`;
411
+ return `If(${condRef}, ${trueVal}, ${falseVal})`;
412
+ }
413
+
414
+ if (obj.__intrinsic === "Equals") {
415
+ const left = this.generateValue(obj.left, ir, importedSymbols);
416
+ const right = this.generateValue(obj.right, ir, importedSymbols);
417
+ return `Equals(${left}, ${right})`;
418
+ }
419
+
420
+ if (obj.__intrinsic === "And" || obj.__intrinsic === "Or") {
421
+ const operands = (obj.conditions as unknown[]).map((c) => this.generateValue(c, ir, importedSymbols));
422
+ return `${obj.__intrinsic}(${operands.join(", ")})`;
423
+ }
424
+
425
+ if (obj.__intrinsic === "Not") {
426
+ return `Not(${this.generateValue(obj.condition, ir, importedSymbols)})`;
427
+ }
428
+
429
+ if (obj.__intrinsic === "ConditionRef") {
430
+ return this.conditionVarRef(obj.name as string, ir, importedSymbols);
350
431
  }
351
432
 
352
433
  if (obj.__intrinsic === "Join") {
@@ -198,3 +198,99 @@ describe("CFParser", () => {
198
198
  expect(vars.KEY).toBe("value");
199
199
  });
200
200
  });
201
+
202
+ describe("CFParser conditions and outputs (#2069)", () => {
203
+ const parser = new CFParser();
204
+
205
+ const template = JSON.stringify({
206
+ AWSTemplateFormatVersion: "2010-09-09",
207
+ Parameters: { Cutover: { Type: "String", Default: "false" } },
208
+ Conditions: {
209
+ DoCutover: { "Fn::Equals": [{ Ref: "Cutover" }, "true"] },
210
+ NoCutover: { "Fn::Not": [{ Condition: "DoCutover" }] },
211
+ },
212
+ Resources: {
213
+ Rule: {
214
+ Type: "AWS::Logs::LogGroup",
215
+ Condition: "DoCutover",
216
+ Properties: { LogGroupName: { "Fn::If": ["DoCutover", "/on", "/off"] } },
217
+ },
218
+ },
219
+ Outputs: {
220
+ RuleName: { Condition: "DoCutover", Value: { Ref: "Rule" }, Description: "d", Export: { Name: "rule-name" } },
221
+ Plain: { Value: "literal" },
222
+ },
223
+ });
224
+
225
+ test("parses the Conditions section, Condition references included", () => {
226
+ const ir = parser.parse(template);
227
+ expect(ir.conditions).toHaveLength(2);
228
+ expect(ir.conditions![0]).toEqual({
229
+ name: "DoCutover",
230
+ expression: { __intrinsic: "Equals", left: { __intrinsic: "Ref", name: "Cutover" }, right: "true" },
231
+ });
232
+ expect(ir.conditions![1]).toEqual({
233
+ name: "NoCutover",
234
+ expression: { __intrinsic: "Not", condition: { __intrinsic: "ConditionRef", name: "DoCutover" } },
235
+ });
236
+ });
237
+
238
+ test("carries the resource-level Condition key", () => {
239
+ const ir = parser.parse(template);
240
+ expect(ir.resources[0].condition).toBe("DoCutover");
241
+ });
242
+
243
+ test("parses Outputs with Condition, Description, and Export", () => {
244
+ const ir = parser.parse(template);
245
+ expect(ir.outputs).toHaveLength(2);
246
+ expect(ir.outputs![0]).toEqual({
247
+ name: "RuleName",
248
+ value: { __intrinsic: "Ref", name: "Rule" },
249
+ description: "d",
250
+ exportName: "rule-name",
251
+ condition: "DoCutover",
252
+ });
253
+ expect(ir.outputs![1]).toEqual({
254
+ name: "Plain",
255
+ value: "literal",
256
+ description: undefined,
257
+ exportName: undefined,
258
+ condition: undefined,
259
+ });
260
+ });
261
+
262
+ test("a single-key Condition object in resource properties is NOT a condition reference", () => {
263
+ const ir = parser.parse(
264
+ JSON.stringify({
265
+ Resources: {
266
+ Role: {
267
+ Type: "AWS::IAM::Role",
268
+ Properties: {
269
+ Policy: { Condition: "not-a-ref" },
270
+ },
271
+ },
272
+ },
273
+ }),
274
+ );
275
+ expect(ir.resources[0].properties.Policy).toEqual({ Condition: "not-a-ref" });
276
+ });
277
+
278
+ test("names sections import cannot carry instead of dropping them silently", () => {
279
+ const ir = parser.parse(
280
+ JSON.stringify({
281
+ Resources: { R: { Type: "AWS::S3::Bucket" } },
282
+ Mappings: { M: {} },
283
+ Rules: { R1: {} },
284
+ }),
285
+ );
286
+ expect(ir.warnings).toEqual([
287
+ 'Template section "Mappings" is not carried by import — it is dropped from the generated source',
288
+ 'Template section "Rules" is not carried by import — it is dropped from the generated source',
289
+ ]);
290
+ });
291
+
292
+ test("no warnings for fully-carried templates", () => {
293
+ const ir = parser.parse(template);
294
+ expect(ir.warnings).toBeUndefined();
295
+ });
296
+ });