@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
@@ -0,0 +1,262 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveEmit, formatCarveEmit } from "./carve-emit";
6
+ import { carveBridge } from "./carve-bridge";
7
+ import { loadHcl2json } from "../../terraform/parse";
8
+ import type { ImportResult, LiveImportOptions } from "./import";
9
+ import type { LexiconPlugin } from "../../lexicon";
10
+
11
+ /**
12
+ * `carve emit` over the kubernetes provider (#999), end to end: a Terraform
13
+ * estate that manages Kubernetes objects through `kubernetes_manifest`, carved
14
+ * into a buildable chant project for the k8s lexicon.
15
+ *
16
+ * The point of the fixture is the second resource. A `kubernetes_manifest` has
17
+ * no fixed entity type — the kind is inside the body — so a cert-manager
18
+ * Certificate and a core ConfigMap go through the identical path, which is what
19
+ * "one rule covers every CRD" has to mean in practice.
20
+ */
21
+ let parserAvailable = false;
22
+ try {
23
+ await loadHcl2json();
24
+ parserAvailable = true;
25
+ } catch {
26
+ parserAvailable = false;
27
+ }
28
+
29
+ const ESTATE = `
30
+ resource "kubernetes_namespace" "web" {
31
+ metadata {
32
+ name = "web"
33
+ }
34
+ }
35
+
36
+ resource "kubernetes_manifest" "app_config" {
37
+ manifest = {
38
+ apiVersion = "v1"
39
+ kind = "ConfigMap"
40
+ metadata = {
41
+ name = "app-config"
42
+ namespace = kubernetes_namespace.web.metadata[0].name
43
+ }
44
+ data = {
45
+ LOG_LEVEL = "info"
46
+ }
47
+ }
48
+ }
49
+
50
+ resource "kubernetes_manifest" "web_cert" {
51
+ manifest = {
52
+ apiVersion = "cert-manager.io/v1"
53
+ kind = "Certificate"
54
+ metadata = {
55
+ name = "web-tls"
56
+ namespace = "web"
57
+ }
58
+ spec = {
59
+ secretName = "web-tls"
60
+ dnsNames = ["web.example.com"]
61
+ }
62
+ }
63
+ }
64
+
65
+ resource "kubernetes_config_map" "legacy" {
66
+ metadata {
67
+ name = "legacy"
68
+ namespace = "web"
69
+ }
70
+ data = {
71
+ OLD = "true"
72
+ }
73
+ }
74
+ `;
75
+
76
+ const TFSTATE = JSON.stringify({
77
+ version: 4,
78
+ resources: [
79
+ {
80
+ mode: "managed",
81
+ type: "kubernetes_manifest",
82
+ name: "app_config",
83
+ instances: [
84
+ {
85
+ attributes: {
86
+ manifest: {
87
+ apiVersion: "v1",
88
+ kind: "ConfigMap",
89
+ metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
90
+ data: { LOG_LEVEL: "info" },
91
+ },
92
+ computed_fields: ["metadata.labels", "metadata.annotations"],
93
+ field_manager: null,
94
+ },
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ mode: "managed",
100
+ type: "kubernetes_manifest",
101
+ name: "web_cert",
102
+ instances: [
103
+ {
104
+ attributes: {
105
+ manifest: {
106
+ apiVersion: "cert-manager.io/v1",
107
+ kind: "Certificate",
108
+ metadata: { name: "web-tls", namespace: "web" },
109
+ spec: { secretName: "web-tls", dnsNames: ["web.example.com"] },
110
+ },
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ {
116
+ mode: "managed",
117
+ type: "kubernetes_config_map",
118
+ name: "legacy",
119
+ instances: [{ attributes: { id: "web/legacy", data: { OLD: "true" } } }],
120
+ },
121
+ ],
122
+ });
123
+
124
+ const fakeImport = () =>
125
+ vi.fn(
126
+ async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
127
+ success: true,
128
+ generatedFiles: ["infra/main.ts"],
129
+ warnings: [],
130
+ }),
131
+ );
132
+
133
+ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
134
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-k8s-"));
135
+ try {
136
+ writeFileSync(join(dir, "main.tf"), ESTATE);
137
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
138
+ return await fn(dir);
139
+ } finally {
140
+ rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ }
143
+
144
+ describe("carve emit — kubernetes_manifest (#999)", () => {
145
+ test("carves a ConfigMap manifest into a buildable k8s project", async () => {
146
+ if (!parserAvailable) return;
147
+ await withEstate(async (dir) => {
148
+ const out = join(dir, "carveout");
149
+ const liveImport = fakeImport();
150
+ const res = await carveEmit(
151
+ { from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
152
+ { plugins: [], liveImport },
153
+ );
154
+
155
+ expect(res.ok).toBe(true);
156
+ expect(res.source).toBe("tfstate");
157
+ expect(liveImport).not.toHaveBeenCalled();
158
+
159
+ const source = readFileSync(join(out, "src", "app_config.ts"), "utf-8");
160
+ expect(source).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
161
+ expect(source).toContain("export const app_config = k8sManifest({");
162
+ expect(source).toContain('apiVersion: "v1"');
163
+ expect(source).toContain('kind: "ConfigMap"');
164
+ expect(source).toContain('"app.kubernetes.io/name": "web"');
165
+ expect(source).toContain("-> v1 ConfigMap");
166
+
167
+ // The scaffold targets the provider's lexicon, so the emitted project
168
+ // builds with `npm install && npm run build` as it stands.
169
+ const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
170
+ expect(pkg.scripts.build).toBe("chant build src --lexicon k8s");
171
+ expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-k8s"]);
172
+ expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["k8s"]');
173
+
174
+ // The namespace the manifest read is a survivor, so it is an outbound
175
+ // edge and becomes a declared build parameter.
176
+ expect(res.params?.map((p) => p.name)).toEqual(["manifest"]);
177
+ expect(source).toContain("Deferred deploy-time input: manifest read kubernetes_namespace.web");
178
+ // Nothing is substituted: state resolved `manifest` to the whole object.
179
+ expect(source).not.toContain("params.");
180
+
181
+ expect(res.manifestPath).toBe(join(out, "kubernetes_manifest-app_config.carve.json"));
182
+ expect(formatCarveEmit(res)).toContain("Adopted from Terraform state (offline).");
183
+ });
184
+ });
185
+
186
+ test("a CRD manifest carves through the same path as a core kind", async () => {
187
+ if (!parserAvailable) return;
188
+ await withEstate(async (dir) => {
189
+ const out = join(dir, "carveout");
190
+ const res = await carveEmit(
191
+ { from: dir, select: "kubernetes_manifest.web_cert", statePath: join(dir, "terraform.tfstate"), output: out },
192
+ { plugins: [], liveImport: fakeImport() },
193
+ );
194
+
195
+ expect(res.ok).toBe(true);
196
+ const source = readFileSync(join(out, "src", "web_cert.ts"), "utf-8");
197
+ expect(source).toContain("-> cert-manager.io/v1 Certificate");
198
+ expect(source).toContain('apiVersion: "cert-manager.io/v1"');
199
+ expect(source).toContain('kind: "Certificate"');
200
+ expect(source).toContain('secretName: "web-tls"');
201
+ expect(source).toContain('"web.example.com",');
202
+ // No CRD-specific mapping was needed, so nothing was left behind.
203
+ expect(source).not.toContain("TODO");
204
+ });
205
+ });
206
+
207
+ test("--env refuses: the kind is in the body, so a live export has nothing to filter on", async () => {
208
+ if (!parserAvailable) return;
209
+ await withEstate(async (dir) => {
210
+ const liveImport = fakeImport();
211
+ const res = await carveEmit(
212
+ { from: dir, select: "kubernetes_manifest.app_config", env: "prod" },
213
+ { plugins: [], liveImport },
214
+ );
215
+ expect(res.ok).toBe(false);
216
+ expect(res.error).toContain("no live adoption path");
217
+ expect(res.error).toContain("--state");
218
+ expect(liveImport).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ test("a typed kubernetes resource ranks but is refused identically on both paths", async () => {
223
+ if (!parserAvailable) return;
224
+ await withEstate(async (dir) => {
225
+ const liveImport = fakeImport();
226
+ const live = await carveEmit(
227
+ { from: dir, select: "kubernetes_config_map.legacy", env: "prod" },
228
+ { plugins: [], liveImport },
229
+ );
230
+ const state = await carveEmit(
231
+ { from: dir, select: "kubernetes_config_map.legacy", statePath: join(dir, "terraform.tfstate") },
232
+ { plugins: [], liveImport },
233
+ );
234
+ expect(live.ok).toBe(false);
235
+ expect(state.error).toBe(live.error);
236
+ expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
237
+ expect(live.error).toContain("kubernetes_manifest");
238
+ expect(liveImport).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ test("bridge still refuses the carved manifest, so emit writes no half-bridged estate", async () => {
243
+ if (!parserAvailable) return;
244
+ await withEstate(async (dir) => {
245
+ const out = join(dir, "carveout");
246
+ const emitted = await carveEmit(
247
+ { from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
248
+ { plugins: [], liveImport: fakeImport() },
249
+ );
250
+ expect(emitted.ok).toBe(true);
251
+
252
+ // Emit does not unlock bridge: `manifest.metadata.name` is a path into
253
+ // nested blocks and a data-source body is flat `attr = value`, so the
254
+ // survivors' references cannot be repointed yet.
255
+ const bridged = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
256
+ expect(bridged.ok).toBe(false);
257
+ expect(bridged.error).toContain("cannot be bridged");
258
+ expect(bridged.error).toContain("manifest.metadata.name");
259
+ expect(existsSync(join(out, "kubernetes_manifest-app_config-runbook.md"))).toBe(false);
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,207 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveEmit, formatCarveEmit } from "./carve-emit";
6
+ import { loadHcl2json } from "../../terraform/parse";
7
+ import { registerCarveProvider, type CarveProvider } from "../../terraform/carve-provider";
8
+ import type { ImportResult, LiveImportOptions } from "./import";
9
+ import type { LexiconPlugin } from "../../lexicon";
10
+
11
+ /**
12
+ * The carve provider seam (#2016), driven end to end by a provider core has
13
+ * never heard of. Nothing in `carve-emit.ts` or `adopt-state.ts` mentions
14
+ * `fake_`, `fakelex` or `fake:Widget` — if emit can carry this estate to
15
+ * emitted source, a scaffolded project and a live selector, then adding a real
16
+ * provider (#999 kubernetes, #2017 gcp) is a file under `terraform/providers/`.
17
+ */
18
+ let parserAvailable = false;
19
+ try {
20
+ await loadHcl2json();
21
+ parserAvailable = true;
22
+ } catch {
23
+ parserAvailable = false;
24
+ }
25
+
26
+ const ESTATE = `
27
+ resource "fake_widget" "hero" { widget_name = "hero-widget" }
28
+ resource "fake_widget_setting" "hero" {
29
+ widget = fake_widget.hero.id
30
+ colour = "blue"
31
+ }
32
+ resource "fake_consumer" "reader" { widget = fake_widget.hero.id }
33
+ resource "fake_gizmo" "spare" { size = 3 }
34
+ `;
35
+
36
+ const TFSTATE = JSON.stringify({
37
+ version: 4,
38
+ resources: [
39
+ {
40
+ mode: "managed",
41
+ type: "fake_widget",
42
+ name: "hero",
43
+ instances: [{ attributes: { id: "hero-widget", widget_name: "hero-widget" } }],
44
+ },
45
+ {
46
+ mode: "managed",
47
+ type: "fake_widget_setting",
48
+ name: "hero",
49
+ instances: [{ attributes: { id: "hero-widget", colour: "blue" } }],
50
+ },
51
+ ],
52
+ });
53
+
54
+ /** A provider with an emit path: adopt from state, and a live selector type. */
55
+ const fakeProvider: CarveProvider = {
56
+ name: "test-fake-emit",
57
+ tfTypePrefixes: ["fake_"],
58
+ lexicon: "fakelex",
59
+ tiers: {
60
+ fake_widget: { tier: 1, mapsTo: "fake:Widget" },
61
+ fake_widget_setting: { tier: 2, mapsTo: "fake:WidgetSetting" },
62
+ fake_gizmo: { tier: 2, mapsTo: "fake:Gizmo" },
63
+ },
64
+ identityAttrs: { fake_widget: "widget_name" },
65
+ foldsInto: { fake_widget_setting: "fake_widget" },
66
+ emitTypes: ["fake_widget"],
67
+ adopt: (resource, params, folded) => ({
68
+ fileName: `${resource.name}.widget.ts`,
69
+ content: `// fake provider adopted ${resource.type}.${resource.name}\nexport const ${resource.name} = "${resource.attributes.widget_name}";\n`,
70
+ mapped: true,
71
+ nativeType: "fake:Widget",
72
+ parameterized: params.map((p) => p.name),
73
+ folded: folded.map((f) => ({ address: `${f.type}.${f.name}`, props: ["Colour"] })),
74
+ }),
75
+ liveSelectorType: (tfType) => (tfType === "fake_widget" ? "fake:Widget" : undefined),
76
+ };
77
+
78
+ /** Same provider, state-only: no live adoption path at all. */
79
+ const stateOnlyProvider: CarveProvider = {
80
+ ...fakeProvider,
81
+ name: "test-fake-state-only",
82
+ liveSelectorType: undefined,
83
+ };
84
+
85
+ const fakeImport = () =>
86
+ vi.fn(
87
+ async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
88
+ success: true,
89
+ generatedFiles: ["infra/hero.ts"],
90
+ warnings: [],
91
+ }),
92
+ );
93
+
94
+ let dispose: (() => void) | undefined;
95
+ afterEach(() => {
96
+ dispose?.();
97
+ dispose = undefined;
98
+ });
99
+
100
+ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
101
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-provider-"));
102
+ try {
103
+ writeFileSync(join(dir, "main.tf"), ESTATE);
104
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
105
+ return await fn(dir);
106
+ } finally {
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ describe("carve emit through a registered provider (#2016)", () => {
112
+ test("without a provider the type is unknown to advise and refused by emit", async () => {
113
+ if (!parserAvailable) return;
114
+ await withEstate(async (dir) => {
115
+ const res = await carveEmit(
116
+ { from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate") },
117
+ { plugins: [], liveImport: fakeImport() },
118
+ );
119
+ expect(res.ok).toBe(false);
120
+ expect(res.error).toMatch(/no known native mapping/i);
121
+ });
122
+ });
123
+
124
+ test("--state emits through the provider and scaffolds for its lexicon", async () => {
125
+ if (!parserAvailable) return;
126
+ dispose = registerCarveProvider(fakeProvider);
127
+ await withEstate(async (dir) => {
128
+ const out = join(dir, "carveout");
129
+ const liveImport = fakeImport();
130
+ const res = await carveEmit(
131
+ { from: dir, select: "fake_widget.hero", statePath: join(dir, "terraform.tfstate"), output: out },
132
+ { plugins: [], liveImport },
133
+ );
134
+
135
+ expect(res.ok).toBe(true);
136
+ expect(res.source).toBe("tfstate");
137
+ expect(liveImport).not.toHaveBeenCalled();
138
+
139
+ // The provider named the file and wrote the content; core wrote it out.
140
+ expect(res.emittedFiles).toEqual([join(out, "src", "hero.widget.ts")]);
141
+ expect(readFileSync(res.emittedFiles![0], "utf-8")).toContain("fake provider adopted fake_widget.hero");
142
+
143
+ // The scaffold targets the provider's lexicon, not a hardcoded aws.
144
+ const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
145
+ expect(pkg.scripts.build).toBe("chant build src --lexicon fakelex");
146
+ expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-fakelex"]);
147
+ expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["fakelex"]');
148
+
149
+ // The provider's foldsInto drove the carve set, so the setting folded in.
150
+ expect(res.folded).toEqual([{ address: "fake_widget_setting.hero", props: ["Colour"] }]);
151
+ expect(formatCarveEmit(res)).toContain("fake_widget_setting.hero (Colour)");
152
+ // And its identityAttrs gave the graph the physical name.
153
+ expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["fake_consumer.reader"]);
154
+ });
155
+ });
156
+
157
+ test("--env uses the provider's live selector type and lexicon", async () => {
158
+ if (!parserAvailable) return;
159
+ dispose = registerCarveProvider(fakeProvider);
160
+ await withEstate(async (dir) => {
161
+ const liveImport = fakeImport();
162
+ const res = await carveEmit(
163
+ { from: dir, select: "fake_widget.hero", env: "prod" },
164
+ { plugins: [], liveImport },
165
+ );
166
+ expect(res.ok).toBe(true);
167
+ expect(res.selector).toEqual({ type: "fake:Widget" });
168
+ expect(liveImport.mock.calls[0][1]).toMatchObject({
169
+ environment: "prod",
170
+ selector: { type: "fake:Widget" },
171
+ lexicon: "fakelex",
172
+ });
173
+ expect(formatCarveEmit(res)).toContain("Adopted live as fake:Widget");
174
+ });
175
+ });
176
+
177
+ test("a ranked type the provider does not emit is refused identically on both paths", async () => {
178
+ if (!parserAvailable) return;
179
+ dispose = registerCarveProvider(fakeProvider);
180
+ await withEstate(async (dir) => {
181
+ const liveImport = fakeImport();
182
+ const live = await carveEmit({ from: dir, select: "fake_gizmo.spare", env: "prod" }, { plugins: [], liveImport });
183
+ const state = await carveEmit(
184
+ { from: dir, select: "fake_gizmo.spare", statePath: join(dir, "terraform.tfstate") },
185
+ { plugins: [], liveImport },
186
+ );
187
+ expect(live.ok).toBe(false);
188
+ expect(state.ok).toBe(false);
189
+ expect(live.error).toBe(state.error);
190
+ expect(live.error).toContain("fake_gizmo cannot be emitted yet");
191
+ expect(liveImport).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ test("a state-only provider refuses --env instead of importing the wrong thing", async () => {
196
+ if (!parserAvailable) return;
197
+ dispose = registerCarveProvider(stateOnlyProvider);
198
+ await withEstate(async (dir) => {
199
+ const liveImport = fakeImport();
200
+ const res = await carveEmit({ from: dir, select: "fake_widget.hero", env: "prod" }, { plugins: [], liveImport });
201
+ expect(res.ok).toBe(false);
202
+ expect(res.error).toContain("no live adoption path");
203
+ expect(res.error).toContain("--state");
204
+ expect(liveImport).not.toHaveBeenCalled();
205
+ });
206
+ });
207
+ });
@@ -1,9 +1,10 @@
1
1
  import { describe, test, expect, vi } from "vitest";
2
- import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
5
  import { carveEmit, formatCarveEmit } from "./carve-emit";
6
6
  import { loadHcl2json } from "../../terraform/parse";
7
+ import { listCarveManifests } from "../../terraform/manifest";
7
8
  import type { ImportResult, LiveImportOptions } from "./import";
8
9
  import type { LexiconPlugin } from "../../lexicon";
9
10
 
@@ -35,12 +36,30 @@ resource "aws_lambda_function" "api" {
35
36
  resource "random_pet" "suffix" {
36
37
  length = 2
37
38
  }
39
+ resource "kubernetes_config_map" "demo" {
40
+ metadata { name = "demo-config" }
41
+ }
38
42
  `;
39
43
 
44
+ // A state file for the bucket, so the --state path reaches the same gate the
45
+ // --env path does rather than stopping at "not found in state".
46
+ const TFSTATE = JSON.stringify({
47
+ version: 4,
48
+ resources: [
49
+ {
50
+ mode: "managed",
51
+ type: "kubernetes_config_map",
52
+ name: "demo",
53
+ instances: [{ attributes: { id: "default/demo-config" } }],
54
+ },
55
+ ],
56
+ });
57
+
40
58
  async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
41
59
  const dir = mkdtempSync(join(tmpdir(), "chant-carve-emit-"));
42
60
  try {
43
61
  writeFileSync(join(dir, "main.tf"), ESTATE);
62
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
44
63
  return await fn(dir);
45
64
  } finally {
46
65
  rmSync(dir, { recursive: true, force: true });
@@ -142,6 +161,58 @@ describe("carveEmit — emit + boundary", () => {
142
161
  });
143
162
  });
144
163
 
164
+ test("a failed live import fails the emit and writes no carve manifest (#2015)", async () => {
165
+ if (!parserAvailable) return;
166
+ await withEstate(async (dir) => {
167
+ const out = join(dir, "carveout");
168
+ const li = vi.fn(
169
+ async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
170
+ success: false,
171
+ generatedFiles: [],
172
+ warnings: [],
173
+ error: "no stack in prod exports AWS::S3::Bucket",
174
+ }),
175
+ );
176
+ const res = await carveEmit(
177
+ { from: dir, select: "aws_s3_bucket.assets", env: "prod", output: out },
178
+ { plugins: noPlugins, liveImport: li },
179
+ );
180
+
181
+ expect(res.ok).toBe(false);
182
+ expect(res.error).toContain("no stack in prod exports AWS::S3::Bucket");
183
+ expect(res.manifestPath).toBeUndefined();
184
+ // Bridge/apply compose from the manifest; one written here would make
185
+ // bridge excise a `.tf` block for a resource that was never emitted.
186
+ expect(listCarveManifests(out)).toEqual([]);
187
+ expect(existsSync(out)).toBe(false);
188
+ // No false "Adopted live" line on the failure path.
189
+ expect(formatCarveEmit(res)).not.toContain("Adopted live");
190
+ });
191
+ });
192
+
193
+ test("a type emit cannot carve is refused identically on --state and --env (#2015)", async () => {
194
+ if (!parserAvailable) return;
195
+ await withEstate(async (dir) => {
196
+ const li = fakeImport();
197
+ const live = await carveEmit(
198
+ { from: dir, select: "kubernetes_config_map.demo", env: "prod" },
199
+ { plugins: noPlugins, liveImport: li },
200
+ );
201
+ const state = await carveEmit(
202
+ { from: dir, select: "kubernetes_config_map.demo", statePath: join(dir, "terraform.tfstate") },
203
+ { plugins: noPlugins, liveImport: li },
204
+ );
205
+
206
+ expect(live.ok).toBe(false);
207
+ expect(state.ok).toBe(false);
208
+ expect(live.error).toBe(state.error);
209
+ expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
210
+ // advise ranks it (tier 2), so it gets past resolveTier — the emit gate
211
+ // is what refuses it, before the AWS exporter is ever reached.
212
+ expect(li).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
145
216
  test("--live-name narrows the live selector to a CFN logical id", async () => {
146
217
  if (!parserAvailable) return;
147
218
  await withEstate(async (dir) => {