@intentius/chant 0.52.2 → 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 (103) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/identity.d.ts +196 -0
  23. package/dist/identity.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/lexicon.d.ts +41 -0
  27. package/dist/lexicon.d.ts.map +1 -1
  28. package/dist/terraform/adopt-state.d.ts +17 -66
  29. package/dist/terraform/adopt-state.d.ts.map +1 -1
  30. package/dist/terraform/aws-resources.d.ts +1 -1
  31. package/dist/terraform/carve-provider.d.ts +142 -0
  32. package/dist/terraform/carve-provider.d.ts.map +1 -0
  33. package/dist/terraform/carve.d.ts +36 -3
  34. package/dist/terraform/carve.d.ts.map +1 -1
  35. package/dist/terraform/emit-source.d.ts +25 -0
  36. package/dist/terraform/emit-source.d.ts.map +1 -0
  37. package/dist/terraform/graduate.d.ts +11 -1
  38. package/dist/terraform/graduate.d.ts.map +1 -1
  39. package/dist/terraform/providers/aws.d.ts +19 -0
  40. package/dist/terraform/providers/aws.d.ts.map +1 -0
  41. package/dist/terraform/providers/gcp.d.ts +41 -0
  42. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  43. package/dist/terraform/providers/index.d.ts +15 -0
  44. package/dist/terraform/providers/index.d.ts.map +1 -0
  45. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  46. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  47. package/dist/terraform/score.d.ts +70 -4
  48. package/dist/terraform/score.d.ts.map +1 -1
  49. package/dist/terraform/tier-map.d.ts +30 -35
  50. package/dist/terraform/tier-map.d.ts.map +1 -1
  51. package/dist/terraform/types.d.ts +6 -0
  52. package/dist/terraform/types.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  55. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  56. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  57. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  58. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  59. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  60. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  61. package/src/cdk/advise.test.ts +208 -0
  62. package/src/cdk/advise.ts +44 -0
  63. package/src/cdk/assembly.ts +133 -0
  64. package/src/cdk/graph.test.ts +206 -0
  65. package/src/cdk/graph.ts +525 -0
  66. package/src/cdk/tier-map.ts +71 -0
  67. package/src/cdk/types.ts +115 -0
  68. package/src/cli/commands/carve-apply.ts +20 -4
  69. package/src/cli/commands/carve-bridge.ts +9 -5
  70. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  71. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  72. package/src/cli/commands/carve-emit.ts +28 -24
  73. package/src/cli/commands/carve.ts +139 -36
  74. package/src/cli/handlers/carve-emit.ts +8 -3
  75. package/src/cli/handlers/carve.ts +8 -7
  76. package/src/cli/handlers/lifecycle.test.ts +187 -1
  77. package/src/cli/handlers/lifecycle.ts +125 -1
  78. package/src/cli/main.ts +12 -5
  79. package/src/identity.test.ts +199 -0
  80. package/src/identity.ts +346 -0
  81. package/src/index.ts +1 -0
  82. package/src/lexicon.ts +53 -0
  83. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  84. package/src/terraform/adopt-state.test.ts +131 -0
  85. package/src/terraform/adopt-state.ts +19 -167
  86. package/src/terraform/aws-resources.test.ts +38 -22
  87. package/src/terraform/aws-resources.ts +1 -1
  88. package/src/terraform/carve-provider.test.ts +155 -0
  89. package/src/terraform/carve-provider.ts +237 -0
  90. package/src/terraform/carve.test.ts +55 -1
  91. package/src/terraform/carve.ts +0 -0
  92. package/src/terraform/emit-source.ts +39 -0
  93. package/src/terraform/graduate.test.ts +37 -0
  94. package/src/terraform/graduate.ts +55 -7
  95. package/src/terraform/graph.ts +3 -3
  96. package/src/terraform/providers/aws.ts +169 -0
  97. package/src/terraform/providers/gcp.test.ts +228 -0
  98. package/src/terraform/providers/gcp.ts +329 -0
  99. package/src/terraform/providers/index.ts +21 -0
  100. package/src/terraform/providers/kubernetes.ts +224 -0
  101. package/src/terraform/score.ts +111 -25
  102. package/src/terraform/tier-map.ts +45 -107
  103. package/src/terraform/types.ts +6 -0
