@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
@@ -159,3 +159,134 @@ describe("adoptFromState", () => {
159
159
  expect(adoptFromState({ type: "random_pet", name: "x", attributes: { id: "abc" } })).toBeNull();
160
160
  });
161
161
  });
162
+
163
+ /**
164
+ * `kubernetes_manifest` adoption (#999). The Terraform type names no kind: the
165
+ * target is whatever the manifest body says it is, so these assertions are
166
+ * about reading the body, not about a per-type property mapping.
167
+ */
168
+ describe("adoptFromState — kubernetes_manifest", () => {
169
+ const configMap: StateResource = {
170
+ type: "kubernetes_manifest",
171
+ name: "app_config",
172
+ attributes: {
173
+ manifest: {
174
+ apiVersion: "v1",
175
+ kind: "ConfigMap",
176
+ metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
177
+ data: { LOG_LEVEL: "info" },
178
+ },
179
+ field_manager: null,
180
+ wait: null,
181
+ computed_fields: ["metadata.labels", "metadata.annotations"],
182
+ },
183
+ };
184
+
185
+ test("adopts the manifest body verbatim through the lexicon escape hatch", () => {
186
+ const out = adoptFromState(configMap)!;
187
+ expect(out.fileName).toBe("app_config.ts");
188
+ expect(out.mapped).toBe(true);
189
+ // The kind came out of the body, not out of the Terraform type.
190
+ expect(out.nativeType).toBe("v1 ConfigMap");
191
+
192
+ expect(out.content).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
193
+ expect(out.content).toContain("export const app_config = k8sManifest({");
194
+ expect(out.content).toContain('apiVersion: "v1"');
195
+ expect(out.content).toContain('kind: "ConfigMap"');
196
+ expect(out.content).toContain('namespace: "web"');
197
+ // A key that is not an identifier is quoted, so the emitted source parses.
198
+ expect(out.content).toContain('"app.kubernetes.io/name": "web"');
199
+ expect(out.content).toContain('LOG_LEVEL: "info"');
200
+ // Provider knobs are reported, never smuggled into the object.
201
+ expect(out.content).toContain("Provider-behaviour attributes not part of the object: computed_fields");
202
+ expect(out.content).not.toContain("computed_fields:");
203
+ expect(out.folded).toEqual([]);
204
+ });
205
+
206
+ test("a CRD body needs no per-type mapping — one rule covers every kind", () => {
207
+ const out = adoptFromState({
208
+ type: "kubernetes_manifest",
209
+ name: "cert",
210
+ attributes: {
211
+ manifest: {
212
+ apiVersion: "cert-manager.io/v1",
213
+ kind: "Certificate",
214
+ metadata: { name: "web-tls", namespace: "web" },
215
+ spec: {
216
+ secretName: "web-tls",
217
+ dnsNames: ["web.example.com"],
218
+ issuerRef: { name: "letsencrypt", kind: "ClusterIssuer" },
219
+ },
220
+ },
221
+ },
222
+ })!;
223
+ expect(out.nativeType).toBe("cert-manager.io/v1 Certificate");
224
+ expect(out.content).toContain('apiVersion: "cert-manager.io/v1"');
225
+ expect(out.content).toContain('kind: "Certificate"');
226
+ expect(out.content).toContain('secretName: "web-tls"');
227
+ expect(out.content).toContain('"web.example.com",');
228
+ });
229
+
230
+ test("falls back to the computed object, without the fields the API server owns", () => {
231
+ const out = adoptFromState({
232
+ type: "kubernetes_manifest",
233
+ name: "ns",
234
+ attributes: {
235
+ object: {
236
+ apiVersion: "v1",
237
+ kind: "Namespace",
238
+ metadata: {
239
+ name: "web",
240
+ uid: "8f1e",
241
+ resourceVersion: "4711",
242
+ creationTimestamp: "2026-01-01T00:00:00Z",
243
+ labels: {},
244
+ },
245
+ status: { phase: "Active" },
246
+ },
247
+ },
248
+ })!;
249
+ expect(out.content).toContain('name: "web"');
250
+ expect(out.content).not.toContain("resourceVersion");
251
+ expect(out.content).not.toContain("creationTimestamp");
252
+ expect(out.content).not.toContain("uid");
253
+ expect(out.content).not.toContain("status");
254
+ });
255
+
256
+ test("a deferred input is reported in the source, not substituted", () => {
257
+ // The survivor value sits inside the body, so the boundary report names the
258
+ // enclosing `manifest` attribute and state resolves it to an object —
259
+ // there is no scalar for a `params.<name>` reference to replace.
260
+ const params: DeferredParam[] = [
261
+ { name: "manifest", tfAttr: "manifest", survivor: "aws_eks_cluster.main", attrs: ["endpoint"] },
262
+ ];
263
+ const out = adoptFromState(configMap, params)!;
264
+ expect(out.parameterized).toEqual([]);
265
+ expect(out.content).toContain("Deferred deploy-time input: manifest read aws_eks_cluster.main.endpoint");
266
+ expect(out.content).toContain('declared as build param "manifest" in chant.config.ts');
267
+ expect(out.content).not.toContain("params.");
268
+ });
269
+
270
+ test("a name Terraform allows but TypeScript does not becomes an identifier", () => {
271
+ const out = adoptFromState({
272
+ type: "kubernetes_manifest",
273
+ name: "app-config",
274
+ attributes: { manifest: { apiVersion: "v1", kind: "ConfigMap", metadata: { name: "app-config" } } },
275
+ })!;
276
+ expect(out.fileName).toBe("app_config.ts");
277
+ expect(out.content).toContain("export const app_config = k8sManifest({");
278
+ });
279
+
280
+ test("a body without apiVersion/kind is refused rather than emitted headless", () => {
281
+ expect(
282
+ adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: { manifest: { metadata: { name: "x" } } } }),
283
+ ).toBeNull();
284
+ expect(adoptFromState({ type: "kubernetes_manifest", name: "x", attributes: {} })).toBeNull();
285
+ });
286
+
287
+ test("a typed kubernetes resource is ranked but not adoptable", () => {
288
+ expect(canAdoptFromState("kubernetes_manifest")).toBe(true);
289
+ expect(canAdoptFromState("kubernetes_config_map")).toBe(false);
290
+ expect(adoptFromState({ type: "kubernetes_config_map", name: "c", attributes: {} })).toBeNull();
291
+ });
292
+ });
@@ -4,75 +4,23 @@
4
4
  * a TF resource is created through the provider API, not CloudFormation, so it
