@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
@@ -4,193 +4,48 @@
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_CARVE_TYPES,
23
- AWS_LEXICON_IMPORT,
24
- awsCarveType,
25
- applyAwsMapper,
26
- applyAwsFold,
27
- unmappedFoldAttrs,
28
- } from "./aws-resources";
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).
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`.
39
24
  */
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
- /** Is this Terraform type adoptable from state (has a native constructor)? */
74
25
  export function canAdoptFromState(tfType: string): boolean {
75
- return awsCarveType(tfType) !== undefined;
26
+ return canCarveEmit(tfType);
76
27
  }
77
28
 
78
29
  /** Terraform types that can currently be adopted from state, for user-facing hints. */
79
30
  export function supportedStateAdoptionTypes(): string[] {
80
- return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
81
- }
82
-
83
- /** Marker for a prop whose value is a `params.<name>` reference, not a literal. */
84
- class ParamRef {
85
- constructor(
86
- readonly name: string,
87
- readonly type: "string" | "number" | "boolean",
88
- ) {}
31
+ return carveEmitTypes();
89
32
  }
90
33
 
91
34
  /**
92
- * Render chant source for a state-adopted resource. Emits the native
93
- * constructor with mapped properties, plus a reference comment listing the
94
- * Terraform attributes that were not mapped so nothing is silently dropped.
95
- *
96
- * A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
97
- * reference (a real chant build parameter) instead of the state literal —
98
- * the value came from a survivor, so it stays overridable per build.
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}.
99
40
  *
100
- * `folded` carries the carve set's sub-resources (`aws_s3_bucket_versioning`
101
- * and friends), read from the same state file. Their mappable attributes join
102
- * the parent's props (#1637) — a fold that only announced itself and left the
103
- * emitted resource without the versioning or public-access block the Terraform
104
- * declared was a silent loss of configuration. A sub-resource's setting wins
105
- * over the parent's own legacy in-state block: it is the one the config
106
- * 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.
107
43
  */
108
44
  export function adoptFromState(
109
45
  resource: StateResource,
110
46
  params: DeferredParam[] = [],
111
47
  folded: StateResource[] = [],
112
48
  ): AdoptedSource | null {
113
- const entry = awsCarveType(resource.type);
114
- if (!entry) return null;
115
-
116
- const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
117
-
118
- const contributions: FoldedContribution[] = [];
119
- const foldedUnmapped: Record<string, Record<string, unknown>> = {};
120
- for (const sub of folded) {
121
- const address = `${sub.type}.${sub.name}`;
122
- const fold = applyAwsFold(sub.type, sub.attributes);
123
- // No fold mapping for this sub-resource type: it still carves with the
124
- // parent, so report its attributes rather than dropping them on the floor.
125
- const rest = fold ? fold.unmapped : unmappedFoldAttrs(sub.attributes);
126
- if (fold) Object.assign(props, fold.props);
127
- if (Object.keys(rest).length) foldedUnmapped[address] = rest;
128
- contributions.push({ address, props: Object.keys(fold?.props ?? {}) });
129
- }
130
-
131
- // Substitute deferred inputs: only plain (untransformed) field mappings can
132
- // carry a parameter reference — a transform ran against the literal at emit
133
- // time and cannot re-run at build. Everything else keeps the state literal.
134
- const parameterized: string[] = [];
135
- for (const param of params) {
136
- const spec = entry.fields[param.tfAttr];
137
- if (typeof spec !== "string" || !(spec in props)) continue;
138
- const type = typeof param.default === "number" ? "number" : typeof param.default === "boolean" ? "boolean" : "string";
139
- props[spec] = new ParamRef(param.name, type);
140
- parameterized.push(param.name);
141
- }
142
-
143
- const unmapped: Record<string, unknown> = {};
144
- for (const [k, v] of Object.entries(resource.attributes)) {
145
- if (!mappedKeys.includes(k)) unmapped[k] = v;
146
- }
147
- // A folded sub-resource's leftovers are keyed by its address, so the comment
148
- // says which block a stray attribute came from.
149
- for (const [address, attrs] of Object.entries(foldedUnmapped)) unmapped[address] = attrs;
150
-
151
- const L: string[] = [];
152
- L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
153
- L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
154
- for (const c of contributions) {
155
- const into = c.props.length ? c.props.join(", ") : "nothing mappable — see the reference comment below";
156
- L.push(`// Folded in ${c.address} -> ${into}`);
157
- }
158
- L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
159
- if (parameterized.length) {
160
- L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
161
- L.push(`import { params } from "${PARAMS_IMPORT}";`);
162
- }
163
- L.push("");
164
- L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
165
- if (Object.keys(unmapped).length) {
166
- L.push("");
167
- L.push("/* Unmapped Terraform attributes (reconcile to native props before building):");
168
- L.push(JSON.stringify(unmapped, null, 2));
169
- L.push("*/");
170
- }
171
-
172
- return {
173
- fileName: `${resource.name}.ts`,
174
- content: L.join("\n") + "\n",
175
- mapped: Object.keys(props).length > 0,
176
- nativeType: entry.nativeType,
177
- parameterized,
178
- folded: contributions,
179
- };
180
- }
181
-
182
- /** Render a JS object literal with stable key order and given indent. */
183
- function renderObject(obj: Record<string, unknown>, indent: number): string {
184
- const keys = Object.keys(obj);
185
- if (keys.length === 0) return "{}";
186
- const pad = " ".repeat(indent);
187
- const body = keys
188
- .map((k) => `${pad}${k}: ${renderValue(obj[k])},`)
189
- .join("\n");
190
- return `{\n${body}\n}`;
191
- }
192
-
193
- /** A prop value: a `params.<name>` reference (cast keeps the project tsc-clean) or a literal. */
194
- function renderValue(value: unknown): string {
195
- 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;
196
51
  }