@@ -0,0 +1,208 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveAdvise, carveJson, formatCarveReport, CARVE_REPORT_VERSION } from "../cli/commands/carve";
6
+ import { carveEmit } from "../cli/commands/carve-emit";
7
+ import { carveBridge } from "../cli/commands/carve-bridge";
8
+ import { carveApply } from "../cli/commands/carve-apply";
9
+
10
+ const FIXTURES = join(__dirname, "__fixtures__");
11
+ const ASSEMBLY = join(FIXTURES, "cdk.out");
12
+ const DUMMY = join(FIXTURES, "cdk.out-dummy");
13
+
14
+ /**
15
+ * `chant carve advise` over a committed CDK cloud assembly (#1056). No CDK CLI,
16
+ * no network, no wasm parser — the assembly is JSON and it is in the repo.
17
+ */
18
+ describe("carve advise --from <cdk.out>", () => {
19
+ test("routes on the directory's contents, not a second flag", async () => {
20
+ const r = await carveAdvise({ from: ASSEMBLY });
21
+ expect(r.ok).toBe(true);
22
+ expect(r.source).toBe("cdk");
23
+ });
24
+
25
+ test("ranks constructs into the same bands the Terraform path uses", async () => {
26
+ const r = await carveAdvise({ from: ASSEMBLY });
27
+ const byAddress = new Map((r.results ?? []).map((x) => [x.address, x]));
28
+
29
+ // 100 - 12 inbound (the cross-stack import) on a tier-1 bucket.
30
+ expect(byAddress.get("DataStack/Assets")).toMatchObject({ score: 88, band: "clean leaf", mapsTo: "AWS::S3::Bucket" });
31
+ // 100 - 4x2 outbound - 15 tier2 - 10 asset.
32
+ expect(byAddress.get("AppStack/Handler")).toMatchObject({ score: 67, band: "carvable w/ edits" });
33
+ // A module (an L3 subtree) takes the same tier-2 penalty a Terraform module does.
34
+ expect(byAddress.get("AppStack/Api")).toMatchObject({ score: 69, kind: "module" });
35
+ expect(byAddress.get("AppStack/Api")?.mapsTo).toBeUndefined();
36
+ });
37
+
38
+ test("a construct ranks once, with its CloudFormation resources folded in", async () => {
39
+ const r = await carveAdvise({ from: ASSEMBLY });
40
+ const addresses = (r.results ?? []).map((x) => x.address);
41
+ expect(addresses).toContain("AppStack/Handler");
42
+ expect(addresses.filter((a) => a.startsWith("AppStack/Handler"))).toHaveLength(1);
43
+
44
+ const handler = r.results?.find((x) => x.address === "AppStack/Handler");
45
+ expect(handler?.members?.map((m) => m.id).sort()).toEqual([
46
+ "Handler886CB40B",
47
+ "HandlerServiceRoleDefaultPolicy4C43A1F9",
48
+ "HandlerServiceRoleFCDC14AE",
49
+ ]);
50
+ });
51
+
52
+ test("scaffolding does not appear in the report", async () => {
53
+ const r = await carveAdvise({ from: ASSEMBLY });
54
+ const text = JSON.stringify(carveJson(r));
55
+ expect(text).not.toContain("CDKMetadata");
56
+ expect(text).not.toContain("AWS::CDK::Metadata");
57
+ expect(text).not.toContain("BootstrapVersion");
58
+ });
59
+
60
+ test("a cross-stack Fn::ImportValue is a boundary edge from both ends", async () => {
61
+ const payload = carveJson(await carveAdvise({ from: ASSEMBLY }));
62
+ const producer = payload.resources.find((x) => x.address === "DataStack/Assets");
63
+ const consumer = payload.resources.find((x) => x.address === "AppStack/Handler");
64
+
65
+ expect(producer?.boundary?.inbound).toContainEqual({
66
+ direction: "inbound",
67
+ survivor: "AppStack/Handler",
68
+ carved: "DataStack/Assets",
69
+ attrs: ["Export"],
70
+ via: ["PolicyDocument"],
71
+ bridge: "cdk-import",
72
+ required: "immediately",
73
+ crossStack: true,
74
+ });
75
+ expect(consumer?.boundary?.outbound).toContainEqual({
76
+ direction: "outbound",
77
+ survivor: "DataStack/Assets",
78
+ carved: "AppStack/Handler",
79
+ attrs: ["Export"],
80
+ via: ["PolicyDocument"],
81
+ bridge: "deferred-input",
82
+ required: "at-apply",
83
+ crossStack: true,
84
+ });
85
+ });
86
+
87
+ test("a stack Output reading a construct bridges as an output rewrite", async () => {
88
+ const payload = carveJson(await carveAdvise({ from: ASSEMBLY }));
89
+ const table = payload.resources.find((x) => x.address === "DataStack/Table");
90
+ expect(table?.boundary?.inbound).toContainEqual({
91
+ direction: "inbound",
92
+ survivor: "output.DataStack.TableName",
93
+ carved: "DataStack/Table",
94
+ attrs: ["Ref"],
95
+ via: ["Value"],
96
+ bridge: "cfn-output-rewrite",
97
+ required: "immediately",
98
+ });
99
+ expect(table?.breakdown).toMatchObject({ inbound: 0, outputs: 1 });
100
+ });
101
+
102
+ test("a dummy-value assembly scores 0 with the reason, not a plausible score", async () => {
103
+ const r = await carveAdvise({ from: DUMMY });
104
+ expect(r.ok).toBe(true);
105
+ expect(r.results?.length).toBeGreaterThan(0);
106
+ for (const scored of r.results ?? []) {
107
+ expect(scored.score).toBe(0);
108
+ expect(scored.notes?.join(" ")).toContain("unresolved context lookup");
109
+ }
110
+ const payload = carveJson(r);
111
+ expect(payload.diagnostics?.join(" ")).toContain("unresolved context lookup");
112
+ expect(formatCarveReport(r)).toContain("unresolved context lookup");
113
+ });
114
+
115
+ test("the report advises and never suggests a mutation", async () => {
116
+ const r = await carveAdvise({ from: ASSEMBLY });
117
+ const text = formatCarveReport(r);
118
+ expect(text).toContain("CDK cloud-assembly carve-out advisory");
119
+ expect(text).toContain("Advises only");
120
+ expect(text).toContain("LEAVE IN CDK");
121
+ expect(text).not.toMatch(/cdk deploy|cdk destroy|terraform state rm/i);
122
+ });
123
+
124
+ test("--report writes the same payload to a file", async () => {
125
+ const dir = mkdtempSync(join(tmpdir(), "chant-cdk-carve-"));
126
+ try {
127
+ const reportFile = join(dir, "report.json");
128
+ const r = await carveAdvise({ from: ASSEMBLY, reportFile });
129
+ expect(r.ok).toBe(true);
130
+ const payload = JSON.parse(readFileSync(reportFile, "utf-8"));
131
+ expect(payload).toEqual(carveJson(r));
132
+ expect(payload.count).toBe(r.results?.length);
133
+ } finally {
134
+ rmSync(dir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ test("--state is a Terraform option and is refused, not ignored", async () => {
139
+ const r = await carveAdvise({ from: ASSEMBLY, statePath: join(ASSEMBLY, "manifest.json") });
140
+ expect(r.ok).toBe(false);
141
+ expect(r.error).toContain("--state is a Terraform option");
142
+ });
143
+ });
144
+
145
+ /**
146
+ * The report is a cross-tool contract (behold's carve lens reads it). The CDK
147
+ * source is additive within schema version 1: it introduces no field an
148
+ * existing reader keys on, and changes no field's type or meaning.
149
+ */
150
+ describe("the versioned report contract", () => {
151
+ test("the CDK source does not bump the schema version", async () => {
152
+ const payload = carveJson(await carveAdvise({ from: ASSEMBLY }));
153
+ expect(payload.version).toBe(CARVE_REPORT_VERSION);
154
+ expect(CARVE_REPORT_VERSION).toBe(1);
155
+ });
156
+
157
+ test("every field an existing reader validates keeps its type", async () => {
158
+ const payload = carveJson(await carveAdvise({ from: ASSEMBLY }));
159
+ // behold refuses a report whose entries are not {address: string, score:
160
+ // number, band: string}, and reads `kind` as "resource" | "module".
161
+ expect(Array.isArray(payload.resources)).toBe(true);
162
+ for (const resource of payload.resources) {
163
+ expect(typeof resource.address).toBe("string");
164
+ expect(typeof resource.score).toBe("number");
165
+ expect(typeof resource.band).toBe("string");
166
+ expect(["resource", "module"]).toContain(resource.kind);
167
+ expect(["clean leaf", "carvable w/ edits", "leave in Terraform"]).toContain(resource.band);
168
+ }
169
+ });
170
+
171
+ test("the score arithmetic still adds up from the penalty terms", async () => {
172
+ // behold reproduces a score as `100 + sum(penalties)`, so an added term
173
+ // (the CDK asset penalty) has to be summable rather than special-cased.
174
+ const payload = carveJson(await carveAdvise({ from: ASSEMBLY }));
175
+ for (const resource of payload.resources) {
176
+ if (resource.breakdown.tier === null || resource.score === 0) continue;
177
+ const total = Object.values(resource.breakdown.penalties).reduce((sum, n) => sum + n, 0);
178
+ expect(100 + total).toBe(resource.score);
179
+ }
180
+ });
181
+
182
+ test("the source discriminator is present on both paths", async () => {
183
+ expect(carveJson(await carveAdvise({ from: ASSEMBLY })).source).toBe("cdk");
184
+ // A result with no source at all (an older caller) still reads as Terraform.
185
+ expect(carveJson({ ok: true, from: "x", results: [] }).source).toBe("terraform");
186
+ });
187
+ });
188
+
189
+ describe("the Terraform-only phases", () => {
190
+ test("refuse a cloud assembly by name instead of reporting an empty estate", async () => {
191
+ const emit = await carveEmit(
192
+ { from: ASSEMBLY, select: "AppStack/Handler", statePath: "unused.tfstate" },
193
+ // Refused before anything is read, so neither dependency is reached.
194
+ { plugins: [], liveImport: async () => { throw new Error("no cloud call in an advise-only path"); } },
195
+ );
196
+ expect(emit.ok).toBe(false);
197
+ expect(emit.error).toContain("carve emit");
198
+ expect(emit.error).toContain("carve advise");
199
+
200
+ const bridge = await carveBridge({ from: ASSEMBLY, select: "AppStack/Handler" });
201
+ expect(bridge.ok).toBe(false);
202
+ expect(bridge.error).toContain("carve bridge");
203
+
204
+ const apply = await carveApply({ from: ASSEMBLY, select: "AppStack/Handler", env: "prod" });
205
+ expect(apply.ok).toBe(false);
206
+ expect(apply.error).toContain("carve apply");
207
+ });
208
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The CDK half of `chant carve advise` (#1056): read a cloud assembly, rank its
3
+ * constructs with the same model the Terraform advisor uses.
4
+ *
5
+ * Read-only, like the Terraform path — it emits nothing, patches nothing, and
6
+ * touches no live resource. `carve emit`, `carve bridge` and `carve apply` are
7
+ * deliberately Terraform-only: bridging a CDK carve means rewriting the
8
+ * surviving app's source, which does not generalize across jsii languages.
9
+ */
10
+
11
+ import { readCloudAssembly } from "./assembly";
12
+ import { buildCdkGraph } from "./graph";
13
+ import { resolveCfnTier } from "./tier-map";
14
+ import type { CarveDialect } from "../terraform/carve";
15
+ import type { Peelability, ScoreOptions } from "../terraform/score";
16
+ import { scoreEstate } from "../terraform/score";
17
+ import type { TfGraph } from "../terraform/types";
18
+
19
+ export interface CdkAdvice {
20
+ graph: TfGraph;
21
+ results: Peelability[];
22
+ /** The hooks that produced `results`, so the boundary pass agrees with it. */
23
+ scoreOptions: ScoreOptions;
24
+ diagnostics: string[];
25
+ }
26
+
27
+ /** The bridge vocabulary a CDK report's boundary edges are named in. */
28
+ export const CDK_DIALECT: CarveDialect = "cdk";
29
+
30
+ /**
31
+ * Rank a cloud assembly. The scoring model, the bands and the report shape are
32
+ * the Terraform path's, verbatim; what changes is the tier lookup (already in
33
+ * CloudFormation type space, so no translation) and the signals only a CDK
34
+ * reader can see — assets, nested stacks, L3 subtrees, a dummy-value assembly.
35
+ */
36
+ export function adviseCloudAssembly(dir: string): CdkAdvice {
37
+ const assembly = readCloudAssembly(dir);
38
+ const { graph, signals, diagnostics } = buildCdkGraph(assembly);
39
+ const scoreOptions: ScoreOptions = {
40
+ tierOf: resolveCfnTier,
41
+ signalsFor: (node) => signals.get(node.address) ?? {},
42
+ };
43
+ return { graph, results: scoreEstate(graph, scoreOptions), scoreOptions, diagnostics };
44
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Read a CDK cloud assembly off disk (#1056).
3
+ *
4
+ * The only filesystem layer the CDK advisor needs. Detection is by content, not
5
+ * a flag: a directory holding `manifest.json` plus at least one
6
+ * `*.template.json` is a cloud assembly, so `chant carve advise --from ./cdk.out`
7
+ * routes itself.
8
+ *
9
+ * A malformed or truncated assembly degrades rather than throws — a stack whose
10
+ * template will not parse is dropped with a diagnostic, and the rest is still
11
+ * ranked. The advisor is read-only, so a partial read costs nothing but a
12
+ * shorter report.
13
+ */
14
+
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
16
+ import { join } from "path";
17
+ import type { CdkManifest, CdkStack, CdkTreeFile, CdkTreeNode, CfnTemplate, CloudAssembly } from "./types";
18
+
19
+ /** Every `*.template.json` directly under `dir`. */
20
+ function listTemplates(dir: string): string[] {
21
+ return readdirSync(dir)
22
+ .filter((f) => f.endsWith(".template.json"))
23
+ .sort();
24
+ }
25
+
26
+ /**
27
+ * Is `dir` a CDK cloud assembly? `manifest.json` plus at least one synthesized
28
+ * template. Both are required: `manifest.json` alone is far too common a
29
+ * filename to claim on sight.
30
+ */
31
+ export function isCloudAssembly(dir: string): boolean {
32
+ try {
33
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return false;
34
+ if (!existsSync(join(dir, "manifest.json"))) return false;
35
+ return listTemplates(dir).length > 0;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Why a Terraform-only carve phase refuses a cloud assembly (#1056).
43
+ *
44
+ * Advise is the only phase that reads CDK. Bridging a carve means rewriting the
45
+ * surviving app's source so it reimports what was taken, and CDK source is
46
+ * TypeScript, Python, Java or C# depending on who wrote it — nothing that
47
+ * generalizes the way patching HCL does. Better to say so than to parse the
48
+ * assembly's `.tf` files, find none, and report an empty estate.
49
+ */
50
+ export function cdkNotSupported(dir: string, phase: "emit" | "bridge" | "apply"): string {
51
+ return (
52
+ `${dir} is a CDK cloud assembly, and \`chant carve ${phase}\` reads Terraform. ` +
53
+ `\`chant carve advise --from ${dir}\` ranks it read-only; emit, bridge and apply are Terraform-only.`
54
+ );
55
+ }
56
+
57
+ function readJson<T>(path: string): T | undefined {
58
+ try {
59
+ return JSON.parse(readFileSync(path, "utf-8")) as T;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The stack's construct path. `displayName` carries it when the stack sits in a
67
+ * Stage (`Stage/AppStack` for artifact id `Stage-AppStack`); otherwise the
68
+ * artifact id is the path.
69
+ */
70
+ function stackPath(id: string, displayName?: string): string {
71
+ return typeof displayName === "string" && displayName.length > 0 ? displayName : id;
72
+ }
73
+
74
+ /** Index `tree.json` by construct path, so a resource's path resolves in one lookup. */
75
+ export function indexTree(root: CdkTreeNode | undefined): Map<string, CdkTreeNode> {
76
+ const byPath = new Map<string, CdkTreeNode>();
77
+ const visit = (node: CdkTreeNode): void => {
78
+ if (typeof node.path === "string") byPath.set(node.path, node);
79
+ for (const child of Object.values(node.children ?? {})) visit(child);
80
+ };
81
+ if (root) visit(root);
82
+ return byPath;
83
+ }
84
+
85
+ /**
86
+ * Read the assembly at `dir`. Throws only when the directory is not an assembly
87
+ * at all — everything past that point is a diagnostic.
88
+ */
89
+ export function readCloudAssembly(dir: string): CloudAssembly {
90
+ const diagnostics: string[] = [];
91
+ const manifest = readJson<CdkManifest>(join(dir, "manifest.json"));
92
+ if (!manifest) {
93
+ throw new Error(`${join(dir, "manifest.json")} is not readable JSON — not a CDK cloud assembly.`);
94
+ }
95
+
96
+ const tree = readJson<CdkTreeFile>(join(dir, "tree.json"))?.tree;
97
+ if (!tree) {
98
+ diagnostics.push(
99
+ "No tree.json in the assembly — CloudFormation resources are grouped by their construct path alone, " +
100
+ "so sibling constructs of one L2 (a function's role and policy, say) rank separately instead of folding in.",
101
+ );
102
+ }
103
+
104
+ const stacks: CdkStack[] = [];
105
+ const artifacts = Object.entries(manifest.artifacts ?? {});
106
+ for (const [id, artifact] of artifacts) {
107
+ if (artifact?.type !== "aws:cloudformation:stack") continue;
108
+ const templateFile = artifact.properties?.templateFile;
109
+ if (typeof templateFile !== "string") {
110
+ diagnostics.push(`Stack artifact ${id} names no templateFile — skipped.`);
111
+ continue;
112
+ }
113
+ const template = readJson<CfnTemplate>(join(dir, templateFile));
114
+ if (!template) {
115
+ diagnostics.push(`Could not read ${templateFile} for stack ${id} — skipped.`);
116
+ continue;
117
+ }
118
+ stacks.push({ id, path: stackPath(id, artifact.displayName), templateFile, template });
119
+ }
120
+
121
+ if (stacks.length === 0) {
122
+ // Templates exist (isCloudAssembly said so) but no artifact claims them —
123
+ // an assembly written by something other than `cdk synth`, or a manifest
124
+ // from a version this does not understand. Say so rather than reporting an
125
+ // empty estate as if the app were empty.
126
+ diagnostics.push(
127
+ "The manifest declares no aws:cloudformation:stack artifacts, so no template was ranked. " +
128
+ "Re-synthesize with `cdk synth` and point --from at the resulting cdk.out.",
129
+ );
130
+ }
131
+
132
+ return { dir, manifest, tree, stacks: stacks.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), diagnostics };
133
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { join } from "path";
3
+ import { cdkNotSupported, isCloudAssembly, readCloudAssembly } from "./assembly";
4
+ import { buildCdkGraph, constructLevel, dummyAssemblyReason } from "./graph";
5
+ import { CFN_TIER_MAP, isScaffoldingParameter, resolveCfnTier } from "./tier-map";
6
+ import { AWS_CARVE_TYPES } from "../terraform/aws-resources";
7
+
8
+ const FIXTURES = join(__dirname, "__fixtures__");
9
+ const ASSEMBLY = join(FIXTURES, "cdk.out");
10
+ const DUMMY = join(FIXTURES, "cdk.out-dummy");
11
+
12
+ describe("cloud assembly detection", () => {
13
+ test("a directory with a manifest and a synthesized template is an assembly", () => {
14
+ expect(isCloudAssembly(ASSEMBLY)).toBe(true);
15
+ expect(isCloudAssembly(DUMMY)).toBe(true);
16
+ });
17
+
18
+ test("a directory that is not one is not claimed", () => {
19
+ // manifest.json alone is far too common a filename to claim on sight; a
20
+ // Terraform estate and a missing directory are both plainly not assemblies.
21
+ expect(isCloudAssembly(join(__dirname, "..", "terraform", "__fixtures__", "sample-estate"))).toBe(false);
22
+ expect(isCloudAssembly(join(FIXTURES, "definitely-not-here"))).toBe(false);
23
+ });
24
+
25
+ test("the Terraform-only phases refuse an assembly by name", () => {
26
+ const message = cdkNotSupported("./cdk.out", "emit");
27
+ expect(message).toContain("carve emit");
28
+ expect(message).toContain("carve advise --from ./cdk.out");
29
+ });
30
+ });
31
+
32
+ describe("the CloudFormation tier map", () => {
33
+ test("is the AWS carve-out table read from the other end", () => {
34
+ // Every carvable Terraform type names a CloudFormation type, and that type
35
+ // resolves to the same tier from this side. No second table to keep in step.
36
+ for (const entry of AWS_CARVE_TYPES) {
37
+ expect(resolveCfnTier(entry.nativeType)).toEqual({ tier: entry.tier, mapsTo: entry.nativeType });
38
+ }
39
+ expect(Object.keys(CFN_TIER_MAP).length).toBeGreaterThan(70);
40
+ });
41
+
42
+ test("an unmapped CloudFormation type resolves to nothing", () => {
43
+ expect(resolveCfnTier("AWS::CloudFormation::Stack")).toBeNull();
44
+ expect(resolveCfnTier("AWS::S3::BucketPolicy")).toBeNull();
45
+ });
46
+
47
+ test("synthesizer parameters are told apart from an author's", () => {
48
+ expect(isScaffoldingParameter("BootstrapVersion")).toBe(true);
49
+ expect(isScaffoldingParameter("AssetParameters1a2b3c4dS3BucketA1B2C3")).toBe(true);
50
+ expect(isScaffoldingParameter("EnvName")).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe("construct levels", () => {
55
+ test("classify by jsii class", () => {
56
+ expect(constructLevel("aws-cdk-lib.aws_s3.CfnBucket")).toBe("l1");
57
+ expect(constructLevel("aws-cdk-lib.aws_s3.Bucket")).toBe("l2");
58
+ expect(constructLevel("@aws-cdk/aws-s3.Bucket")).toBe("l2");
59
+ expect(constructLevel("constructs.Construct")).toBe("l3");
60
+ expect(constructLevel("my-app.ApiConstruct")).toBe("l3");
61
+ expect(constructLevel("aws-cdk-lib.NestedStack")).toBe("nested");
62
+ expect(constructLevel("aws-cdk-lib.Stack")).toBe("container");
63
+ expect(constructLevel(undefined)).toBe("unknown");
64
+ });
65
+ });
66
+
67
+ describe("buildCdkGraph", () => {
68
+ const { graph, signals, diagnostics } = buildCdkGraph(readCloudAssembly(ASSEMBLY));
69
+ const addresses = graph.nodes.map((n) => n.address);
70
+ const node = (address: string) => graph.nodes.find((n) => n.address === address);
71
+
72
+ test("ranks constructs, not CloudFormation resources", () => {
73
+ expect(addresses).toEqual([
74
+ "AppStack/Api",
75
+ "AppStack/Handler",
76
+ "AppStack/Legacy",
77
+ "AppStack/Reports.NestedStack",
78
+ "DataStack/Assets",
79
+ "DataStack/Table",
80
+ ]);
81
+ });
82
+
83
+ test("one L2 emitting three resources is one node, with its members named", () => {
84
+ expect(node("AppStack/Handler")).toMatchObject({ kind: "resource", type: "AWS::Lambda::Function" });
85
+ expect(signals.get("AppStack/Handler")?.members?.map((m) => m.type)).toEqual([
86
+ "AWS::Lambda::Function",
87
+ "AWS::IAM::Policy",
88
+ "AWS::IAM::Role",
89
+ ]);
90
+ // The role and the policy are not separately rankable — they carve with it.
91
+ expect(addresses).not.toContain("AppStack/Handler/ServiceRole");
92
+ });
93
+
94
+ test("an L3 subtree is one Composite candidate, ranked as a module", () => {
95
+ expect(node("AppStack/Api")).toMatchObject({ kind: "module" });
96
+ expect(node("AppStack/Api")?.type).toBeUndefined();
97
+ expect(signals.get("AppStack/Api")?.members).toHaveLength(3);
98
+ expect(signals.get("AppStack/Api")?.notes?.join(" ")).toContain("Composite candidate");
99
+ expect(addresses).not.toContain("AppStack/Api/Queue");
100
+ });
101
+
102
+ test("a bare L1 stands on its own", () => {
103
+ expect(node("AppStack/Legacy")).toMatchObject({ kind: "resource", type: "AWS::S3::Bucket" });
104
+ });
105
+
106
+ test("synthesis scaffolding never appears", () => {
107
+ expect(addresses.some((a) => a.includes("CDKMetadata"))).toBe(false);
108
+ for (const members of [...signals.values()].map((s) => s.members ?? [])) {
109
+ expect(members.some((m) => m.type === "AWS::CDK::Metadata")).toBe(false);
110
+ }
111
+ });
112
+
113
+ test("references inside one construct are not edges", () => {
114
+ // The bucket policy Refs its bucket and the function Refs its own role;
115
+ // both are internal to a carve set, so neither is boundary work.
116
+ const internal = graph.edges.filter((e) => e.from === e.to);
117
+ expect(internal).toHaveLength(0);
118
+ expect(graph.edges.filter((e) => e.from === "DataStack/Assets")).toHaveLength(0);
119
+ });
120
+
121
+ test("an intra-stack Ref is an edge carrying the property it sits in", () => {
122
+ expect(graph.edges).toContainEqual({
123
+ from: "AppStack/Handler",
124
+ to: "AppStack/Api",
125
+ attrs: ["Ref"],
126
+ via: ["Environment"],
127
+ });
128
+ });
129
+
130
+ test("Fn::ImportValue resolves through the exporting stack's Outputs", () => {
131
+ // Not a dangling export name: the edge lands on the construct that
132
+ // actually produces the value, in the other stack.
133
+ expect(graph.edges).toContainEqual({
134
+ from: "AppStack/Handler",
135
+ to: "DataStack/Assets",
136
+ attrs: ["Export"],
137
+ via: ["PolicyDocument"],
138
+ crossStack: true,
139
+ });
140
+ });
141
+
142
+ test("an output whose export is imported is not counted a second time", () => {
143
+ // DataStack exports the bucket ARN and AppStack imports it. That is one
144
+ // dependency, already drawn between the two constructs, so the output
145
+ // block does not also charge the bucket.
146
+ const outputs = graph.edges.filter((e) => e.fromKind === "output");
147
+ expect(outputs.map((e) => e.from)).toEqual(["output.AppStack.QueueUrl", "output.DataStack.TableName"]);
148
+ });
149
+
150
+ test("a stack output nobody imports is an output edge on what it reads", () => {
151
+ expect(graph.edges).toContainEqual({
152
+ from: "output.DataStack.TableName",
153
+ to: "DataStack/Table",
154
+ attrs: ["Ref"],
155
+ via: ["Value"],
156
+ fromKind: "output",
157
+ });
158
+ });
159
+
160
+ test("a CloudFormation Condition or an author's parameter marks a construct dynamic", () => {
161
+ expect(node("AppStack/Legacy")?.hasDynamic).toBe(true);
162
+ expect(node("DataStack/Assets")?.hasDynamic).toBe(false);
163
+ });
164
+
165
+ test("an asset-backed construct carries a penalty and says why", () => {
166
+ expect(signals.get("AppStack/Handler")?.penalties).toEqual({ asset: -10 });
167
+ expect(signals.get("AppStack/Handler")?.notes?.join(" ")).toContain("Asset-backed (Code)");
168
+ });
169
+
170
+ test("a nested stack is disqualified rather than scored", () => {
171
+ expect(signals.get("AppStack/Reports.NestedStack")?.disqualified).toContain("Nested stack");
172
+ });
173
+
174
+ test("a complete assembly has nothing to report about the read", () => {
175
+ expect(diagnostics).toEqual([]);
176
+ });
177
+ });
178
+
179
+ describe("a dummy-value assembly", () => {
180
+ const assembly = readCloudAssembly(DUMMY);
181
+
182
+ test("is recognized from the manifest's unresolved lookups", () => {
183
+ const reason = dummyAssemblyReason(assembly);
184
+ expect(reason).toContain("unresolved context lookup");
185
+ expect(reason).toContain("vpc-provider");
186
+ });
187
+
188
+ test("is recognized from a placeholder left in a template", () => {
189
+ // The same verdict with the manifest's admission removed: the template
190
+ // still holds the value the lookup would have replaced.
191
+ const withoutMissing = { ...assembly, manifest: { ...assembly.manifest, missing: [] } };
192
+ expect(dummyAssemblyReason(withoutMissing)).toContain("vpc-12345678");
193
+ });
194
+
195
+ test("disqualifies every construct in it", () => {
196
+ const { signals } = buildCdkGraph(assembly);
197
+ expect(signals.size).toBeGreaterThan(0);
198
+ for (const signal of signals.values()) expect(signal.disqualified).toContain("unresolved context lookup");
199
+ });
200
+
201
+ test("without tree.json, grouping falls back to the construct path and says so", () => {
202
+ const { graph, diagnostics } = buildCdkGraph(assembly);
203
+ expect(graph.nodes.map((n) => n.address)).toEqual(["LookupStack/AppBucket", "LookupStack/AppSg"]);
204
+ expect(diagnostics.join(" ")).toContain("No tree.json");
205
+ });
206
+ });