@intentius/chant 0.52.1 → 0.53.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 (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,525 @@
1
+ /**
2
+ * Turn a CDK cloud assembly into the same dependency graph the Terraform
3
+ * advisor scores (#1056).
4
+ *
5
+ * Two jobs, both exact rather than heuristic:
6
+ *
7
+ * **Grouping.** CloudFormation resources are the wrong unit to rank. One L2
8
+ * construct emits several of them — a `Function` emits a role, a policy and the
9
+ * function — and they carve together or not at all. Every resource carries its
10
+ * emitting construct in `Metadata["aws:cdk:path"]`, and `tree.json` says which
11
+ * ancestor of that path is the construct a person actually wrote. So resources
12
+ * fold up to the shallowest L2 (or L3) ancestor and that construct ranks once.
13
+ * This is the CDK analogue of Terraform sub-resources folding into their parent.
14
+ *
15
+ * **Edges.** `Ref`, `Fn::GetAtt` and `Fn::Sub` are intra-stack; `Fn::ImportValue`
16
+ * resolves through the exporting stack's `Outputs` to the construct that
17
+ * actually produces the value, so a cross-stack dependency lands as a real edge
18
+ * between two constructs in both directions rather than a dangling name. There
19
+ * is no expression AST to consult and no regex over HCL: the template is JSON,
20
+ * and an intrinsic is a JSON object with a known key.
21
+ *
22
+ * Pure — no filesystem, no CDK CLI. `assembly.ts` does the reading.
23
+ */
24
+
25
+ import { indexTree } from "./assembly";
26
+ import { NESTED_STACK_TYPE, SCAFFOLDING_TYPES, isScaffoldingParameter, resolveCfnTier } from "./tier-map";
27
+ import type { CdkStack, CdkTreeNode, CfnResource, CloudAssembly } from "./types";
28
+ import type { CarveUnitMember, ScoreSignals } from "../terraform/score";
29
+ import type { TfEdge, TfGraph, TfNode } from "../terraform/types";
30
+
31
+ /**
32
+ * What a construct is, from its jsii class name.
33
+ *
34
+ * - `l1` — a `Cfn*` class: one construct, one CloudFormation resource.
35
+ * - `l2` — a curated service construct (`aws-cdk-lib.aws_s3.Bucket`).
36
+ * - `l3` — a pattern or a hand-written grouping construct. A Composite
37
+ * candidate (#1000), ranked whole instead of leaf by leaf.
38
+ * - `nested` — a `NestedStack`: a template of its own.
39
+ * - `container` — App/Stage/Stack. Never a scoring unit; the ranking lives
40
+ * inside it.
41
+ * - `unknown` — no tree metadata to go on.
42
+ *
43
+ * A user's own construct class is not jsii-compiled, so tree metadata reports
44
+ * its nearest jsii ancestor: `constructs.Construct` for a grouping construct,
45
+ * `aws-cdk-lib.Stack` for a stack subclass. That is why stacks are recognized
46
+ * by their manifest artifact rather than by fqn — the fqn alone cannot tell a
47
+ * user's stack from a user's L3.
48
+ */
49
+ export type ConstructLevel = "l1" | "l2" | "l3" | "nested" | "container" | "unknown";
50
+
51
+ const CONTAINER_FQNS = new Set([
52
+ "aws-cdk-lib.App",
53
+ "aws-cdk-lib.Stack",
54
+ "aws-cdk-lib.Stage",
55
+ "aws-cdk-lib.TreeMetadata",
56
+ "@aws-cdk/core.App",
57
+ "@aws-cdk/core.Stack",
58
+ "@aws-cdk/core.Stage",
59
+ ]);
60
+
61
+ const NESTED_STACK_FQNS = new Set(["aws-cdk-lib.NestedStack", "@aws-cdk/core.NestedStack"]);
62
+
63
+ /** The leaf ids CDK gives an L2's own L1 child. Not constructs anyone wrote. */
64
+ const IMPLICIT_LEAF_IDS = new Set(["Resource", "Default"]);
65
+
66
+ export function constructLevel(fqn: string | undefined): ConstructLevel {
67
+ if (!fqn) return "unknown";
68
+ if (CONTAINER_FQNS.has(fqn)) return "container";
69
+ if (NESTED_STACK_FQNS.has(fqn)) return "nested";
70
+ const cls = fqn.slice(fqn.lastIndexOf(".") + 1);
71
+ if (cls.startsWith("Cfn")) return "l1";
72
+ if (/^(aws-cdk-lib\.aws_|@aws-cdk\/aws-)/.test(fqn)) return "l2";
73
+ return "l3";
74
+ }
75
+
76
+ /** One CloudFormation resource, tied back to the construct that emitted it. */
77
+ interface ResourceEntry {
78
+ logicalId: string;
79
+ type: string;
80
+ stack: string;
81
+ /** Construct path from `aws:cdk:path`, or a synthetic one when absent. */
82
+ path: string;
83
+ resource: CfnResource;
84
+ }
85
+
86
+ /** A scoring unit: one construct plus every CloudFormation resource under it. */
87
+ interface Unit {
88
+ path: string;
89
+ kind: "resource" | "module";
90
+ level: ConstructLevel;
91
+ members: ResourceEntry[];
92
+ }
93
+
94
+ /** A reference found inside a template, before it is resolved to a unit. */
95
+ interface RawRef {
96
+ /** Logical ID in the referring stack, or an export name when `crossStack`. */
97
+ target: string;
98
+ attr: string;
99
+ via?: string;
100
+ crossStack?: boolean;
101
+ }
102
+
103
+ const AWS_PSEUDO = /^AWS::/;
104
+
105
+ /**
106
+ * Every reference in a JSON value, plus whether the value carries a construct
107
+ * that makes it dynamic (`Fn::If`, a template `Condition`). Walks the JSON
108
+ * looking for CloudFormation intrinsics — the whole grammar is `{"Fn::X": ...}`
109
+ * and `{"Ref": "..."}`, so there is nothing to tokenize.
110
+ */
111
+ function refsIn(value: unknown, via: string | undefined, out: RawRef[], flags: { conditional: boolean }): void {
112
+ if (Array.isArray(value)) {
113
+ for (const item of value) refsIn(item, via, out, flags);
114
+ return;
115
+ }
116
+ if (!value || typeof value !== "object") return;
117
+ const obj = value as Record<string, unknown>;
118
+ const keys = Object.keys(obj);
119
+
120
+ if (keys.length === 1) {
121
+ const [key] = keys;
122
+ const arg = obj[key];
123
+ if (key === "Ref" && typeof arg === "string") {
124
+ if (!AWS_PSEUDO.test(arg)) out.push({ target: arg, attr: "Ref", via });
125
+ return;
126
+ }
127
+ if (key === "Fn::GetAtt") {
128
+ // Both encodings: ["LogicalId", "Attr"] and the "LogicalId.Attr" shorthand.
129
+ const parts = Array.isArray(arg) ? arg : typeof arg === "string" ? arg.split(".") : [];
130
+ const [id, ...rest] = parts;
131
+ if (typeof id === "string" && !AWS_PSEUDO.test(id)) {
132
+ out.push({ target: id, attr: rest.filter((p) => typeof p === "string").join(".") || "Ref", via });
133
+ }
134
+ // A GetAtt argument can itself be an intrinsic in the array form.
135
+ if (Array.isArray(arg)) for (const item of arg) if (typeof item !== "string") refsIn(item, via, out, flags);
136
+ return;
137
+ }
138
+ if (key === "Fn::ImportValue") {
139
+ if (typeof arg === "string") out.push({ target: arg, attr: "Export", via, crossStack: true });
140
+ else refsIn(arg, via, out, flags);
141
+ return;
142
+ }
143
+ if (key === "Fn::Sub") {
144
+ // `"...${LogicalId.Attr}..."` or `["...", { var: <expr> }]`.
145
+ const body = Array.isArray(arg) ? arg[0] : arg;
146
+ const vars = Array.isArray(arg) && arg[1] && typeof arg[1] === "object" ? (arg[1] as Record<string, unknown>) : {};
147
+ const declared = new Set(Object.keys(vars));
148
+ if (typeof body === "string") {
149
+ for (const match of body.matchAll(/\$\{([^}]+)\}/g)) {
150
+ const token = match[1].trim();
151
+ if (AWS_PSEUDO.test(token) || declared.has(token) || token.startsWith("!")) continue;
152
+ const dot = token.indexOf(".");
153
+ const id = dot > 0 ? token.slice(0, dot) : token;
154
+ out.push({ target: id, attr: dot > 0 ? token.slice(dot + 1) : "Ref", via });
155
+ }
156
+ }
157
+ for (const inner of Object.values(vars)) refsIn(inner, via, out, flags);
158
+ return;
159
+ }
160
+ if (key === "Fn::If") {
161
+ flags.conditional = true;
162
+ // The condition name is not a resource; its branches are.
163
+ if (Array.isArray(arg)) for (const branch of arg.slice(1)) refsIn(branch, via, out, flags);
164
+ return;
165
+ }
166
+ }
167
+
168
+ for (const [key, inner] of Object.entries(obj)) refsIn(inner, via ?? key, out, flags);
169
+ }
170
+
171
+ /** The construct path a resource declares, normalized (CDK writes a leading `/` in places). */
172
+ function declaredPath(resource: CfnResource): string | undefined {
173
+ const raw = resource.Metadata?.["aws:cdk:path"];
174
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
175
+ return raw.replace(/^\/+/, "");
176
+ }
177
+
178
+ /**
179
+ * The property a CDK asset backs, when the resource is asset-backed at all.
180
+ * `""` means asset metadata is present but names no property; `undefined` means
181
+ * the resource carries no asset.
182
+ */
183
+ function assetProperty(resource: CfnResource): string | undefined {
184
+ const metadata = resource.Metadata ?? {};
185
+ const prop = metadata["aws:asset:property"];
186
+ if (typeof prop === "string") return prop;
187
+ return Object.keys(metadata).some((k) => k.startsWith("aws:asset:")) ? "" : undefined;
188
+ }
189
+
190
+ /**
191
+ * Strip an implicit `Resource`/`Default` leaf: the construct a person wrote is
192
+ * the parent. Used only when tree metadata cannot say better.
193
+ */
194
+ function ownerOf(path: string): string {
195
+ const segments = path.split("/");
196
+ if (segments.length > 1 && IMPLICIT_LEAF_IDS.has(segments[segments.length - 1])) segments.pop();
197
+ return segments.join("/");
198
+ }
199
+
200
+ /**
201
+ * The construct a resource ranks under: the shallowest ancestor inside the
202
+ * stack that is an L2, an L3 or a nested stack. With no such ancestor (a bare
203
+ * L1, or no tree metadata at all) it is the construct that owns the resource.
204
+ */
205
+ function resolveUnit(
206
+ resourcePath: string,
207
+ stackPath: string,
208
+ tree: Map<string, CdkTreeNode>,
209
+ ): { path: string; level: ConstructLevel } {
210
+ const inStack = resourcePath.startsWith(`${stackPath}/`) ? resourcePath.slice(stackPath.length + 1) : undefined;
211
+ if (inStack) {
212
+ const segments = inStack.split("/");
213
+ for (let i = 1; i <= segments.length; i++) {
214
+ const candidate = `${stackPath}/${segments.slice(0, i).join("/")}`;
215
+ const level = constructLevel(tree.get(candidate)?.constructInfo?.fqn);
216
+ if (level === "l2" || level === "l3" || level === "nested") return { path: candidate, level };
217
+ }
218
+ }
219
+ const owner = ownerOf(resourcePath);
220
+ return { path: owner, level: constructLevel(tree.get(owner)?.constructInfo?.fqn) };
221
+ }
222
+
223
+ /** Every resource in a stack that describes infrastructure rather than synthesis. */
224
+ function realResources(stack: CdkStack): ResourceEntry[] {
225
+ const entries: ResourceEntry[] = [];
226
+ for (const [logicalId, resource] of Object.entries(stack.template.Resources ?? {})) {
227
+ const type = typeof resource?.Type === "string" ? resource.Type : "";
228
+ if (!type || SCAFFOLDING_TYPES.has(type)) continue;
229
+ entries.push({
230
+ logicalId,
231
+ type,
232
+ stack: stack.path,
233
+ path: declaredPath(resource) ?? `${stack.path}/${logicalId}`,
234
+ resource: resource ?? {},
235
+ });
236
+ }
237
+ return entries;
238
+ }
239
+
240
+ /**
241
+ * The unit's primary CloudFormation type — the one a carve would target. A
242
+ * construct's own resource sits at `<construct>/Resource` or `<construct>/Default`
243
+ * (or is the construct, for an L1); anything else is a supporting resource that
244
+ * carves along with it. With no obvious principal, the shallowest member wins,
245
+ * tie-broken by type so the answer does not depend on template key order.
246
+ */
247
+ function primaryMember(unit: Unit): ResourceEntry | undefined {
248
+ const principal = unit.members.find(
249
+ (m) => m.path === unit.path || IMPLICIT_LEAF_IDS.has(m.path.slice(unit.path.length + 1)),
250
+ );
251
+ if (principal) return principal;
252
+ return [...unit.members].sort(
253
+ (a, b) => a.path.split("/").length - b.path.split("/").length || (a.type < b.type ? -1 : a.type > b.type ? 1 : 0),
254
+ )[0];
255
+ }
256
+
257
+ /** The assembly's dependency graph, plus the signals only a CDK reader can see. */
258
+ export interface CdkCarveGraph {
259
+ graph: TfGraph;
260
+ /** Per-node scoring signals, keyed by construct path. */
261
+ signals: Map<string, ScoreSignals>;
262
+ diagnostics: string[];
263
+ }
264
+
265
+ /**
266
+ * Placeholder values `cdk synth` writes when a context lookup could not be
267
+ * answered. A template holding one describes an account that does not exist.
268
+ */
269
+ const DUMMY_MARKERS = ["dummy-value-for-", "vpc-12345678", "dummy1a", "ami-1234", "s-12345"];
270
+
271
+ /**
272
+ * Is the assembly a faithful picture of an account, or a guess? Either the
273
+ * manifest admits an unresolved context query, or a template still carries the
274
+ * placeholder that query would have replaced.
275
+ */
276
+ export function dummyAssemblyReason(assembly: CloudAssembly): string | undefined {
277
+ const missing = assembly.manifest.missing ?? [];
278
+ if (missing.length > 0) {
279
+ const providers = [...new Set(missing.map((m) => m.provider).filter((p): p is string => !!p))].sort();
280
+ return (
281
+ `The assembly was synthesized with ${missing.length} unresolved context lookup(s)` +
282
+ `${providers.length ? ` (${providers.join(", ")})` : ""}, so its templates hold placeholder values rather ` +
283
+ "than this account's. Prime the context and re-synthesize before advising."
284
+ );
285
+ }
286
+ for (const stack of assembly.stacks) {
287
+ const text = JSON.stringify(stack.template);
288
+ const hit = DUMMY_MARKERS.find((marker) => text.includes(marker));
289
+ if (hit) {
290
+ return (
291
+ `${stack.templateFile} still holds the context placeholder \`${hit}\`, so it describes a lookup that ` +
292
+ "never resolved. Prime the context and re-synthesize before advising."
293
+ );
294
+ }
295
+ }
296
+ return undefined;
297
+ }
298
+
299
+ /**
300
+ * Build the graph and the per-construct signals from a read assembly.
301
+ *
302
+ * Every resource folds into a construct, every construct becomes one node, and
303
+ * every intrinsic becomes an edge between two nodes. Nothing is inferred: a
304
+ * reference with no resolvable target is dropped rather than drawn.
305
+ */
306
+ export function buildCdkGraph(assembly: CloudAssembly): CdkCarveGraph {
307
+ const tree = indexTree(assembly.tree);
308
+ const diagnostics = [...assembly.diagnostics];
309
+
310
+ // Resources, grouped into units.
311
+ const units = new Map<string, Unit>();
312
+ /** logical ID → unit address, per stack. */
313
+ const unitOfLogicalId = new Map<string, Map<string, string>>();
314
+ const resourcesOf = new Map<string, ResourceEntry[]>();
315
+
316
+ for (const stack of assembly.stacks) {
317
+ const byLogicalId = new Map<string, string>();
318
+ unitOfLogicalId.set(stack.path, byLogicalId);
319
+ const entries = realResources(stack);
320
+ resourcesOf.set(stack.path, entries);
321
+ for (const entry of entries) {
322
+ const { path, level } = resolveUnit(entry.path, stack.path, tree);
323
+ const unit = units.get(path) ?? { path, kind: level === "l3" ? "module" : "resource", level, members: [] };
324
+ unit.members.push(entry);
325
+ units.set(path, unit);
326
+ byLogicalId.set(entry.logicalId, path);
327
+ }
328
+ }
329
+
330
+ // Cross-stack exports: export name → the refs the exporting stack's output
331
+ // resolves to, so an ImportValue lands on a real construct rather than a name.
332
+ interface ExportEntry {
333
+ stack: string;
334
+ refs: RawRef[];
335
+ }
336
+ const exports = new Map<string, ExportEntry>();
337
+ for (const stack of assembly.stacks) {
338
+ for (const output of Object.values(stack.template.Outputs ?? {})) {
339
+ const name = output?.Export?.Name;
340
+ if (typeof name !== "string") continue;
341
+ const refs: RawRef[] = [];
342
+ refsIn(output.Value, "Value", refs, { conditional: false });
343
+ exports.set(name, { stack: stack.path, refs });
344
+ }
345
+ }
346
+
347
+ // Edges.
348
+ const rawEdges: TfEdge[] = [];
349
+ const consumedExports = new Set<string>();
350
+ const push = (from: string, to: string, attrs: string[], via: string[], crossStack?: boolean): void => {
351
+ if (!from || !to || from === to) return;
352
+ rawEdges.push({ from, to, attrs, via, ...(crossStack ? { crossStack: true } : {}) });
353
+ };
354
+
355
+ /** Resolve a raw ref to the unit(s) it names, following exports across stacks. */
356
+ const targetsOf = (ref: RawRef, stackPath: string): Array<{ address: string; crossStack: boolean }> => {
357
+ if (ref.crossStack) {
358
+ const entry = exports.get(ref.target);
359
+ if (!entry) return [];
360
+ consumedExports.add(ref.target);
361
+ const local = unitOfLogicalId.get(entry.stack);
362
+ return entry.refs
363
+ .map((inner) => local?.get(inner.target))
364
+ .filter((address): address is string => !!address)
365
+ .map((address) => ({ address, crossStack: true }));
366
+ }
367
+ const address = unitOfLogicalId.get(stackPath)?.get(ref.target);
368
+ return address ? [{ address, crossStack: false }] : [];
369
+ };
370
+
371
+ const dynamicUnits = new Set<string>();
372
+ /** unit address → the property the asset backs, `""` when CDK did not name one. */
373
+ const assetUnits = new Map<string, string>();
374
+
375
+ for (const stack of assembly.stacks) {
376
+ const parameters = new Set(Object.keys(stack.template.Parameters ?? {}));
377
+ for (const entry of resourcesOf.get(stack.path) ?? []) {
378
+ const from = unitOfLogicalId.get(stack.path)?.get(entry.logicalId);
379
+ if (!from) continue;
380
+ const refs: RawRef[] = [];
381
+ const flags = { conditional: !!entry.resource.Condition };
382
+ refsIn(entry.resource.Properties, undefined, refs, flags);
383
+
384
+ const asset = assetProperty(entry.resource);
385
+ if (asset !== undefined && !assetUnits.get(from)) assetUnits.set(from, asset);
386
+
387
+ for (const ref of refs) {
388
+ if (!ref.crossStack && parameters.has(ref.target)) {
389
+ // A template parameter, not a resource. The synthesizer's own
390
+ // bootstrap/asset parameters say nothing about the author's design.
391
+ if (isScaffoldingParameter(ref.target)) {
392
+ if (ref.target.startsWith("AssetParameters") && !assetUnits.has(from)) assetUnits.set(from, "");
393
+ } else {
394
+ flags.conditional = true;
395
+ }
396
+ continue;
397
+ }
398
+ for (const target of targetsOf(ref, stack.path)) {
399
+ push(from, target.address, [ref.attr], ref.via ? [ref.via] : [], target.crossStack);
400
+ }
401
+ }
402
+
403
+ // `DependsOn` is an ordering dependency, and just as real a cut.
404
+ const dependsOn = entry.resource.DependsOn;
405
+ for (const id of Array.isArray(dependsOn) ? dependsOn : dependsOn ? [dependsOn] : []) {
406
+ if (typeof id !== "string") continue;
407
+ const address = unitOfLogicalId.get(stack.path)?.get(id);
408
+ if (address) push(from, address, ["DependsOn"], ["DependsOn"]);
409
+ }
410
+
411
+ if (flags.conditional) dynamicUnits.add(from);
412
+ }
413
+ }
414
+
415
+ // `Outputs` blocks reference without being nodes — the CloudFormation twin of
416
+ // a Terraform `output` (#1638). An output whose export something imports is
417
+ // skipped: the direct cross-stack edge already stands for that cut, and
418
+ // counting both would charge one dependency twice.
419
+ const outputEdges: TfEdge[] = [];
420
+ for (const stack of assembly.stacks) {
421
+ for (const [outputName, output] of Object.entries(stack.template.Outputs ?? {})) {
422
+ const exportName = output?.Export?.Name;
423
+ if (typeof exportName === "string" && consumedExports.has(exportName)) continue;
424
+ const refs: RawRef[] = [];
425
+ refsIn(output?.Value, "Value", refs, { conditional: false });
426
+ for (const ref of refs) {
427
+ for (const target of targetsOf(ref, stack.path)) {
428
+ outputEdges.push({
429
+ from: `output.${stack.path}.${outputName}`,
430
+ to: target.address,
431
+ attrs: [ref.attr],
432
+ via: ["Value"],
433
+ fromKind: "output",
434
+ });
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ // Nodes.
441
+ const nodes: TfNode[] = [];
442
+ const signals = new Map<string, ScoreSignals>();
443
+ const dummyReason = dummyAssemblyReason(assembly);
444
+
445
+ for (const unit of units.values()) {
446
+ const primary = primaryMember(unit);
447
+ const type = unit.kind === "module" ? undefined : primary?.type;
448
+ const nested = unit.level === "nested" || primary?.type === NESTED_STACK_TYPE;
449
+ const assetProp = assetUnits.get(unit.path);
450
+ const stacks = [...new Set(unit.members.map((m) => m.stack))].sort();
451
+
452
+ nodes.push({
453
+ address: unit.path,
454
+ kind: unit.kind,
455
+ ...(type ? { type } : {}),
456
+ name: unit.path.split("/").pop() ?? unit.path,
457
+ instances: 1,
458
+ hasDynamic: dynamicUnits.has(unit.path),
459
+ });
460
+
461
+ const members: CarveUnitMember[] = [...unit.members]
462
+ .sort((a, b) => (a.logicalId < b.logicalId ? -1 : a.logicalId > b.logicalId ? 1 : 0))
463
+ .map((m) => ({ id: m.logicalId, type: m.type, stack: m.stack, path: m.path }));
464
+
465
+ const notes: string[] = [];
466
+ if (unit.kind === "module") {
467
+ notes.push(
468
+ "L3 construct subtree — a Composite candidate (#1000). Its leaves rank here as one unit rather than separately.",
469
+ );
470
+ }
471
+ if (assetProp !== undefined) {
472
+ notes.push(
473
+ `Asset-backed${assetProp ? ` (${assetProp})` : ""} — carving it moves the CDK bundling too, not just the resource.`,
474
+ );
475
+ }
476
+ if (unit.members.length > 1) {
477
+ notes.push(`${unit.members.length} CloudFormation resources fold into this construct.`);
478
+ }
479
+ if (stacks.length > 1) notes.push(`Spans ${stacks.length} stacks: ${stacks.join(", ")}.`);
480
+ if (type && !nested && resolveCfnTier(type) === null) {
481
+ notes.push(`No chant AWS lexicon target for ${type}.`);
482
+ }
483
+
484
+ const disqualified = dummyReason
485
+ ? dummyReason
486
+ : nested
487
+ ? "Nested stack — its resources live in a template this advisor does not descend into, so carving it means " +
488
+ "carving that whole template. Advise the nested assembly on its own."
489
+ : undefined;
490
+
491
+ signals.set(unit.path, {
492
+ ...(assetProp !== undefined && !disqualified ? { penalties: { asset: -10 } } : {}),
493
+ ...(notes.length ? { notes } : {}),
494
+ members,
495
+ ...(disqualified ? { disqualified } : {}),
496
+ });
497
+ }
498
+
499
+ if (dummyReason) diagnostics.push(dummyReason);
500
+
501
+ // Merge duplicate edges (same pair), then order by code point so the report
502
+ // is stable across platforms and template key order.
503
+ const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
504
+ const merged = new Map<string, TfEdge>();
505
+ for (const edge of [...rawEdges, ...outputEdges]) {
506
+ const key = `${edge.from}|${edge.to}|${edge.fromKind ?? ""}`;
507
+ const prev = merged.get(key);
508
+ if (prev) {
509
+ prev.attrs = [...new Set([...prev.attrs, ...edge.attrs])].sort(cmp);
510
+ prev.via = [...new Set([...prev.via, ...edge.via])].sort(cmp);
511
+ if (edge.crossStack) prev.crossStack = true;
512
+ } else {
513
+ merged.set(key, { ...edge, attrs: [...new Set(edge.attrs)].sort(cmp), via: [...new Set(edge.via)].sort(cmp) });
514
+ }
515
+ }
516
+
517
+ return {
518
+ graph: {
519
+ nodes: nodes.sort((a, b) => cmp(a.address, b.address)),
520
+ edges: [...merged.values()].sort((a, b) => cmp(a.from, b.from) || cmp(a.to, b.to)),
521
+ },
522
+ signals,
523
+ diagnostics,
524
+ };
525
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CloudFormation-type → native-spec tier for the CDK carve advisor (#1056).
3
+ *
4
+ * The Terraform path's hardest layer — translating `aws_s3_bucket` into
5
+ * `AWS::S3::Bucket` and a tier — does not exist here: a synthesized template is
6
+ * already in CloudFormation type space, which is the space
7
+ * `AWS_CARVE_TYPES.nativeType` is written in. So this is the same table read
8
+ * from the other end, not a second table to keep in step. Adding an AWS type to
9
+ * the carve-out table lights it up for both advisors at once.
10
+ *
11
+ * `mapsTo` is the CloudFormation type itself, because that identity is the
12
+ * point: chant's AWS lexicon constructors take CloudFormation PascalCase props,
13
+ * so a CDK resource needs no translation to become one.
14
+ */
15
+
16
+ import { AWS_CARVE_TYPES } from "../terraform/aws-resources";
17
+ import type { TierInfo } from "../terraform/tier-map";
18
+
19
+ /**
20
+ * CloudFormation type → tier. Two Terraform types can name one CloudFormation
21
+ * type (`aws_lb` and `aws_alb` are the same load balancer); the first entry
22
+ * wins, and since aliases share a tier by construction there is nothing to
23
+ * reconcile.
24
+ */
25
+ export const CFN_TIER_MAP: Record<string, TierInfo> = (() => {
26
+ const map: Record<string, TierInfo> = {};
27
+ for (const entry of AWS_CARVE_TYPES) {
28
+ if (map[entry.nativeType]) continue;
29
+ map[entry.nativeType] = { tier: entry.tier, mapsTo: entry.nativeType };
30
+ }
31
+ return map;
32
+ })();
33
+
34
+ /** The chant AWS lexicon constructor a CloudFormation type would carve into. */
35
+ export const CFN_CTOR: Record<string, string> = (() => {
36
+ const map: Record<string, string> = {};
37
+ for (const entry of AWS_CARVE_TYPES) map[entry.nativeType] ??= entry.ctor;
38
+ return map;
39
+ })();
40
+
41
+ export function resolveCfnTier(cfnType: string): TierInfo | null {
42
+ return CFN_TIER_MAP[cfnType] ?? null;
43
+ }
44
+
45
+ /**
46
+ * A nested stack is an assembly of its own: its resources live in a separate
47
+ * template that this advisor does not descend into, so carving the
48
+ * `AWS::CloudFormation::Stack` resource means carving everything under it.
49
+ */
50
+ export const NESTED_STACK_TYPE = "AWS::CloudFormation::Stack";
51
+
52
+ /**
53
+ * CloudFormation resources CDK synthesizes for its own bookkeeping. They
54
+ * describe the synthesis, not the infrastructure, so they never appear in the
55
+ * ranking.
56
+ */
57
+ export const SCAFFOLDING_TYPES = new Set(["AWS::CDK::Metadata"]);
58
+
59
+ /**
60
+ * Template parameters CDK adds for bootstrap and asset plumbing. A reference to
61
+ * one is not a sign the resource is parameterized by its author — it is the
62
+ * synthesizer talking to itself — so these do not mark a construct dynamic.
63
+ */
64
+ export function isScaffoldingParameter(name: string): boolean {
65
+ return (
66
+ name === "BootstrapVersion" ||
67
+ name === "CdkBootstrapVersion" ||
68
+ name.startsWith("AssetParameters") ||
69
+ name.startsWith("BootstrapVersion")
70
+ );
71
+ }