@@ -1,18 +1,42 @@
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 } 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
+ test("the emit gate is narrower than the tier map, which also ranks k8s (#2015)", () => {
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();
21
+ expect(canCarveEmit("kubernetes_config_map")).toBe(false);
22
+ expect(canCarveEmit("kubernetes_manifest")).toBe(true);
23
+ expect(canCarveEmit("random_pet")).toBe(false);
24
+ expect(canCarveEmit("aws_s3_bucket")).toBe(true);
25
+ expect(carveEmitTypes()).toEqual([...AWS_CARVE_TYPES.map((t) => t.tfType), "kubernetes_manifest"].sort());
26
+ // State adoption and live emit gate on the same table.
27
+ for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
28
+ });
29
+
30
+ test("canBridge rejects a dotted identity attribute (#2015)", () => {
31
+ // A data source body is flat `attr = value`; a dotted path is not HCL.
32
+ expect(identityAttrOf("kubernetes_manifest")).toContain(".");
33
+ expect(canBridge("kubernetes_manifest")).toBe(false);
34
+ expect(canBridge("aws_s3_bucket")).toBe(true);
35
+ // No identity entry at all is fine — the bridge writes a TODO comment.
36
+ expect(canBridge("random_pet")).toBe(true);
37
+ for (const t of AWS_CARVE_TYPES) expect(canBridge(t.tfType)).toBe(true);
38
+ });
39
+
16
40
  test("covers the common carve targets across families", () => {
17
41
  const types = new Set(AWS_CARVE_TYPES.map((t) => t.tfType));
18
42
  for (const t of [
@@ -103,7 +127,7 @@ describe("applyAwsMapper", () => {
103
127
  describe("folded sub-resource mappers (#1637)", () => {
104
128
  test("every fold mapper is for a type that actually folds", () => {
105
129
  for (const tfType of Object.keys(AWS_FOLD_MAPPERS)) {
106
- expect(FOLDS_INTO[tfType]).toBeDefined();
130
+ expect(foldParentOf(tfType)).toBeDefined();
107
131
  }
108
132
  });
109
133
 
@@ -203,12 +227,27 @@ describe("folded sub-resource mappers (#1637)", () => {
203
227
 
204
228
  describe("tier + identity coverage maps (#998)", () => {
205
229
  test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
206
- expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
207
- for (const t of ["kubernetes_deployment", "kubernetes_config_map", "kubernetes_service", "kubernetes_namespace"]) {
208
- expect(TIER_MAP[t]).toMatchObject({ tier: 2 });
209
- 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]);
245
+ }
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]+$/);
210
249
  }
211
- expect(TIER_MAP.kubernetes_horizontal_pod_autoscaler_v2).toEqual(TIER_MAP.kubernetes_horizontal_pod_autoscaler);
250
+ expect(tierMap().kubernetes_horizontal_pod_autoscaler_v2).toEqual(tierMap().kubernetes_horizontal_pod_autoscaler);
212
251
  });
213
252
 
214
253
  test("S3 and ECR sub-resources fold into their parent", () => {
@@ -216,17 +255,17 @@ describe("tier + identity coverage maps (#998)", () => {
216
255
  "aws_s3_bucket_website_configuration", "aws_s3_bucket_cors_configuration", "aws_s3_bucket_logging",
217
256
  "aws_s3_bucket_ownership_controls", "aws_s3_bucket_notification",
218
257
  ]) {
219
- expect(FOLDS_INTO[t]).toBe("aws_s3_bucket");
258
+ expect(foldParentOf(t)).toBe("aws_s3_bucket");
220
259
  }
221
- expect(FOLDS_INTO.aws_ecr_lifecycle_policy).toBe("aws_ecr_repository");
222
- 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");
223
262
  });
224
263
 
225
264
  test("identity attrs cover the expanded types, including a dotted non-AWS path", () => {
226
- expect(IDENTITY_ATTR.aws_elasticache_cluster).toBe("cluster_id");
227
- expect(IDENTITY_ATTR.aws_ecs_task_definition).toBe("family");
228
- expect(IDENTITY_ATTR.aws_route53_record).toBe("name");
229
- expect(IDENTITY_ATTR.aws_eks_node_group).toBe("node_group_name");
230
- 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");
231
270
  });
232
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
@@ -51,6 +51,18 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
51
51
  );
52
52
  });
53
53
 
54
+ test("never renders a dotted identity attribute into the data body (#2015)", () => {
55
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
56
+ const dotted = new Map<string, CarvedIdentity>([
57
+ ["aws_s3_bucket.assets", { attr: "manifest.metadata.name", value: "demo-config" }],
58
+ ]);
59
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], dotted);
60
+
61
+ // `manifest.metadata.name = "demo-config"` is not valid HCL.
62
+ expect(plan.dataSources[0].hcl).not.toContain("manifest.metadata.name =");
63
+ expect(plan.dataSources[0].hcl).toContain("# TODO: identify the resource");
64
+ });
65
+
54
66
  test("rewrites survivor references to the data source", () => {
55
67
  const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
56
68
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
@@ -81,8 +81,11 @@ function referenceRegex(address: string): RegExp {
81
81
  }
82
82
 
83
83
  function dataSourceHcl(type: string, name: string, identity: CarvedIdentity): string {
84
+ // A dotted attr is a path into nested blocks; `a.b.c = v` is not valid HCL,
85
+ // so it falls back to the TODO. `carve bridge` refuses such a type outright.
86
+ const flat = identity.attr !== undefined && !identity.attr.includes(".");
84
87
  const body =
85
- identity.attr && identity.value !== undefined
88
+ flat && identity.value !== undefined
86
89
  ? ` ${identity.attr} = ${JSON.stringify(identity.value)}`
87
90
  : ` # TODO: identify the resource (its name/id was interpolated in the source)`;
88
91
  return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${body}\n}`;
@@ -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
+ });