@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
@@ -14,18 +14,14 @@
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 { 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
- import {
23
- adoptFromState,
24
- canAdoptFromState,
25
- supportedStateAdoptionTypes,
26
- type DeferredParam,
27
- type FoldedContribution,
28
- } from "../../terraform/adopt-state";
24
+ import { adoptFromState, type DeferredParam, type FoldedContribution } from "../../terraform/adopt-state";
29
25
  import { getChantVersion } from "./init";
30
26
  import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
31
27
  import type { ImportResult, LiveImportOptions } from "./import";
@@ -90,6 +86,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
90
86
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
91
87
  return { ok: false, error: `Not a directory: ${opts.from}` };
92
88
  }
89
+ if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "emit") };
93
90
 
94
91
  let report: CarveReport | null;
95
92
  let tfType: string | undefined;
@@ -104,27 +101,32 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
104
101
 
105
102
  if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
106
103
 
107
- const tier = tfType ? resolveTier(tfType) : null;
108
- if (!tier) {
104
+ if (!tfType || !resolveTier(tfType)) {
109
105
  return {
110
106
  ok: false,
111
107
  error: `${opts.select} (${tfType ?? "unknown type"}) has no known native mapping, so it cannot be emitted. Advisor bands it "leave in Terraform".`,
112
108
  };
113
109
  }
114
110
 
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
114
+ // neither path can proceed on a type the other rejects.
115
+ const provider = resolveEmitProvider(tfType);
116
+ if (!provider) {
117
+ return {
118
+ ok: false,
119
+ error:
120
+ `${tfType} cannot be emitted yet (no carve provider adopts it).\n` +
121
+ `Supported types: ${carveEmitTypes().join(", ")}. ` +
122
+ `Coverage is expanding — see chant issue #1001.`,
123
+ };
124
+ }
125
+
115
126
  if (opts.reportFile) writeFileSync(opts.reportFile, JSON.stringify(report, null, 2));
116
127
 
117
128
  // ── Adoption path 1: from .tfstate (offline, correct for TF-managed) ──
118
129
  if (opts.statePath) {
119
- if (!canAdoptFromState(tfType!)) {
120
- return {
121
- ok: false,
122
- error:
123
- `${tfType} cannot be adopted from state yet (no native constructor mapping).\n` +
124
- `Supported types: ${supportedStateAdoptionTypes().join(", ")}. ` +
125
- `Coverage is expanding — see chant issue #1001.`,
126
- };
127
- }
128
130
  const stateResource = readStateResource(opts.statePath, opts.select);
129
131
  if (!stateResource) {
130
132
  return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
@@ -149,8 +151,7 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
149
151
  mkdirSync(srcDir, { recursive: true });
150
152
  const outPath = join(srcDir, adopted.fileName);
151
153
  writeFileSync(outPath, adopted.content);
152
- const lexicon = tier.mapsTo.split("::")[0]?.toLowerCase() ?? "aws";
153
- const scaffolded = scaffoldProject(outDir, lexicon, params);
154
+ const scaffolded = scaffoldProject(outDir, provider.lexicon, params);
154
155
 
155
156
  const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
156
157
  return {
@@ -166,21 +167,47 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
166
167
  }
167
168
 
168
169
  // ── Adoption path 2: live import (cloud→code) ──
169
- // The live import filters a CloudFormation stack's template by logical ID, not
170
- // the Terraform physical name — so select by native type, narrowing to a
171
- // logical ID only when the caller passes --live-name (a stack with several of
172
- // 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
+ }
173
183
  const selector: ResourceSelector = opts.liveName
174
- ? { type: tier.mapsTo, name: opts.liveName }
175
- : { type: tier.mapsTo };
184
+ ? { type: selectorType, name: opts.liveName }
185
+ : { type: selectorType };
176
186
 
177
187
  const emit = await deps.liveImport(deps.plugins, {
178
188
  environment: opts.env!,
179
189
  selector,
180
190
  output: opts.output,
181
- lexicon: tier.mapsTo.split("::")[0]?.toLowerCase() === "aws" ? "aws" : undefined,
191
+ lexicon: provider.lexicon,
182
192
  });
183
193
 
194
+ // A failed import emitted no source. Reporting success here would write a
195
+ // carve manifest that `carve bridge` composes from, and bridge excises the
196
+ // survivor's `.tf` block for the resource — so the estate would lose a
197
+ // declaration that was never adopted. Fail, and persist nothing.
198
+ if (!emit.success) {
199
+ return {
200
+ ok: false,
201
+ error:
202
+ `Live import of ${selector.type} from ${opts.env} failed: ${emit.error ?? "no error reported"}\n` +
203
+ `Nothing was emitted and no carve manifest was written.`,
204
+ report,
205
+ emit,
206
+ selector,
207
+ source: "live",
208
+ };
209
+ }
210
+
184
211
  const outDir = opts.output ?? join(opts.from, "carveout");
185
212
  const manifestPath = persistManifest(outDir, opts, report, tfType, "live", emit.generatedFiles ?? []);
186
213
  return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles, manifestPath };
@@ -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
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The import command's own contract, separate from the mapping it delegates to
3
+ * (covered by the fountain lexicon's `local-agents.test.ts`).
4
+ *
5
+ * What matters here is the parts a user hits when something is off: a lexicon
6
+ * that can't be loaded or can't express agents, an output directory that
7
+ * already has files in it, and — above all — that every lossy step the mapper
8
+ * reports actually reaches the user. Re-expression silently dropping a config
9
+ * or defaulting a required field is the failure mode that would make the
10
+ * generated code untrustworthy.
11
+ *
12
+ * The plugin is injected rather than loaded, so these run without a built
13
+ * lexicon on disk.
14
+ */
15
+
16
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
17
+ import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
18
+ import { join } from "path";
19
+ import { tmpdir } from "os";
20
+ import { importAgentsCommand, DEFAULT_AGENT_LEXICON, type PluginLoader } from "./import-agents";
21
+ import type { LexiconPlugin } from "../../lexicon";
22
+ import type { AgentImportOutcome } from "../../agents/importer";
23
+ import type { TemplateIR } from "../../import/parser";
24
+
25
+ let home: string;
26
+ let out: string;
27
+
28
+ function writeIn(root: string, rel: string, content: string): void {
29
+ const full = join(root, rel);
30
+ mkdirSync(join(full, ".."), { recursive: true });
31
+ writeFileSync(full, content, "utf-8");
32
+ }
33
+
34
+ beforeEach(() => {
35
+ home = join(tmpdir(), `chant-ia-home-${Math.random().toString(36).slice(2)}`);
36
+ out = join(tmpdir(), `chant-ia-out-${Math.random().toString(36).slice(2)}`);
37
+ mkdirSync(home, { recursive: true });
38
+ });
39
+
40
+ afterEach(() => {
41
+ rmSync(home, { recursive: true, force: true });
42
+ rmSync(out, { recursive: true, force: true });
43
+ });
44
+
45
+ /** A config with one MCP server, so the scan finds something to import. */
46
+ function seedConfig(): void {
47
+ writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "x" } } }));
48
+ }
49
+
50
+ const EMPTY_OUTCOME: AgentImportOutcome = { ir: { resources: [], parameters: [] }, skipped: [], unmappedModel: [], redactedSecrets: [] };
51
+
52
+ /** A plugin whose importer and generator are both stubbed. */
53
+ function fakePlugin(outcome: Partial<AgentImportOutcome> = {}, overrides: Partial<LexiconPlugin> = {}): LexiconPlugin {
54
+ const resolved: AgentImportOutcome = { ...EMPTY_OUTCOME, ...outcome };
55
+ return {
56
+ name: "fountain",
57
+ agentConfigImporter: () => ({ toTemplateIR: () => resolved }),
58
+ templateGenerator: () => ({
59
+ generate: (ir: TemplateIR) => [{ path: "main.ts", content: ir.resources.map((r) => `export const ${r.logicalId} = {};`).join("\n") }],
60
+ }),
61
+ ...overrides,
62
+ } as unknown as LexiconPlugin;
63
+ }
64
+
65
+ /** An outcome with one resource, so the command reaches the write step. */
66
+ const ONE_RESOURCE: Partial<AgentImportOutcome> = {
67
+ ir: { resources: [{ logicalId: "userClaude", type: "Fountain::V1::Agent", properties: {} }], parameters: [] },
68
+ };
69
+
70
+ const loaderFor = (plugin: LexiconPlugin): PluginLoader => async () => plugin;
71
+
72
+ const run = (opts: Parameters<typeof importAgentsCommand>[0] = {}) =>
73
+ importAgentsCommand({ home, platform: "linux", projectRoots: [], output: out, pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE)), ...opts });
74
+
75
+ describe("nothing to import", () => {
76
+ test("fails with a clear message when no agent config exists", async () => {
77
+ const result = await run();
78
+ expect(result.success).toBe(false);
79
+ expect(result.error).toContain("No agent configuration found");
80
+ expect(result.generatedFiles).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("lexicon resolution", () => {
85
+ test("defaults to the fountain lexicon", async () => {
86
+ seedConfig();
87
+ let asked: string | undefined;
88
+ await run({ pluginLoader: async (name) => { asked = name; return fakePlugin(ONE_RESOURCE); } });
89
+ expect(asked).toBe(DEFAULT_AGENT_LEXICON);
90
+ });
91
+
92
+ test("reports an install hint when the lexicon package is missing", async () => {
93
+ seedConfig();
94
+ const result = await run({
95
+ lexicon: "nope",
96
+ pluginLoader: async () => { throw new Error("Cannot find module"); },
97
+ });
98
+ expect(result.success).toBe(false);
99
+ expect(result.error).toContain("npm i @intentius/chant-lexicon-nope");
100
+ });
101
+
102
+ test("names the fallback when the lexicon cannot express agent config", async () => {
103
+ seedConfig();
104
+ const result = await run({ lexicon: "k8s", pluginLoader: loaderFor(fakePlugin({}, { agentConfigImporter: undefined })) });
105
+ expect(result.success).toBe(false);
106
+ expect(result.error).toContain("cannot express agent configuration");
107
+ expect(result.error).toContain(`--lexicon ${DEFAULT_AGENT_LEXICON}`);
108
+ });
109
+
110
+ test("fails when the lexicon has no generator to emit TypeScript", async () => {
111
+ seedConfig();
112
+ const result = await run({ pluginLoader: loaderFor(fakePlugin(ONE_RESOURCE, { templateGenerator: undefined })) });
113
+ expect(result.success).toBe(false);
114
+ expect(result.error).toContain("no templateGenerator");
115
+ });
116
+ });
117
+
118
+ describe("reporting what was lost", () => {
119
+ test("surfaces a skipped site with the mapper's reason", async () => {
120
+ seedConfig();
121
+ const result = await run({
122
+ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, skipped: [{ siteId: "user-cursor", reason: 'fountain has no "cursor" runtime' }] })),
123
+ });
124
+ expect(result.warnings.some((w) => w.includes("user-cursor") && w.includes("cursor"))).toBe(true);
125
+ });
126
+
127
+ test("warns that a defaulted model needs editing before it is applied", async () => {
128
+ seedConfig();
129
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, unmappedModel: ["user-claude"] })) });
130
+ expect(result.warnings.some((w) => w.includes("user-claude") && w.includes("Edit it before applying"))).toBe(true);
131
+ });
132
+
133
+ test("states that redacted credentials were not copied into the generated code", async () => {
134
+ // The user has to know a secret was *removed*, not carried over — otherwise
135
+ // they'd assume the generated config works as-is.
136
+ seedConfig();
137
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ ...ONE_RESOURCE, redactedSecrets: ["user-claude"] })) });
138
+ const warning = result.warnings.find((w) => w.includes("Literal credentials"));
139
+ expect(warning).toContain("were NOT copied");
140
+ });
141
+
142
+ test("fails, rather than writing an empty file, when nothing could be mapped", async () => {
143
+ seedConfig();
144
+ const result = await run({ pluginLoader: loaderFor(fakePlugin({ skipped: [{ siteId: "user-cursor", reason: "no runtime" }] })) });
145
+ expect(result.success).toBe(false);
146
+ expect(result.error).toContain("none could be expressed");
147
+ expect(existsSync(join(out, "main.ts"))).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("writing output", () => {
152
+ test("writes the generated files and reports what it counted", async () => {
153
+ seedConfig();
154
+ const result = await run();
155
+ expect(result.success).toBe(true);
156
+ expect(result.generatedFiles).toEqual([join(out, "main.ts")]);
157
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
158
+ expect(result.summary).toEqual({ discovered: 1, mapped: 1 });
159
+ });
160
+
161
+ test("refuses to overwrite an existing file without --force", async () => {
162
+ // Generated agent code gets hand-edited after the first run; silently
163
+ // reverting those edits would be the worst outcome for this command.
164
+ seedConfig();
165
+ mkdirSync(out, { recursive: true });
166
+ writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
167
+
168
+ const result = await run();
169
+ expect(result.success).toBe(false);
170
+ expect(result.error).toContain("--force");
171
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toBe("// hand-edited");
172
+ });
173
+
174
+ test("overwrites with --force", async () => {
175
+ seedConfig();
176
+ mkdirSync(out, { recursive: true });
177
+ writeFileSync(join(out, "main.ts"), "// hand-edited", "utf-8");
178
+
179
+ const result = await run({ force: true });
180
+ expect(result.success).toBe(true);
181
+ expect(readFileSync(join(out, "main.ts"), "utf-8")).toContain("export const userClaude");
182
+ });
183
+
184
+ test("creates the output directory when it does not exist", async () => {
185
+ seedConfig();
186
+ const nested = join(out, "deep", "nested");
187
+ const result = await run({ output: nested });
188
+ expect(result.success).toBe(true);
189
+ expect(existsSync(join(nested, "main.ts"))).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe("scope and runtime filtering", () => {
194
+ test("passes the scope filter through to the scan", async () => {
195
+ seedConfig();
196
+ // user scope holds the config; restricting to project finds nothing.
197
+ const result = await run({ scopes: ["project"] });
198
+ expect(result.success).toBe(false);
199
+ expect(result.error).toContain("No agent configuration found");
200
+ });
201
+
202
+ test("passes the runtime filter through to the scan", async () => {
203
+ seedConfig();
204
+ const result = await run({ runtimes: ["codex"] });
205
+ expect(result.success).toBe(false);
206
+ expect(result.error).toContain("No agent configuration found");
207
+ });
208
+ });