@intentius/chant 0.52.2 → 0.53.1

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 (125) 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/handlers/op-progress.d.ts +37 -4
  22. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +61 -1
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/run.d.ts.map +1 -1
  26. package/dist/cli/main.d.ts.map +1 -1
  27. package/dist/cli/registry.d.ts +9 -1
  28. package/dist/cli/registry.d.ts.map +1 -1
  29. package/dist/identity.d.ts +196 -0
  30. package/dist/identity.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/lexicon.d.ts +41 -0
  34. package/dist/lexicon.d.ts.map +1 -1
  35. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  36. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  37. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  38. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  39. package/dist/terraform/adopt-state.d.ts +17 -66
  40. package/dist/terraform/adopt-state.d.ts.map +1 -1
  41. package/dist/terraform/aws-resources.d.ts +1 -1
  42. package/dist/terraform/carve-provider.d.ts +142 -0
  43. package/dist/terraform/carve-provider.d.ts.map +1 -0
  44. package/dist/terraform/carve.d.ts +36 -3
  45. package/dist/terraform/carve.d.ts.map +1 -1
  46. package/dist/terraform/emit-source.d.ts +25 -0
  47. package/dist/terraform/emit-source.d.ts.map +1 -0
  48. package/dist/terraform/graduate.d.ts +11 -1
  49. package/dist/terraform/graduate.d.ts.map +1 -1
  50. package/dist/terraform/providers/aws.d.ts +19 -0
  51. package/dist/terraform/providers/aws.d.ts.map +1 -0
  52. package/dist/terraform/providers/gcp.d.ts +41 -0
  53. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  54. package/dist/terraform/providers/index.d.ts +15 -0
  55. package/dist/terraform/providers/index.d.ts.map +1 -0
  56. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  57. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  58. package/dist/terraform/score.d.ts +70 -4
  59. package/dist/terraform/score.d.ts.map +1 -1
  60. package/dist/terraform/tier-map.d.ts +30 -35
  61. package/dist/terraform/tier-map.d.ts.map +1 -1
  62. package/dist/terraform/types.d.ts +6 -0
  63. package/dist/terraform/types.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  66. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  67. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  68. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  69. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  70. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  71. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  72. package/src/cdk/advise.test.ts +208 -0
  73. package/src/cdk/advise.ts +44 -0
  74. package/src/cdk/assembly.ts +133 -0
  75. package/src/cdk/graph.test.ts +206 -0
  76. package/src/cdk/graph.ts +525 -0
  77. package/src/cdk/tier-map.ts +71 -0
  78. package/src/cdk/types.ts +115 -0
  79. package/src/cli/commands/carve-apply.ts +20 -4
  80. package/src/cli/commands/carve-bridge.ts +9 -5
  81. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  82. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  83. package/src/cli/commands/carve-emit.ts +28 -24
  84. package/src/cli/commands/carve.ts +139 -36
  85. package/src/cli/handlers/carve-emit.ts +8 -3
  86. package/src/cli/handlers/carve.ts +8 -7
  87. package/src/cli/handlers/lifecycle.test.ts +187 -1
  88. package/src/cli/handlers/lifecycle.ts +125 -1
  89. package/src/cli/handlers/op-progress.test.ts +67 -1
  90. package/src/cli/handlers/op-progress.ts +70 -14
  91. package/src/cli/handlers/operator.test.ts +302 -1
  92. package/src/cli/handlers/operator.ts +193 -5
  93. package/src/cli/handlers/run.ts +11 -13
  94. package/src/cli/main.test.ts +19 -0
  95. package/src/cli/main.ts +38 -10
  96. package/src/cli/registry.ts +9 -1
  97. package/src/identity.test.ts +199 -0
  98. package/src/identity.ts +346 -0
  99. package/src/index.ts +1 -0
  100. package/src/lexicon.ts +53 -0
  101. package/src/lifecycle/converge-ledger.test.ts +110 -0
  102. package/src/lifecycle/converge-ledger.ts +110 -2
  103. package/src/lifecycle/gate-ledger.test.ts +81 -1
  104. package/src/lifecycle/gate-ledger.ts +64 -1
  105. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  106. package/src/terraform/adopt-state.test.ts +131 -0
  107. package/src/terraform/adopt-state.ts +19 -167
  108. package/src/terraform/aws-resources.test.ts +38 -22
  109. package/src/terraform/aws-resources.ts +1 -1
  110. package/src/terraform/carve-provider.test.ts +155 -0
  111. package/src/terraform/carve-provider.ts +237 -0
  112. package/src/terraform/carve.test.ts +55 -1
  113. package/src/terraform/carve.ts +0 -0
  114. package/src/terraform/emit-source.ts +39 -0
  115. package/src/terraform/graduate.test.ts +37 -0
  116. package/src/terraform/graduate.ts +55 -7
  117. package/src/terraform/graph.ts +3 -3
  118. package/src/terraform/providers/aws.ts +169 -0
  119. package/src/terraform/providers/gcp.test.ts +228 -0
  120. package/src/terraform/providers/gcp.ts +329 -0
  121. package/src/terraform/providers/index.ts +21 -0
  122. package/src/terraform/providers/kubernetes.ts +224 -0
  123. package/src/terraform/score.ts +111 -25
  124. package/src/terraform/tier-map.ts +45 -107
  125. package/src/terraform/types.ts +6 -0