5
5
  * is not in any CFN stack — but its resolved attributes ARE in the state file.
6
6
  *
7
- * chant AWS constructors take CloudFormation PascalCase properties (e.g.
8
- * `new Bucket({ BucketName })`), while Terraform state uses the provider's
9
- * snake_case attributes (`bucket`, `tags`). The mapping is driven by the single
10
- * AWS carve-out table (`aws-resources.ts`) — the same table the advisor's tier
11
- * map derives from, so every AWS type advise ranks can be emitted. Attributes
12
- * without a mapping are preserved in a reference comment, never dropped.
13
- *
14
- * Deferred outbound inputs (#998): an attribute whose value the Terraform
15
- * source read from a survivor (`vpc_id = aws_vpc.main.id`) is not a fact of
16
- * the carved resource — it is a deploy-time input. Those props are emitted as
17
- * `params.<name>` references instead of frozen literals, with the state's
18
- * resolved value as the declared default (see `carve-emit.ts`'s scaffold).
7
+ * The mapping itself belongs to the carve provider that owns the Terraform type
8
+ * (`carve-provider.ts`, #2016) — aws maps snake_case provider attributes to
9
+ * CloudFormation PascalCase props over the AWS carve-out table. This module is
10
+ * the dispatch: it resolves the provider and hands off. A new provider adds a
11
+ * file under `providers/`, never an edit here.
19
12
  */
20
13
 
21
- import {
22
- AWS_LEXICON_IMPORT,
23
- awsCarveType,
24
- applyAwsMapper,
25
- applyAwsFold,
26
- unmappedFoldAttrs,
27
- } from "./aws-resources";
28
14
  import { canCarveEmit, carveEmitTypes } from "./tier-map";
15
+ import { resolveEmitProvider, type AdoptedSource, type DeferredParam } from "./carve-provider";
29
16
  import type { StateResource } from "./state";
30
17
 
31
- /** The core subpath the emitted source reads build parameters from. */
32
- export const PARAMS_IMPORT = "@intentius/chant/params";
18
+ export { PARAMS_IMPORT } from "./emit-source";
19
+ export type { AdoptedSource, DeferredParam, FoldedContribution } from "./carve-provider";
33
20
 
34
21
  /**
35
- * One deferred outbound input turned into a build parameter (#998). Derived
36
- * from the boundary report's outbound edges + the state's resolved attributes
37
- * in `carve-emit.ts`; consumed here (source substitution) and by the scaffold
38
- * (`chant.config.ts` `buildParams` declaration).
39
- */
40
- export interface DeferredParam {
41
- /** Build-parameter name, source-referenceable (`params.<name>`). */
42
- name: string;
43
- /** The carved resource's own Terraform attribute the value enters through. */
44
- tfAttr: string;
45
- /** The survivor the Terraform source read, e.g. `aws_vpc.main`. */
46
- survivor: string;
47
- /** Survivor attribute(s) read, e.g. `["id"]`. */
48
- attrs: string[];
49
- /** The state-resolved value — the parameter's declared default. */
50
- default?: string | number | boolean;
51
- }
52
-
53
- /** What one folded sub-resource contributed to the parent's emitted props (#1637). */
54
- export interface FoldedContribution {
55
- /** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
56
- address: string;
57
- /** CFN properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
58
- props: string[];
59
- }
60
-
61
- export interface AdoptedSource {
62
- fileName: string;
63
- content: string;
64
- /** True when at least one attribute was mapped to a native prop. */
65
- mapped: boolean;
66
- nativeType: string;
67
- /** Deferred params actually substituted into the emitted props (#998). */
68
- parameterized: string[];
69
- /** Folded sub-resources and the props each one joined into the parent (#1637). */
70
- folded: FoldedContribution[];
71
- }
72
-
73
- /**
74
- * Is this Terraform type adoptable from state (has a native constructor)? The
75
- * same table gates the live path, so both are the shared `canCarveEmit`.
22
+ * Is this Terraform type adoptable from state (a provider emits it)? The same
23
+ * gate serves the live path, so both are the shared `canCarveEmit`.
76
24
  */
77
25
  export function canAdoptFromState(tfType: string): boolean {
78
26
  return canCarveEmit(tfType);
@@ -83,117 +31,21 @@ export function supportedStateAdoptionTypes(): string[] {
83
31
  return carveEmitTypes();
84
32
  }
85
33
 
86
- /** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
87
- class ParamRef {
88
- constructor(
89
- readonly name: string,
90
- readonly type: "string" | "number" | "boolean",
91
- ) {}
92
- }
93
-
94
34
  /**
95
- * Render chant source for a state-adopted resource. Emits the native
96
- * constructor with mapped properties, plus a reference comment listing the
97
- * Terraform attributes that were not mapped so nothing is silently dropped.
35
+ * Render chant source for a state-adopted resource, through the provider that
36
+ * owns its Terraform type. `params` carries the deferred outbound inputs the
37
+ * boundary report found (#998) and `folded` the carve set's sub-resources read
38
+ * from the same state file (#1637); what a provider does with them is its own
39
+ * business, and both are reported back on the {@link AdoptedSource}.
98
40
  *
99
- * A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
100
- * reference (a real chant build parameter) instead of the state literal —
101
- * the value came from a survivor, so it stays overridable per build.
102
- *
103
- * `folded` carries the carve set's sub-resources (`aws_s3_bucket_versioning`
104
- * and friends), read from the same state file. Their mappable attributes join
105
- * the parent's props (#1637) — a fold that only announced itself and left the
106
- * emitted resource without the versioning or public-access block the Terraform
107
- * declared was a silent loss of configuration. A sub-resource's setting wins
108
- * over the parent's own legacy in-state block: it is the one the config
109
- * actually declares.
41
+ * Null means no registered provider emits this type — the caller has already
42
+ * refused it at the emit gate, so this is the belt-and-braces path.
110
43
  */
111
44
  export function adoptFromState(
112
45
  resource: StateResource,
113
46
  params: DeferredParam[] = [],
114
47
  folded: StateResource[] = [],
115
48
  ): AdoptedSource | null {
116
- const entry = awsCarveType(resource.type);
117
- if (!entry) return null;
118
-
119
- const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
120
-
121
- const contributions: FoldedContribution[] = [];
122
- const foldedUnmapped: Record<string, Record<string, unknown>> = {};
123
- for (const sub of folded) {
124
- const address = `${sub.type}.${sub.name}`;
125
- const fold = applyAwsFold(sub.type, sub.attributes);
126
- // No fold mapping for this sub-resource type: it still carves with the
127
- // parent, so report its attributes rather than dropping them on the floor.
128
- const rest = fold ? fold.unmapped : unmappedFoldAttrs(sub.attributes);
129
- if (fold) Object.assign(props, fold.props);
130
- if (Object.keys(rest).length) foldedUnmapped[address] = rest;
131
- contributions.push({ address, props: Object.keys(fold?.props ?? {}) });
132
- }
133
-
134
- // Substitute deferred inputs: only plain (untransformed) field mappings can
135
- // carry a parameter reference — a transform ran against the literal at emit
136
- // time and cannot re-run at build. Everything else keeps the state literal.
137
- const parameterized: string[] = [];
138
- for (const param of params) {
139
- const spec = entry.fields[param.tfAttr];
140
- if (typeof spec !== "string" || !(spec in props)) continue;
141
- const type = typeof param.default === "number" ? "number" : typeof param.default === "boolean" ? "boolean" : "string";
142
- props[spec] = new ParamRef(param.name, type);
143
- parameterized.push(param.name);
144
- }
145
-
146
- const unmapped: Record<string, unknown> = {};
147
- for (const [k, v] of Object.entries(resource.attributes)) {
148
- if (!mappedKeys.includes(k)) unmapped[k] = v;
149
- }
150
- // A folded sub-resource's leftovers are keyed by its address, so the comment
151
- // says which block a stray attribute came from.
152
- for (const [address, attrs] of Object.entries(foldedUnmapped)) unmapped[address] = attrs;
153
-
154
- const L: string[] = [];
155
- L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
156
- L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
157
- for (const c of contributions) {
158
- const into = c.props.length ? c.props.join(", ") : "nothing mappable — see the reference comment below";
159
- L.push(`// Folded in ${c.address} -> ${into}`);
160
- }
161
- L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
162
- if (parameterized.length) {
163
- L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
164
- L.push(`import { params } from "${PARAMS_IMPORT}";`);
165
- }
166
- L.push("");
167
- L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
168
- if (Object.keys(unmapped).length) {
169
- L.push("");
170
- L.push("/* Unmapped Terraform attributes (reconcile to native props before building):");
171
- L.push(JSON.stringify(unmapped, null, 2));
172
- L.push("*/");
173
- }
174
-
175
- return {
176
- fileName: `${resource.name}.ts`,
177
- content: L.join("\n") + "\n",
178
- mapped: Object.keys(props).length > 0,
179
- nativeType: entry.nativeType,
180
- parameterized,
181
- folded: contributions,
182
- };
183
- }
184
-
185
- /** Render a JS object literal with stable key order and given indent. */
186
- function renderObject(obj: Record<string, unknown>, indent: number): string {
187
- const keys = Object.keys(obj);
188
- if (keys.length === 0) return "{}";
189
- const pad = " ".repeat(indent);
190
- const body = keys
191
- .map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
192
- .join("\n");
193
- return `{\n${body}\n}`;
194
- }
195
-
196
- /** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
197
- function renderValue(value: unknown): string {
198
- return value instanceof ParamRef ? `params.${value.name} as ${value.type}` : JSON.stringify(value);
49
+ const provider = resolveEmitProvider(resource.type);
50
+ return provider?.adopt?.(resource, params, folded) ?? null;
199
51
  }
@@ -1,34 +1,35 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
3
- import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
3
+ import { tierMap, foldParentOf, identityAttrOf, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
4
4
  import { canAdoptFromState } from "./adopt-state";
5
5
 
6
6
  describe("AWS carve-out table", () => {
7
7
  test("advise and emit cover exactly the same AWS types (no cliff)", () => {
8
8
  // Every AWS type the advisor ranks (tier map) must be emittable, and vice
9
9
  // versa. Non-AWS entries (kubernetes_manifest) are excluded.
10
- const tierAws = Object.keys(TIER_MAP).filter((t) => t.startsWith("aws_")).sort();
10
+ const tierAws = Object.keys(tierMap()).filter((t) => t.startsWith("aws_")).sort();
11
11
  const emitAws = AWS_CARVE_TYPES.map((t) => t.tfType).sort();
12
12
  expect(tierAws).toEqual(emitAws);
13
13
  for (const t of emitAws) expect(canAdoptFromState(t)).toBe(true);
14
14
  });
15
15
 
16
16
  test("the emit gate is narrower than the tier map, which also ranks k8s (#2015)", () => {
17
- // `carve advise` bands kubernetes types; emit has no path for them, so the
18
- // gate both emit paths share must refuse them.
19
- expect(TIER_MAP.kubernetes_config_map).toBeDefined();
17
+ // `carve advise` bands every typed kubernetes resource; emit has a path for
18
+ // kubernetes_manifest only (#999), so the gate both emit paths share must
19
+ // refuse the rest.
20
+ expect(tierMap().kubernetes_config_map).toBeDefined();
20
21
  expect(canCarveEmit("kubernetes_config_map")).toBe(false);
21
- expect(canCarveEmit("kubernetes_manifest")).toBe(false);
22
+ expect(canCarveEmit("kubernetes_manifest")).toBe(true);
22
23
  expect(canCarveEmit("random_pet")).toBe(false);
23
24
  expect(canCarveEmit("aws_s3_bucket")).toBe(true);
24
- expect(carveEmitTypes()).toEqual(AWS_CARVE_TYPES.map((t) => t.tfType).sort());
25
+ expect(carveEmitTypes()).toEqual([...AWS_CARVE_TYPES.map((t) => t.tfType), "kubernetes_manifest"].sort());
25
26
  // State adoption and live emit gate on the same table.
26
27
  for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
27
28
  });
28
29
 
29
30
  test("canBridge rejects a dotted identity attribute (#2015)", () => {
30
31
  // A data source body is flat `attr = value`; a dotted path is not HCL.
31
- expect(IDENTITY_ATTR.kubernetes_manifest).toContain(".");
32
+ expect(identityAttrOf("kubernetes_manifest")).toContain(".");
32
33
  expect(canBridge("kubernetes_manifest")).toBe(false);
33
34
  expect(canBridge("aws_s3_bucket")).toBe(true);
34
35
  // No identity entry at all is fine — the bridge writes a TODO comment.
@@ -126,7 +127,7 @@ describe("applyAwsMapper", () => {
126
127
  describe("folded sub-resource mappers (#1637)", () => {
127
128
  test("every fold mapper is for a type that actually folds", () => {
128
129
  for (const tfType of Object.keys(AWS_FOLD_MAPPERS)) {
129
- expect(FOLDS_INTO[tfType]).toBeDefined();
130
+ expect(foldParentOf(tfType)).toBeDefined();
130
131
  }
131
132
  });
132
133
 
@@ -226,12 +227,27 @@ describe("folded sub-resource mappers (#1637)", () => {
226
227
 
227
228
  describe("tier + identity coverage maps (#998)", () => {
228
229
  test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
229
- expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
230
- for (const t of ["kubernetes_deployment", "kubernetes_config_map", "kubernetes_service", "kubernetes_namespace"]) {
231
- expect(TIER_MAP[t]).toMatchObject({ tier: 2 });
232
- expect(TIER_MAP[`${t}_v1`]).toEqual(TIER_MAP[t]);
230
+ // kubernetes_manifest has no fixed kind — the body names it — so the family
231
+ // is all `mapsTo` can say. Every other entry is a real three-part entity
232
+ // type from the generated operation surface: `k8s:Namespace` was in no
233
+ // table chant addresses by (#999).
234
+ expect(tierMap().kubernetes_manifest).toEqual({ tier: 1, mapsTo: "K8s::*" });
235
+ for (const [t, entity] of Object.entries({
236
+ kubernetes_deployment: "K8s::Apps::Deployment",
237
+ kubernetes_config_map: "K8s::Core::ConfigMap",
238
+ kubernetes_service: "K8s::Core::Service",
239
+ kubernetes_namespace: "K8s::Core::Namespace",
240
+ kubernetes_priority_class: "K8s::Scheduling::PriorityClass",
241
+ kubernetes_cron_job: "K8s::Batch::CronJob",
242
+ })) {
243
+ expect(tierMap()[t]).toEqual({ tier: 2, mapsTo: entity });
244
+ expect(tierMap()[`${t}_v1`]).toEqual(tierMap()[t]);
233
245
  }
234
- expect(TIER_MAP.kubernetes_horizontal_pod_autoscaler_v2).toEqual(TIER_MAP.kubernetes_horizontal_pod_autoscaler);
246
+ for (const [t, info] of Object.entries(tierMap())) {
247
+ if (!t.startsWith("kubernetes_") || t === "kubernetes_manifest") continue;
248
+ expect(info.mapsTo).toMatch(/^K8s::[A-Za-z]+::[A-Za-z]+$/);
249
+ }
250
+ expect(tierMap().kubernetes_horizontal_pod_autoscaler_v2).toEqual(tierMap().kubernetes_horizontal_pod_autoscaler);
235
251
  });
236
252
 
237
253
  test("S3 and ECR sub-resources fold into their parent", () => {
@@ -239,17 +255,17 @@ describe("tier + identity coverage maps (#998)", () => {
239
255
  "aws_s3_bucket_website_configuration", "aws_s3_bucket_cors_configuration", "aws_s3_bucket_logging",
240
256
  "aws_s3_bucket_ownership_controls", "aws_s3_bucket_notification",
241
257
  ]) {
242
- expect(FOLDS_INTO[t]).toBe("aws_s3_bucket");
258
+ expect(foldParentOf(t)).toBe("aws_s3_bucket");
243
259
  }
244
- expect(FOLDS_INTO.aws_ecr_lifecycle_policy).toBe("aws_ecr_repository");
245
- expect(FOLDS_INTO.aws_ecr_repository_policy).toBe("aws_ecr_repository");
260
+ expect(foldParentOf("aws_ecr_lifecycle_policy")).toBe("aws_ecr_repository");
261
+ expect(foldParentOf("aws_ecr_repository_policy")).toBe("aws_ecr_repository");
246
262
  });
247
263
 
248
264
  test("identity attrs cover the expanded types, including a dotted non-AWS path", () => {
249
- expect(IDENTITY_ATTR.aws_elasticache_cluster).toBe("cluster_id");
250
- expect(IDENTITY_ATTR.aws_ecs_task_definition).toBe("family");
251
- expect(IDENTITY_ATTR.aws_route53_record).toBe("name");
252
- expect(IDENTITY_ATTR.aws_eks_node_group).toBe("node_group_name");
253
- expect(IDENTITY_ATTR.kubernetes_manifest).toBe("manifest.metadata.name");
265
+ expect(identityAttrOf("aws_elasticache_cluster")).toBe("cluster_id");
266
+ expect(identityAttrOf("aws_ecs_task_definition")).toBe("family");
267
+ expect(identityAttrOf("aws_route53_record")).toBe("name");
268
+ expect(identityAttrOf("aws_eks_node_group")).toBe("node_group_name");
269
+ expect(identityAttrOf("kubernetes_manifest")).toBe("manifest.metadata.name");
254
270
  });
255
271
  });
@@ -322,7 +322,7 @@ export function awsCarveType(tfType: string): AwsCarveType | undefined {
322
322
  }
323
323
 
324
324
  /**
325
- * How a folded sub-resource (see `FOLDS_INTO`) joins its parent's emitted
325
+ * How a folded sub-resource (see the provider's `foldsInto`) joins its parent's emitted
326
326
  * properties (#1637). Terraform splits configuration the CloudFormation shape
327
327
  * keeps inside the parent resource, so the fold is not just a carve-set
328
328
  * membership claim: the sub-resource's attributes have to land in the parent's
@@ -0,0 +1,155 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readdirSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import {
5
+ carveEmitLexicons,
6
+ carveProviders,
7
+ registerCarveProvider,
8
+ resolveCarveProvider,
9
+ resolveEmitProvider,
10
+ type CarveProvider,
11
+ } from "./carve-provider";
12
+ import { canBridge, canCarveEmit, carveEmitTypes, foldParentOf, identityAttrOf, resolveTier, tierMap } from "./tier-map";
13
+ import { adoptFromState } from "./adopt-state";
14
+
15
+ const fake: CarveProvider = {
16
+ name: "test-fake",
17
+ tfTypePrefixes: ["fake_"],
18
+ lexicon: "fakelex",
19
+ tiers: { fake_widget: { tier: 1, mapsTo: "fake:Widget" }, fake_gizmo: { tier: 3, mapsTo: "fake:Gizmo" } },
20
+ identityAttrs: { fake_widget: "widget_name", fake_nested: "spec.metadata.name" },
21
+ foldsInto: { fake_widget_setting: "fake_widget" },
22
+ emitTypes: ["fake_widget"],
23
+ adopt: (resource) => ({
24
+ fileName: `${resource.name}.ts`,
25
+ content: `// fake: ${resource.type}.${resource.name}\n`,
26
+ mapped: true,
27
+ nativeType: "fake:Widget",
28
+ parameterized: [],
29
+ folded: [],
30
+ }),
31
+ liveSelectorType: (tfType) => (tfType === "fake_widget" ? "fake:Widget" : undefined),
32
+ };
33
+
34
+ describe("carve provider registry (#2016)", () => {
35
+ test("core ships aws, gcp and kubernetes; aws and kubernetes emit", () => {
36
+ expect(carveProviders().map((p) => p.name)).toEqual(["aws", "gcp", "kubernetes"]);
37
+ expect(carveEmitLexicons()).toEqual(["aws", "k8s"]);
38
+ expect(resolveCarveProvider("aws_s3_bucket")?.name).toBe("aws");
39
+ expect(resolveCarveProvider("google_storage_bucket")?.name).toBe("gcp");
40
+ expect(resolveCarveProvider("kubernetes_config_map")?.name).toBe("kubernetes");
41
+ expect(resolveCarveProvider("random_pet")).toBeUndefined();
42
+ });
43
+
44
+ test("a provider owns every type under its prefix for advise, only its emitTypes for emit", () => {
45
+ // kubernetes ranks every typed provider resource and emits only
46
+ // kubernetes_manifest; aws emits everything it ranks.
47
+ expect(resolveCarveProvider("kubernetes_config_map")?.name).toBe("kubernetes");
48
+ expect(resolveEmitProvider("kubernetes_config_map")).toBeUndefined();
49
+ expect(resolveEmitProvider("kubernetes_manifest")?.name).toBe("kubernetes");
50
+ expect(resolveEmitProvider("aws_s3_bucket")?.name).toBe("aws");
51
+ // A type under aws_ that the carve table does not list is ranked by nobody
52
+ // and emitted by nobody, but still resolves to the aws provider.
53
+ expect(resolveCarveProvider("aws_glue_job")?.name).toBe("aws");
54
+ expect(resolveEmitProvider("aws_glue_job")).toBeUndefined();
55
+ });
56
+
57
+ test("registering a provider widens advise, emit and bridge without touching them", () => {
58
+ expect(resolveTier("fake_widget")).toBeNull();
59
+ const dispose = registerCarveProvider(fake);
60
+ try {
61
+ // Tier map, identity, folds — all derived live from the registry.
62
+ expect(resolveTier("fake_widget")).toEqual({ tier: 1, mapsTo: "fake:Widget" });
63
+ expect(tierMap().fake_gizmo).toEqual({ tier: 3, mapsTo: "fake:Gizmo" });
64
+ expect(identityAttrOf("fake_widget")).toBe("widget_name");
65
+ expect(foldParentOf("fake_widget_setting")).toBe("fake_widget");
66
+ // The bridge gate reads the same identity attrs: a dotted path is refused.
67
+ expect(canBridge("fake_widget")).toBe(true);
68
+ expect(canBridge("fake_nested")).toBe(false);
69
+ // Emit gate + the user-facing type list.
70
+ expect(canCarveEmit("fake_widget")).toBe(true);
71
+ expect(canCarveEmit("fake_gizmo")).toBe(false);
72
+ expect(carveEmitTypes()).toContain("fake_widget");
73
+ expect(carveEmitLexicons()).toEqual(["aws", "fakelex", "k8s"]);
74
+ // adopt-state dispatches to the provider, with no knowledge of it.
75
+ const adopted = adoptFromState({ type: "fake_widget", name: "w", attributes: { widget_name: "w1" } });
76
+ expect(adopted).toMatchObject({ fileName: "w.ts", nativeType: "fake:Widget" });
77
+ } finally {
78
+ dispose();
79
+ }
80
+ // Unregistering leaves the registry exactly as it was found.
81
+ expect(resolveTier("fake_widget")).toBeNull();
82
+ expect(carveEmitTypes()).not.toContain("fake_widget");
83
+ expect(carveEmitLexicons()).toEqual(["aws", "k8s"]);
84
+ expect(adoptFromState({ type: "fake_widget", name: "w", attributes: {} })).toBeNull();
85
+ });
86
+
87
+ test("the AWS entries are untouched by a second provider being present", () => {
88
+ const before = carveEmitTypes().filter((t) => t.startsWith("aws_"));
89
+ const dispose = registerCarveProvider(fake);
90
+ try {
91
+ expect(carveEmitTypes().filter((t) => t.startsWith("aws_"))).toEqual(before);
92
+ expect(resolveTier("aws_s3_bucket")).toEqual({ tier: 1, mapsTo: "AWS::S3::Bucket" });
93
+ expect(resolveEmitProvider("aws_s3_bucket")?.name).toBe("aws");
94
+ } finally {
95
+ dispose();
96
+ }
97
+ });
98
+
99
+ test("the longest prefix wins, so a narrower provider can claim a subset", () => {
100
+ const narrow: CarveProvider = {
101
+ name: "test-narrow",
102
+ tfTypePrefixes: ["fake_widget"],
103
+ lexicon: "narrowlex",
104
+ tiers: { fake_widget: { tier: 2, mapsTo: "narrow:Widget" } },
105
+ };
106
+ const disposeWide = registerCarveProvider(fake);
107
+ const disposeNarrow = registerCarveProvider(narrow);
108
+ try {
109
+ expect(resolveCarveProvider("fake_widget")?.name).toBe("test-narrow");
110
+ expect(resolveCarveProvider("fake_gizmo")?.name).toBe("test-fake");
111
+ } finally {
112
+ disposeNarrow();
113
+ disposeWide();
114
+ }
115
+ });
116
+
117
+ test("emitTypes without an adopter is refused — it would accept on --env and fail on --state", () => {
118
+ expect(() =>
119
+ registerCarveProvider({
120
+ name: "test-no-adopt",
121
+ tfTypePrefixes: ["broken_"],
122
+ lexicon: "brokenlex",
123
+ tiers: {},
124
+ emitTypes: ["broken_thing"],
125
+ }),
126
+ ).toThrow(/emitTypes but no adopt/);
127
+ expect(resolveCarveProvider("broken_thing")).toBeUndefined();
128
+ });
129
+
130
+ test("a provider claiming no prefix is refused — nothing would ever resolve to it", () => {
131
+ expect(() =>
132
+ registerCarveProvider({ name: "test-no-prefix", tfTypePrefixes: [], lexicon: "x", tiers: {} }),
133
+ ).toThrow(/claims no Terraform type prefix/);
134
+ });
135
+
136
+ test("the seam pulls in no plugin loader and no lexicon package, so advise stays free", () => {
137
+ // The reason the registry lives in core rather than behind a LexiconPlugin
138
+ // capability: `carve advise` runs against a foreign Terraform tree with no
139
+ // chant project to name lexicons, and `carve emit --state` is offline.
140
+ // Either would become plugin-dependent if a provider module imported one.
141
+ const root = join(import.meta.dirname, ".");
142
+ const files = [
143
+ ...readdirSync(root).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts")).map((f) => join(root, f)),
144
+ ...readdirSync(join(root, "providers")).map((f) => join(root, "providers", f)),
145
+ ];
146
+ expect(files.length).toBeGreaterThan(10); // not a vacuous pass
147
+ const offenders: string[] = [];
148
+ for (const file of files) {
149
+ for (const [, spec] of readFileSync(file, "utf-8").matchAll(/\bfrom\s+"([^"]+)"/g)) {
150
+ if (spec.includes("chant-lexicon-") || spec.includes("cli/plugins")) offenders.push(`${file}: ${spec}`);
151
+ }
152
+ }
153
+ expect(offenders).toEqual([]);
154
+ });
155
+ });