@@ -0,0 +1,207 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveEmit, formatCarveEmit } from "./carve-emit";
6
+ import { loadHcl2json } from "../../terraform/parse";
7
+ import { registerCarveProvider, type CarveProvider } from "../../terraform/carve-provider";
8
+ import type { ImportResult, LiveImportOptions } from "./import";
9
+ import type { LexiconPlugin } from "../../lexicon";
10
+
11
+ /**
12
+ * The carve provider seam (#2016), driven end to end by a provider core has
13
+ * never heard of. Nothing in `carve-emit.ts` or `adopt-state.ts` mentions
14
+ * `fake_`, `fakelex` or `fake:Widget` — if emit can carry this estate to
15
+ * emitted source, a scaffolded project and a live selector, then adding a real
16
+ * provider (#999 kubernetes, #2017 gcp) is a file under `terraform/providers/`.
17
+ */
18
+ let parserAvailable = false;
19
+ try {
20
+ await loadHcl2json();
21
+ parserAvailable = true;
22
+ } catch {
23
+ parserAvailable = false;
24
+ }
25
+
26
+ const ESTATE = `
27
+ resource "fake_widget" "hero" { widget_name = "hero-widget" }
28
+ resource "fake_widget_setting" "hero" {
29
+ widget = fake_widget.hero.id
30
+ colour = "blue"
31
+ }
32
+ resource "fake_consumer" "reader" { widget = fake_widget.hero.id }
33
+ resource "fake_gizmo" "spare" { size = 3 }
34
+ `;
35
+
36
+ const TFSTATE = JSON.stringify({
37
+ version: 4,
38
+ resources: [
39
+ {
40
+ mode: "managed",
41
+ type: "fake_widget",
42
+ name: "hero",
43
+ instances: [{ attributes: { id: "hero-widget", widget_name: "hero-widget" } }],
44
+ },
45
+ {
46
+ mode: "managed",
47
+ type: "fake_widget_setting",
48
+ name: "hero",
49
+ instances: [{ attributes: { id: "hero-widget", colour: "blue" } }],
50
+ },
51
+ ],
52
+ });
53
+
54
+ /** A provider with an emit path: adopt from state, and a live selector type. */
55
+ const fakeProvider: CarveProvider = {
56
+ name: "test-fake-emit",
57
+ tfTypePrefixes: ["fake_"],
58
+ lexicon: "fakelex",
59
+ tiers: {
60
+ fake_widget: { tier: 1, mapsTo: "fake:Widget" },
61
+ fake_widget_setting: { tier: 2, mapsTo: "fake:WidgetSetting" },
62
+ fake_gizmo: { tier: 2, mapsTo: "fake:Gizmo" },
63
+ },
64
+ identityAttrs: { fake_widget: "widget_name" },
65
+ foldsInto: { fake_widget_setting: "fake_widget" },
66
+ emitTypes: ["fake_widget"],
67
+ adopt: (resource, params, folded) => ({
68
+ fileName: `${resource.name}.widget.ts`,
69
+ content: `// fake provider adopted ${resource.type}.${resource.name}\nexport const ${resource.name} = "${resource.attributes.widget_name}";\n`,
70
+ mapped: true,
71
+ nativeType: "fake:Widget",
72
+ parameterized: params.map((p) => p.name),
73
+ folded: folded.map((f) => ({ address: `${f.type}.${f.name}`, props: ["Colour"] })),
74
+ }),
75
+ liveSelectorType: (tfType) => (tfType === "fake_widget" ? "fake:Widget" : undefined),
76
+ };
77
+
78
+ /** Same provider, state-only: no live adoption path at all. */
79
+ const stateOnlyProvider: CarveProvider = {
80
+ ...fakeProvider,
81
+ name: "test-fake-state-only",
82
+ liveSelectorType: undefined,
83
+ };
84
+
85
+ const fakeImport = () =>
86
+ vi.fn(
87
+ async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
88
+ success: true,
89
+ generatedFiles: ["infra/hero.ts"],
90
+ warnings: [],
91
+ }),
92
+ );
93
+
94
+ let dispose: (() => void) | undefined;
95
+ afterEach(() => {
96
+ dispose?.();
97
+ dispose = undefined;
98
+ });
99
+
100
+ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
101
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-provider-"));
102
+ try {
103
+ writeFileSync(join(dir, "main.tf"), ESTATE);
104
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
105
+ return await fn(dir);
106
+ } finally {
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ describe("carve emit through a registered provider (#2016)", () => {
112
+ test("without a provider the type is unknown to advise and refused by emit", async () => {
113
+ if (!parserAvailable) return;
114
+ await withEstate(async (dir) => {
115
+ const res = await carveEmit(
116
+ { from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate") },
117
+ { plugins: [], liveImport: fakeImport() },
118
+ );
119
+ expect(res.ok).toBe(false);
120
+ expect(res.error).toMatch(/no known native mapping/i);
121
+ });
122
+ });
123
+
124
+ test("--state emits through the provider and scaffolds for its lexicon", async () => {
125
+ if (!parserAvailable) return;
126
+ dispose = registerCarveProvider(fakeProvider);
127
+ await withEstate(async (dir) => {
128
+ const out = join(dir, "carveout");
129
+ const liveImport = fakeImport();
130
+ const res = await carveEmit(
131
+ { from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate"), output: out },
132
+ { plugins: [], liveImport },
133
+ );
134
+
135
+ expect(res.ok).toBe(true);
136
+ expect(res.source).toBe("tfstate");
137
+ expect(liveImport).not.toHaveBeenCalled();
138
+
139
+ // The provider named the file and wrote the content; core wrote it out.
140
+ expect(res.emittedFiles).toEqual([join(out, "src", "hero.widget.ts")]);
141
+ expect(readFileSync(res.emittedFiles![0], "utf-8")).toContain("fake provider adopted fake_widget.hero");
142
+
143
+ // The scaffold targets the provider's lexicon, not a hardcoded aws.
144
+ const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
145
+ expect(pkg.scripts.build).toBe("chant build src --lexicon fakelex");
146
+ expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-fakelex"]);
147
+ expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["fakelex"]');
148
+
149
+ // The provider's foldsInto drove the carve set, so the setting folded in.
150
+ expect(res.folded).toEqual([{ address: "fake_widget_setting.hero", props: ["Colour"] }]);
151
+ expect(formatCarveEmit(res)).toContain("fake_widget_setting.hero (Colour)");
152
+ // And its identityAttrs gave the graph the physical name.
153
+ expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["fake_consumer.reader"]);
154
+ });
155
+ });
156
+
157
+ test("--env uses the provider's live selector type and lexicon", async () => {
158
+ if (!parserAvailable) return;
159
+ dispose = registerCarveProvider(fakeProvider);
160
+ await withEstate(async (dir) => {
161
+ const liveImport = fakeImport();
162
+ const res = await carveEmit(
163
+ { from: dir, select: "fake_widget.hero", env: "prod" },
164
+ { plugins: [], liveImport },
165
+ );
166
+ expect(res.ok).toBe(true);
167
+ expect(res.selector).toEqual({ type: "fake:Widget" });
168
+ expect(liveImport.mock.calls[0][1]).toMatchObject({
169
+ environment: "prod",
170
+ selector: { type: "fake:Widget" },
171
+ lexicon: "fakelex",
172
+ });
173
+ expect(formatCarveEmit(res)).toContain("Adopted live as fake:Widget");
174
+ });
175
+ });
176
+
177
+ test("a ranked type the provider does not emit is refused identically on both paths", async () => {
178
+ if (!parserAvailable) return;
179
+ dispose = registerCarveProvider(fakeProvider);
180
+ await withEstate(async (dir) => {
181
+ const liveImport = fakeImport();
182
+ const live = await carveEmit({ from: dir, select: "fake_gizmo.spare", env: "prod" }, { plugins: [], liveImport });
183
+ const state = await carveEmit(
184
+ { from: dir, select: "fake_gizmo.spare", statePath: join(dir, "terraform.tfstate") },
185
+ { plugins: [], liveImport },
186
+ );
187
+ expect(live.ok).toBe(false);
188
+ expect(state.ok).toBe(false);
189
+ expect(live.error).toBe(state.error);
190
+ expect(live.error).toContain("fake_gizmo cannot be emitted yet");
191
+ expect(liveImport).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ test("a state-only provider refuses --env instead of importing the wrong thing", async () => {
196
+ if (!parserAvailable) return;
197
+ dispose = registerCarveProvider(stateOnlyProvider);
198
+ await withEstate(async (dir) => {
199
+ const liveImport = fakeImport();
200
+ const res = await carveEmit({ from: dir, select: "fake_widget.hero", env: "prod" }, { plugins: [], liveImport });
201
+ expect(res.ok).toBe(false);
202
+ expect(res.error).toContain("no live adoption path");
203
+ expect(res.error).toContain("--state");
204
+ expect(liveImport).not.toHaveBeenCalled();
205
+ });
206
+ });
207
+ });
@@ -14,9 +14,11 @@
14
14
 
15
15
  import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
16
16
  import { basename, join, resolve } from "path";
17
+ import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
17
18
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
18
19
  import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
19
- import { canCarveEmit, carveEmitTypes, resolveTier } from "../../terraform/tier-map";
20
+ import { carveEmitTypes, resolveTier } from "../../terraform/tier-map";
21
+ import { resolveEmitProvider } from "../../terraform/carve-provider";
20
22
  import { readStateResource, type StateResource } from "../../terraform/state";
21
23
  import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
22
24
  import { adoptFromState, type DeferredParam, type FoldedContribution } from "../../terraform/adopt-state";
@@ -24,14 +26,6 @@ import { getChantVersion } from "./init";
24
26
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
25
27
  import type { ImportResult, LiveImportOptions } from "./import";
26
28
 
27
- /**
28
- * The lexicon emit targets. `canCarveEmit` admits only AWS carve-table types,
29
- * so both the scaffolded project and the live import are AWS. Widening this
30
- * needs the lexicon-agnostic emit seam (#999, #2016) — deriving it from
31
- * `tier.mapsTo` does not work: a `k8s:Namespace` mapping has no `::` to split.
32
- */
33
- const EMIT_LEXICON = "aws";
34
-
35
29
  export interface CarveEmitOptions {
36
30
  /** Terraform estate directory (`--from`). */
37
31
  from?: string;
@@ -92,6 +86,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
92
86
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
93
87
  return { ok: false, error: `Not a directory: ${opts.from}` };
94
88
  }
89
+ if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "emit") };
95
90
 
96
91
  let report: CarveReport | null;
97
92
  let tfType: string | undefined;
@@ -106,23 +101,23 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
106
101
 
107
102
  if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
108
103
 
109
- const tier = tfType ? resolveTier(tfType) : null;
110
- if (!tier) {
104
+ if (!tfType || !resolveTier(tfType)) {
111
105
  return {
112
106
  ok: false,
113
107
  error: `${opts.select} (${tfType ?? "unknown type"}) has no known native mapping, so it cannot be emitted. Advisor bands it "leave in Terraform".`,
114
108
  };
115
109
  }
116
110
 
117
- // `carve advise` ranks every type in the tier map; emit produces source only
118
- // for the ones with a native constructor mapping. Both adoption paths refuse
119
- // the rest here — before any file is written or any cloud call is made — so
111
+ // `carve advise` ranks every type a provider declares; emit produces source
112
+ // only for the ones that provider can adopt. Both adoption paths refuse the
113
+ // rest here — before any file is written or any cloud call is made — so
120
114
  // neither path can proceed on a type the other rejects.
121
- if (!canCarveEmit(tfType!)) {
115
+ const provider = resolveEmitProvider(tfType);
116
+ if (!provider) {
122
117
  return {
123
118
  ok: false,
124
119
  error:
125
- `${tfType} cannot be emitted yet (no native constructor mapping).\n` +
120
+ `${tfType} cannot be emitted yet (no carve provider adopts it).\n` +
126
121
  `Supported types: ${carveEmitTypes().join(", ")}. ` +
127
122
  `Coverage is expanding — see chant issue #1001.`,
128
123
  };
@@ -156,7 +151,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
156
151
  mkdirSync(srcDir, { recursive: true });
157
152
  const outPath = join(srcDir, adopted.fileName);
158
153
  writeFileSync(outPath, adopted.content);
159
- const scaffolded = scaffoldProject(outDir, EMIT_LEXICON, params);
154
+ const scaffolded = scaffoldProject(outDir, provider.lexicon, params);
160
155
 
161
156
  const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
162
157
  return {
@@ -172,19 +167,28 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
172
167
  }
173
168
 
174
169
  // ── Adoption path 2: live import (cloud→code) ──
175
- // The live import filters a CloudFormation stack's template by logical ID, not
176
- // the Terraform physical name — so select by native type, narrowing to a
177
- // logical ID only when the caller passes --live-name (a stack with several of
178
- // that type). `identity` (the TF physical name) is not a valid CFN selector.
170
+ // The provider names the selector type its live export filters on — for aws a
171
+ // CloudFormation type, filtered by logical ID rather than the Terraform
172
+ // physical name, so `--live-name` (not `identity`) is what narrows a stack
173
+ // holding several of that type.
174
+ const selectorType = provider.liveSelectorType?.(tfType);
175
+ if (!selectorType) {
176
+ return {
177
+ ok: false,
178
+ error:
179
+ `${tfType} has no live adoption path — the ${provider.name} carve provider adopts it from state only.\n` +
180
+ `Re-run with --state <tfstate>.`,
181
+ };
182
+ }
179
183
  const selector: ResourceSelector = opts.liveName
180
- ? { type: tier.mapsTo, name: opts.liveName }
181
- : { type: tier.mapsTo };
184
+ ? { type: selectorType, name: opts.liveName }
185
+ : { type: selectorType };
182
186
 
183
187
  const emit = await deps.liveImport(deps.plugins, {
184
188
  environment: opts.env!,
185
189
  selector,
186
190
  output: opts.output,
187
- lexicon: EMIT_LEXICON,
191
+ lexicon: provider.lexicon,
188
192
  });
189
193
 
190
194
  // A failed import emitted no source. Reporting success here would write a
@@ -1,20 +1,29 @@
1
1
  /**
2
- * `chant carve advise` — the read-only Terraform peelability advisor (#214).
2
+ * `chant carve advise` — the read-only peelability advisor (#214, #1056).
3
3
  *
4
- * Points at a Terraform estate and reports which resources/modules are cheap to
5
- * carve into native chant later and which should stay in Terraform. It emits
4
+ * Points at foreign infrastructure and reports which pieces are cheap to carve
5
+ * into native chant later and which should stay where they are. It emits
6
6
  * nothing, patches nothing, and touches no live resource — pure analysis. The
7
- * emit/boundary/apply phases stay in #197, gated on demand.
7
+ * emit/boundary/apply phases (`carve emit|bridge|apply`, #197) read Terraform
8
+ * only, and refuse a cloud assembly by name.
9
+ *
10
+ * Two sources, one report. A Terraform estate is parsed from `.tf`; a CDK cloud
11
+ * assembly is read from `cdk.out` (#1056). Which one is in `--from` is decided
12
+ * by looking, not by a second flag: a directory holding `manifest.json` plus a
13
+ * synthesized `*.template.json` is an assembly. Both produce the same bands,
14
+ * the same score arithmetic, and the same JSON shape.
8
15
  */
9
16
 
10
17
  import { existsSync, statSync, writeFileSync } from "fs";
18
+ import { adviseCloudAssembly, CDK_DIALECT } from "../../cdk/advise";
19
+ import { isCloudAssembly } from "../../cdk/assembly";
11
20
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
12
- import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
13
- import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
21
+ import { scoreEstate, type Peelability, type PeelabilityBand, type ScoreOptions } from "../../terraform/score";
22
+ import { boundaryReport, type BoundaryEdge, type CarveDialect } from "../../terraform/carve";
14
23
  import type { TfGraph } from "../../terraform/types";
15
24
 
16
25
  export interface CarveAdviseOptions {
17
- /** Terraform estate directory (from `--from`). */
26
+ /** Terraform estate directory or CDK cloud assembly (from `--from`). */
18
27
  from?: string;
19
28
  /** Opt-in `.tfstate` path (from `--state`): accurate fan-out instance counts. */
20
29
  statePath?: string;
@@ -22,10 +31,19 @@ export interface CarveAdviseOptions {
22
31
  reportFile?: string;
23
32
  }
24
33
 
34
+ /**
35
+ * Which source the ranking came from. Terraform addresses and CDK construct
36
+ * paths are different namespaces, and the bridge patches have different names,
37
+ * so a report says which it is rather than leaving a reader to guess from the
38
+ * shape of an address.
39
+ */
40
+ export type CarveSource = "terraform" | "cdk";
41
+
25
42
  export interface CarveAdviseResult {
26
43
  ok: boolean;
27
44
  error?: string;
28
45
  from?: string;
46
+ source?: CarveSource;
29
47
  results?: Peelability[];
30
48
  /**
31
49
  * The parsed dependency graph the scores came from. Kept so the JSON report
@@ -33,37 +51,69 @@ export interface CarveAdviseResult {
33
51
  * Not part of the JSON payload — `carveJson` derives from it.
34
52
  */
35
53
  graph?: TfGraph;
54
+ /** The hooks the scores were produced with, so the boundary pass agrees. */
55
+ scoreOptions?: ScoreOptions;
56
+ /** Anything about the read itself worth saying out loud. */
57
+ diagnostics?: string[];
36
58
  }
37
59
 
38
60
  const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
39
61
 
40
62
  export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
41
63
  if (!opts.from) {
42
- return { ok: false, error: "chant carve advise requires --from <terraform-dir>" };
64
+ return { ok: false, error: "chant carve advise requires --from <terraform-dir | cdk.out>" };
43
65
  }
44
66
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
45
67
  return { ok: false, error: `Not a directory: ${opts.from}` };
46
68
  }
69
+
70
+ const result = isCloudAssembly(opts.from) ? adviseCdk(opts) : await adviseTerraform(opts);
71
+ if (result.ok && opts.reportFile) {
72
+ writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /** Rank a CDK cloud assembly. No parser to install, no state file to read. */
78
+ function adviseCdk(opts: CarveAdviseOptions): CarveAdviseResult {
79
+ if (opts.statePath) {
80
+ return {
81
+ ok: false,
82
+ error:
83
+ `${opts.from} is a CDK cloud assembly, and --state is a Terraform option. ` +
84
+ "A synthesized template already carries its own instance counts.",
85
+ };
86
+ }
87
+ try {
88
+ const advice = adviseCloudAssembly(opts.from!);
89
+ return {
90
+ ok: true,
91
+ from: opts.from,
92
+ source: "cdk",
93
+ results: advice.results,
94
+ graph: advice.graph,
95
+ scoreOptions: advice.scoreOptions,
96
+ diagnostics: advice.diagnostics,
97
+ };
98
+ } catch (err) {
99
+ return {
100
+ ok: false,
101
+ error: `Failed to read the CDK cloud assembly in ${opts.from}: ${err instanceof Error ? err.message : String(err)}`,
102
+ };
103
+ }
104
+ }
105
+
106
+ async function adviseTerraform(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
47
107
  if (opts.statePath && !existsSync(opts.statePath)) {
48
108
  return { ok: false, error: `State file not found: ${opts.statePath}` };
49
109
  }
50
-
51
- let graph: TfGraph;
52
- let results: Peelability[];
53
110
  try {
54
- graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
55
- results = scoreEstate(graph);
111
+ const graph = await parseTerraformDir(opts.from!, { statePath: opts.statePath });
112
+ return { ok: true, from: opts.from, source: "terraform", results: scoreEstate(graph), graph };
56
113
  } catch (err) {
57
114
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
58
115
  return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
59
116
  }
60
-
61
- const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
62
- if (opts.reportFile) {
63
- writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
64
- }
65
-
66
- return result;
67
117
  }
68
118
 
69
119
  /**
@@ -81,6 +131,16 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
81
131
  * not).
82
132
  * - A reader that does not know the version it is handed should refuse the
83
133
  * report rather than half-read it.
134
+ *
135
+ * The CDK source (#1056) stays inside that promise and does not bump the
136
+ * version. Every field an existing reader keys on keeps its type and meaning:
137
+ * `address` is still an opaque string identifier (a construct path instead of a
138
+ * Terraform address), `kind` is still `"resource"` or `"module"` (an L3
139
+ * construct subtree is a Composite candidate, which is what `module` already
140
+ * means), `score`/`band`/`breakdown` are the same arithmetic. What is new is
141
+ * additive and ignorable: the top-level `source` discriminator, per-resource
142
+ * `notes` and `members`, and two more `bridge` values in a list that was
143
+ * already declared open-ended.
84
144
  */
85
145
  export const CARVE_REPORT_VERSION = 1;
86
146
 
@@ -113,10 +173,23 @@ export interface CarveJsonResource extends Peelability {
113
173
  export interface CarveJsonReport {
114
174
  version: number;
115
175
  from?: string;
176
+ /**
177
+ * Which source was read (#1056). `terraform` — addresses are Terraform
178
+ * addresses, bridges are `data` sources. `cdk` — addresses are construct
179
+ * paths and bridges are reimports. Absent on a chant that predates the CDK
180
+ * advisor, which a reader should take as `terraform`.
181
+ */
182
+ source: CarveSource;
116
183
  advisory: string;
117
184
  count: number;
118
185
  /** Band name -> how many resources landed in it. */
119
186
  bands: Record<string, number>;
187
+ /**
188
+ * Anything about the read the scores alone do not say — a missing `tree.json`,
189
+ * an assembly synthesized from unresolved context lookups. Present only when
190
+ * there is something to report.
191
+ */
192
+ diagnostics?: string[];
120
193
  resources: CarveJsonResource[];
121
194
  }
122
195
 
@@ -126,20 +199,29 @@ export function carveJson(result: CarveAdviseResult): CarveJsonReport {
126
199
  const counts = Object.fromEntries(
127
200
  BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
128
201
  );
202
+ const source = result.source ?? "terraform";
203
+ const dialect: CarveDialect = source === "cdk" ? CDK_DIALECT : "terraform";
129
204
  return {
130
205
  version: CARVE_REPORT_VERSION,
131
206
  from: result.from,
207
+ source,
132
208
  advisory: "read-only — emits nothing, patches nothing, touches no live resource",
133
209
  count: results.length,
134
210
  bands: counts,
135
- resources: results.map((r) => withBoundary(r, result.graph)),
211
+ ...(result.diagnostics?.length ? { diagnostics: result.diagnostics } : {}),
212
+ resources: results.map((r) => withBoundary(r, result.graph, dialect, result.scoreOptions)),
136
213
  };
137
214
  }
138
215
 
139
216
  /** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
140
- function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
217
+ function withBoundary(
218
+ r: Peelability,
219
+ graph: TfGraph | undefined,
220
+ dialect: CarveDialect,
221
+ score: ScoreOptions | undefined,
222
+ ): CarveJsonResource {
141
223
  if (!graph) return r;
142
- const report = boundaryReport(graph, r.address);
224
+ const report = boundaryReport(graph, r.address, { dialect, score });
143
225
  if (!report) return r;
144
226
  return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
145
227
  }
@@ -147,52 +229,73 @@ function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
147
229
  /** Human-readable banded, ranked summary. */
148
230
  export function formatCarveReport(result: CarveAdviseResult): string {
149
231
  const results = result.results ?? [];
232
+ const source = result.source ?? "terraform";
233
+ const cdk = source === "cdk";
234
+
150
235
  if (results.length === 0) {
151
- return `No carvable resources found in ${result.from}\n(only data sources, providers, or unsupported types were present).`;
236
+ return cdk
237
+ ? `No carvable constructs found in ${result.from}\n(the assembly synthesized no infrastructure beyond CDK's own scaffolding).`
238
+ : `No carvable resources found in ${result.from}\n(only data sources, providers, or unsupported types were present).`;
152
239
  }
153
240
 
154
241
  const lines: string[] = [];
155
- lines.push(`Terraform carve-out advisory for ${result.from}`);
156
- lines.push(` ${results.length} resource(s)/module(s) scored. Advises only — nothing is emitted or changed.`);
242
+ lines.push(`${cdk ? "CDK cloud-assembly" : "Terraform"} carve-out advisory for ${result.from}`);
243
+ lines.push(
244
+ ` ${results.length} ${cdk ? "construct(s)" : "resource(s)/module(s)"} scored. ` +
245
+ "Advises only — nothing is emitted or changed.",
246
+ );
247
+ for (const note of result.diagnostics ?? []) lines.push(` ! ${note}`);
157
248
  lines.push("");
158
249
 
159
250
  for (const band of BAND_ORDER) {
160
251
  const inBand = results.filter((r) => r.band === band);
161
252
  if (inBand.length === 0) continue;
162
- lines.push(`${bandLabel(band)} (${inBand.length})`);
253
+ lines.push(`${bandLabel(band, cdk)} (${inBand.length})`);
163
254
  for (const r of inBand) {
164
255
  const target = r.mapsTo ? ` -> ${r.mapsTo}` : "";
165
256
  lines.push(` ${String(r.score).padStart(3)} ${r.address}${target}`);
166
- lines.push(` ${reasons(r)}`);
257
+ lines.push(` ${reasons(r, cdk)}`);
258
+ for (const note of r.notes ?? []) lines.push(` ${note}`);
167
259
  }
168
260
  lines.push("");
169
261
  }
170
262
 
171
- lines.push("Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in Terraform");
263
+ lines.push(
264
+ `Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in ${cdk ? "CDK" : "Terraform"}`,
265
+ );
172
266
  return lines.join("\n").trimEnd();
173
267
  }
174
268
 
175
- function bandLabel(band: PeelabilityBand): string {
269
+ function bandLabel(band: PeelabilityBand, cdk: boolean): string {
176
270
  switch (band) {
177
271
  case "clean leaf":
178
272
  return "CLEAN LEAF — carve now";
179
273
  case "carvable w/ edits":
180
274
  return "CARVABLE — has boundary work";
181
275
  case "leave in Terraform":
182
- return "LEAVE IN TERRAFORM";
276
+ // The band's name is part of the JSON contract and does not change per
277
+ // source; only the heading a person reads does.
278
+ return cdk ? "LEAVE IN CDK" : "LEAVE IN TERRAFORM";
183
279
  }
184
280
  }
185
281
 
186
282
  /** One-line explanation of what drove a score, from its breakdown. */
187
- function reasons(r: Peelability): string {
283
+ function reasons(r: Peelability, cdk = false): string {
188
284
  const b = r.breakdown;
189
- if (b.tier === null) return "no known native mapping (unsupported provider/type)";
285
+ if (b.tier === null) {
286
+ return cdk ? "no chant AWS lexicon target for this type" : "no known native mapping (unsupported provider/type)";
287
+ }
288
+ // A node that scored 0 with every penalty at 0 was disqualified outright, not
289
+ // scored down. Reciting the arithmetic would credit terms that never applied;
290
+ // the stated reason follows on the next line.
291
+ if (r.score === 0 && Object.values(b.penalties).every((p) => p === 0)) return "not carvable as it stands";
190
292
  const parts: string[] = [];
191
- if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
192
- if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (one-line rewrite each)`);
293
+ if (b.inbound) parts.push(`${b.inbound} inbound (${cdk ? "a reimport each" : "data-source patch each"})`);
294
+ if (b.outputs) parts.push(`${b.outputs} ${cdk ? "stack output(s)" : "output block(s)"} reading it (one-line rewrite each)`);
193
295
  if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
194
296
  if (b.tier > 1) parts.push(`tier ${b.tier} map`);
195
- if (b.hasDynamic) parts.push("count/for_each/data present");
297
+ if (b.hasDynamic) parts.push(cdk ? "condition/parameter present" : "count/for_each/data present");
196
298
  if (b.instances > 1) parts.push(`${b.instances} instances`);
299
+ if (b.penalties.asset) parts.push("asset-backed");
197
300
  return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
198
301
  }
@@ -1,5 +1,6 @@
1
1
  import { carveEmit, formatCarveEmit } from "../commands/carve-emit";
2
2
  import { liveImportFromPlugins } from "../commands/import";
3
+ import { carveEmitLexicons, resolveEmitProvider } from "../../terraform/carve-provider";
3
4
  import { loadPlugins } from "../plugins";
4
5
  import { formatError } from "../format";
5
6
  import type { CommandContext } from "../registry";
@@ -16,12 +17,16 @@ export async function runCarveEmit(ctx: CommandContext): Promise<number> {
16
17
  const { args } = ctx;
17
18
 
18
19
  // Load plugins only for the live path; the offline --state path needs none.
19
- // aws is the only lexicon emit can adopt into (`canCarveEmit`), so it is the
20
- // only one loaded here.
20
+ // Which lexicon comes from the carve provider seam (#2016), resolved off the
21
+ // selected address's type without parsing the estate — so a second provider
22
+ // needs no edit here. A type no provider emits falls back to every lexicon
23
+ // emit can adopt into, and carveEmit refuses the type properly afterwards.
21
24
  let plugins = ctx.plugins;
22
25
  if (!args.statePath && args.env) {
26
+ const tfType = args.selectAddress?.split(".")[0] ?? "";
27
+ const lexicon = resolveEmitProvider(tfType)?.lexicon;
23
28
  try {
24
- plugins = await loadPlugins(["aws"]);
29
+ plugins = await loadPlugins(lexicon ? [lexicon] : carveEmitLexicons());
25
30
  } catch {
26
31
  // fall through with whatever ctx provided; carveEmit surfaces a clear error
27
32
  }