@intentius/chant 0.58.0 → 0.59.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 (146) hide show
  1. package/dist/audit/core.d.ts +17 -1
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +15 -4
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/cli/commands/audit.d.ts.map +1 -1
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  8. package/dist/cli/commands/lint.d.ts +13 -0
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/operator.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts +0 -16
  13. package/dist/cli/main.d.ts.map +1 -1
  14. package/dist/cli/plugins.d.ts +22 -0
  15. package/dist/cli/plugins.d.ts.map +1 -1
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/cli-support.d.ts +4 -1
  19. package/dist/components/cli-support.d.ts.map +1 -1
  20. package/dist/components/component.d.ts +19 -4
  21. package/dist/components/component.d.ts.map +1 -1
  22. package/dist/components/driver.d.ts +8 -2
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/verbs/run-agent.d.ts +1 -7
  25. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  26. package/dist/detectLexicon.d.ts +13 -0
  27. package/dist/detectLexicon.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +170 -3
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  31. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  32. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +2 -2
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/reconcile.d.ts +62 -2
  36. package/dist/op/activities/reconcile.d.ts.map +1 -1
  37. package/dist/op/builders.d.ts +2 -2
  38. package/dist/op/builders.d.ts.map +1 -1
  39. package/dist/op/change-signal.d.ts +91 -0
  40. package/dist/op/change-signal.d.ts.map +1 -0
  41. package/dist/op/composites/apply-op.d.ts +7 -2
  42. package/dist/op/composites/apply-op.d.ts.map +1 -1
  43. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  44. package/dist/op/gate-name.d.ts +40 -0
  45. package/dist/op/gate-name.d.ts.map +1 -0
  46. package/dist/op/gate-summary.d.ts +43 -0
  47. package/dist/op/gate-summary.d.ts.map +1 -0
  48. package/dist/op/index.d.ts +6 -2
  49. package/dist/op/index.d.ts.map +1 -1
  50. package/dist/op/local-executor.d.ts.map +1 -1
  51. package/dist/op/op-ir.d.ts +2 -1
  52. package/dist/op/op-ir.d.ts.map +1 -1
  53. package/dist/op/operator.d.ts +63 -0
  54. package/dist/op/operator.d.ts.map +1 -1
  55. package/dist/op/types.d.ts +18 -3
  56. package/dist/op/types.d.ts.map +1 -1
  57. package/dist/terraform/bridge.d.ts +26 -7
  58. package/dist/terraform/bridge.d.ts.map +1 -1
  59. package/dist/terraform/carve-provider.d.ts +28 -2
  60. package/dist/terraform/carve-provider.d.ts.map +1 -1
  61. package/dist/terraform/data-source-shape.d.ts +77 -0
  62. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  63. package/dist/terraform/graph.d.ts.map +1 -1
  64. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  65. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  66. package/dist/terraform/tier-map.d.ts +16 -4
  67. package/dist/terraform/tier-map.d.ts.map +1 -1
  68. package/dist/terraform/types.d.ts +8 -0
  69. package/dist/terraform/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/audit/core.ts +25 -3
  72. package/src/audit/discover.ts +122 -14
  73. package/src/cli/commands/audit.test.ts +5 -3
  74. package/src/cli/commands/audit.ts +5 -1
  75. package/src/cli/commands/build.test.ts +39 -1
  76. package/src/cli/commands/build.ts +14 -0
  77. package/src/cli/commands/carve-bridge.test.ts +237 -14
  78. package/src/cli/commands/carve-bridge.ts +21 -17
  79. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  80. package/src/cli/commands/lint.test.ts +146 -1
  81. package/src/cli/commands/lint.ts +82 -7
  82. package/src/cli/handlers/operator.ts +81 -0
  83. package/src/cli/handlers/run.test.ts +132 -3
  84. package/src/cli/handlers/run.ts +87 -3
  85. package/src/cli/main.test.ts +9 -11
  86. package/src/cli/main.ts +5 -27
  87. package/src/cli/plugins.test.ts +68 -2
  88. package/src/cli/plugins.ts +39 -0
  89. package/src/cli/registry.ts +14 -0
  90. package/src/components/README.md +2 -2
  91. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  92. package/src/components/cli-support.test.ts +18 -7
  93. package/src/components/cli-support.ts +8 -3
  94. package/src/components/component-schema.test.ts +18 -2
  95. package/src/components/component.schema.json +17 -4
  96. package/src/components/component.test.ts +2 -2
  97. package/src/components/component.ts +25 -5
  98. package/src/components/config-defaults.test.ts +2 -2
  99. package/src/components/driver.test.ts +20 -1
  100. package/src/components/driver.ts +12 -5
  101. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  102. package/src/components/verbs/run-agent.test.ts +19 -0
  103. package/src/components/verbs/run-agent.ts +1 -7
  104. package/src/detectLexicon.ts +18 -1
  105. package/src/discovery/fold-import.test.ts +1 -1
  106. package/src/fold/foldable-helpers.ts +1 -1
  107. package/src/graph-ops.test.ts +1 -1
  108. package/src/lexicon.ts +177 -3
  109. package/src/lifecycle/gate-ledger.ts +12 -1
  110. package/src/lint/pipeline-change-gate.test.ts +2 -2
  111. package/src/lint/rules/comp/comp.test.ts +26 -0
  112. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  113. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  114. package/src/op/activities/index.ts +2 -2
  115. package/src/op/activities/reconcile.test.ts +82 -2
  116. package/src/op/activities/reconcile.ts +169 -2
  117. package/src/op/builders.ts +3 -3
  118. package/src/op/change-signal.test.ts +117 -0
  119. package/src/op/change-signal.ts +169 -0
  120. package/src/op/composites/apply-op.ts +14 -4
  121. package/src/op/composites/composites.test.ts +17 -4
  122. package/src/op/composites/reconcile-op.ts +7 -4
  123. package/src/op/effect-step.test.ts +3 -3
  124. package/src/op/gate-name.test.ts +65 -0
  125. package/src/op/gate-name.ts +60 -0
  126. package/src/op/gate-summary.ts +84 -0
  127. package/src/op/index.ts +9 -2
  128. package/src/op/local-executor.test.ts +16 -1
  129. package/src/op/local-executor.ts +4 -3
  130. package/src/op/op-ir.test.ts +12 -1
  131. package/src/op/op-ir.ts +5 -3
  132. package/src/op/op-verb-class.test.ts +2 -2
  133. package/src/op/op.test.ts +2 -2
  134. package/src/op/operator.test.ts +368 -0
  135. package/src/op/operator.ts +141 -5
  136. package/src/op/runtimes/local.test.ts +1 -1
  137. package/src/op/types.ts +23 -3
  138. package/src/terraform/aws-resources.test.ts +13 -4
  139. package/src/terraform/bridge.test.ts +22 -9
  140. package/src/terraform/bridge.ts +89 -28
  141. package/src/terraform/carve-provider.ts +38 -2
  142. package/src/terraform/data-source-shape.ts +95 -0
  143. package/src/terraform/graph.ts +35 -7
  144. package/src/terraform/providers/kubernetes.ts +48 -2
  145. package/src/terraform/tier-map.ts +21 -6
  146. package/src/terraform/types.ts +8 -0
@@ -5,6 +5,7 @@ import { tmpdir } from "os";
5
5
  import { join } from "path";
6
6
  import { carveBridge, formatCarveBridge } from "./carve-bridge";
7
7
  import { loadHcl2json } from "../../terraform/parse";
8
+ import { registerCarveProvider } from "../../terraform/carve-provider";
8
9
  import { readCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
9
10
 
10
11
  let parserAvailable = false;
@@ -195,36 +196,258 @@ describe("carveBridge", () => {
195
196
  }
196
197
  });
197
198
 
198
- test("refuses a target whose identity attribute is a dotted path (#2015)", async () => {
199
+ test("refuses a dotted-identity type no provider declares a shape for (#2015, #2034)", async () => {
199
200
  if (!parserAvailable) return;
200
- const dir = mkdtempSync(join(tmpdir(), "chant-bridge-k8s-"));
201
+ // A dotted identity attribute is a path into nested values, and a data body
202
+ // is flat `attr = value` — `spec.metadata.name = "x"` is not valid HCL. The
203
+ // guard now turns on the missing shape, not on the dot, so the message says
204
+ // what would lift it.
205
+ const unregister = registerCarveProvider({
206
+ name: "carve-bridge-test-nested",
207
+ tfTypePrefixes: ["nested_"],
208
+ lexicon: "aws",
209
+ tiers: { nested_widget: { tier: 1, mapsTo: "Test::Widget" } },
210
+ identityAttrs: { nested_widget: "spec.metadata.name" },
211
+ });
212
+ const dir = mkdtempSync(join(tmpdir(), "chant-bridge-nested-"));
201
213
  try {
202
- // `kubernetes_manifest` identifies itself by `manifest.metadata.name`.
203
- // Rendering that into a data body produced `manifest.metadata.name = "x"`,
204
- // which is not valid HCL — and the provider has no manifest data source.
205
214
  writeFileSync(
206
215
  join(dir, "main.tf"),
207
- `resource "kubernetes_manifest" "demo" {\n` +
208
- ` manifest = {\n` +
209
- ` apiVersion = "v1"\n` +
210
- ` kind = "ConfigMap"\n` +
211
- ` metadata = { name = "demo-config" }\n` +
212
- ` }\n` +
213
- `}\n`,
216
+ `resource "nested_widget" "demo" {\n spec {\n metadata {\n name = "demo"\n }\n }\n}\n`,
214
217
  );
215
218
  const out = join(dir, "carveout");
216
- const res = await carveBridge({ from: dir, select: "kubernetes_manifest.demo", output: out });
219
+ const res = await carveBridge({ from: dir, select: "nested_widget.demo", output: out });
217
220
 
218
221
  expect(res.ok).toBe(false);
219
222
  expect(res.error).toContain("cannot be bridged");
220
- expect(res.error).toContain("manifest.metadata.name");
223
+ expect(res.error).toContain("spec.metadata.name");
224
+ expect(res.error).toContain("no carve provider declares a data-source shape for it");
225
+ expect(res.error).toContain("dataSourceShapes");
221
226
  expect(res.plan).toBeUndefined();
222
227
  expect(existsSync(out)).toBe(false);
228
+ } finally {
229
+ unregister();
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ });
235
+
236
+ /**
237
+ * `kubernetes_manifest` reads back through a different type (#2034). The
238
+ * kubernetes provider ships no `kubernetes_manifest` data source — verified
239
+ * against hashicorp/kubernetes v3.2.1 — so the bridge renders
240
+ * `data "kubernetes_resource"` from the manifest's own apiVersion, kind and
241
+ * metadata, and repoints survivors at that data source's attribute path.
242
+ */
243
+ describe("carveBridge — kubernetes_manifest (#2034)", () => {
244
+ const MANIFEST_TF = `resource "kubernetes_manifest" "app_config" {
245
+ manifest = {
246
+ apiVersion = "v1"
247
+ kind = "ConfigMap"
248
+ metadata = {
249
+ name = "app-config"
250
+ namespace = "apps"
251
+ }
252
+ data = {
253
+ LOG_LEVEL = "info"
254
+ }
255
+ }
256
+ }
257
+ `;
258
+ // Three shapes of inbound reference: the issue's own example, one deeper into
259
+ // `object`, and one through `manifest` — the attribute the data source does
260
+ // NOT have, which is why the rewrite maps it rather than only swapping heads.
261
+ const SURVIVOR_TF = `resource "kubernetes_config_map" "mirror" {
262
+ metadata {
263
+ name = kubernetes_manifest.app_config.object.metadata.name
264
+ namespace = "apps"
265
+ }
266
+ data = {
267
+ LOG_LEVEL = kubernetes_manifest.app_config.manifest.data.LOG_LEVEL
268
+ UPSTREAM = kubernetes_manifest.app_config.object.data.LOG_LEVEL
269
+ }
270
+ }
271
+ `;
272
+ const OUTPUTS_TF = `output "app_config_name" {
273
+ value = kubernetes_manifest.app_config.object.metadata.name
274
+ }
275
+ `;
276
+
277
+ async function withManifestEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
278
+ const dir = mkdtempSync(join(tmpdir(), "chant-bridge-manifest-"));
279
+ try {
280
+ writeFileSync(join(dir, "manifest.tf"), MANIFEST_TF);
281
+ writeFileSync(join(dir, "survivor.tf"), SURVIVOR_TF);
282
+ writeFileSync(join(dir, "outputs.tf"), OUTPUTS_TF);
283
+ return await fn(dir);
284
+ } finally {
285
+ rmSync(dir, { recursive: true, force: true });
286
+ }
287
+ }
288
+
289
+ test("bridges to a data \"kubernetes_resource\" block built from the manifest body", async () => {
290
+ if (!parserAvailable) return;
291
+ await withManifestEstate(async (dir) => {
292
+ const out = join(dir, "carveout");
293
+ const res = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
294
+ expect(res.ok).toBe(true);
295
+
296
+ // The data source is the provider's generic read, not the carved type.
297
+ expect(res.plan!.dataSources).toHaveLength(1);
298
+ expect(res.plan!.dataSources[0]).toMatchObject({
299
+ address: "kubernetes_manifest.app_config",
300
+ type: "kubernetes_resource",
301
+ name: "app_config",
302
+ });
303
+ expect(res.plan!.dataSources[0].hcl).toBe(
304
+ `data "kubernetes_resource" "app_config" {\n` +
305
+ ` api_version = "v1"\n` +
306
+ ` kind = "ConfigMap"\n` +
307
+ `\n` +
308
+ ` metadata {\n` +
309
+ ` name = "app-config"\n` +
310
+ ` namespace = "apps"\n` +
311
+ ` }\n` +
312
+ `}`,
313
+ );
314
+ expect(formatCarveBridge(res)).toContain(
315
+ "data.kubernetes_resource.app_config (was kubernetes_manifest.app_config)",
316
+ );
317
+ });
318
+ });
319
+
320
+ test("the emitted data source parses as HCL and is terraform-fmt clean", async () => {
321
+ if (!parserAvailable) return;
322
+ await withManifestEstate(async (dir) => {
323
+ const out = join(dir, "carveout");
324
+ await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
325
+ const dsPath = join(out, "kubernetes_manifest-app_config-datasources.tf");
326
+ const hcl = readFileSync(dsPath, "utf-8");
327
+
328
+ // The repo's own parser: `metadata` has to come out as a block, since the
329
+ // provider declares it Block List (Min 1, Max 1), not an attribute.
330
+ const parsed = await (await loadHcl2json()).parse("datasources.tf", hcl);
331
+ const ds = (parsed.data as Record<string, Record<string, unknown[]>>).kubernetes_resource.app_config[0] as Record<
332
+ string,
333
+ unknown
334
+ >;
335
+ expect(ds.api_version).toBe("v1");
336
+ expect(ds.kind).toBe("ConfigMap");
337
+ expect(ds.metadata).toEqual([{ name: "app-config", namespace: "apps" }]);
338
+
339
+ // And Terraform's own formatter accepts it byte for byte.
340
+ try {
341
+ execFileSync("terraform", ["fmt", "-check", "-diff", dsPath], { stdio: "pipe" });
342
+ } catch (err) {
343
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // no terraform on this machine
344
+ throw err;
345
+ }
346
+ });
347
+ });
348
+
349
+ test("survivor references follow the data source's own attribute path", async () => {
350
+ if (!parserAvailable) return;
351
+ await withManifestEstate(async (dir) => {
352
+ const out = join(dir, "carveout");
353
+ const res = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
354
+ expect(res.ok).toBe(true);
355
+
356
+ const survivor = readFileSync(join(out, "survivor.tf"), "utf-8");
357
+ // The issue's example, and a reference that goes deeper into `object`.
358
+ expect(survivor).toContain("name = data.kubernetes_resource.app_config.object.metadata.name");
359
+ expect(survivor).toContain("UPSTREAM = data.kubernetes_resource.app_config.object.data.LOG_LEVEL");
360
+ // `manifest` is an attribute of the resource that the data source does not
361
+ // have; left alone it would be an "Unsupported attribute" at validate.
362
+ expect(survivor).toContain("LOG_LEVEL = data.kubernetes_resource.app_config.object.data.LOG_LEVEL");
363
+ expect(survivor).not.toContain(".manifest.");
364
+ expect(survivor).not.toMatch(/(?<!data\.)\bkubernetes_manifest\.app_config\b/);
365
+
366
+ // An output reading the manifest is repointed the same way (#1638).
367
+ expect(res.plan!.outputRewrites).toEqual(["output.app_config_name"]);
368
+ expect(readFileSync(join(out, "outputs.tf"), "utf-8")).toContain(
369
+ "value = data.kubernetes_resource.app_config.object.metadata.name",
370
+ );
371
+
372
+ // The carved block leaves the survivor source, as for any other type.
373
+ expect(res.plan!.excised).toEqual(["kubernetes_manifest.app_config"]);
374
+ expect(readFileSync(join(out, "manifest.tf"), "utf-8").trim()).toBe("");
375
+ });
376
+ });
377
+
378
+ test("a namespace-less manifest omits the optional argument", async () => {
379
+ if (!parserAvailable) return;
380
+ const dir = mkdtempSync(join(tmpdir(), "chant-bridge-clusterwide-"));
381
+ try {
382
+ // A cluster-scoped kind has no namespace, and `metadata.namespace` is
383
+ // optional on the data source — so it is left out, not emitted empty.
384
+ writeFileSync(
385
+ join(dir, "main.tf"),
386
+ `resource "kubernetes_manifest" "reader" {\n` +
387
+ ` manifest = {\n` +
388
+ ` apiVersion = "rbac.authorization.k8s.io/v1"\n` +
389
+ ` kind = "ClusterRole"\n` +
390
+ ` metadata = {\n` +
391
+ ` name = "reader"\n` +
392
+ ` }\n` +
393
+ ` }\n` +
394
+ `}\n`,
395
+ );
396
+ writeFileSync(
397
+ join(dir, "outputs.tf"),
398
+ `output "reader" {\n value = kubernetes_manifest.reader.object.metadata.name\n}\n`,
399
+ );
400
+ const out = join(dir, "carveout");
401
+ const res = await carveBridge({ from: dir, select: "kubernetes_manifest.reader", output: out });
402
+ expect(res.ok).toBe(true);
403
+ const hcl = res.plan!.dataSources[0].hcl;
404
+ expect(hcl).toContain('api_version = "rbac.authorization.k8s.io/v1"');
405
+ expect(hcl).toContain('name = "reader"');
406
+ expect(hcl).not.toContain("namespace");
407
+ } finally {
408
+ rmSync(dir, { recursive: true, force: true });
409
+ }
410
+ });
411
+
412
+ test("an interpolated manifest name falls back to the TODO body, never a half-written block", async () => {
413
+ if (!parserAvailable) return;
414
+ const dir = mkdtempSync(join(tmpdir(), "chant-bridge-interp-"));
415
+ try {
416
+ writeFileSync(
417
+ join(dir, "main.tf"),
418
+ `variable "suffix" {\n type = string\n}\n\n` +
419
+ `resource "kubernetes_manifest" "app_config" {\n` +
420
+ ` manifest = {\n` +
421
+ ` apiVersion = "v1"\n` +
422
+ ` kind = "ConfigMap"\n` +
423
+ ` metadata = {\n` +
424
+ ` name = "app-\${var.suffix}"\n` +
425
+ ` }\n` +
426
+ ` }\n` +
427
+ `}\n`,
428
+ );
429
+ writeFileSync(
430
+ join(dir, "outputs.tf"),
431
+ `output "name" {\n value = kubernetes_manifest.app_config.object.metadata.name\n}\n`,
432
+ );
433
+ const out = join(dir, "carveout");
434
+ const res = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
435
+ expect(res.ok).toBe(true);
436
+ // `metadata.name` is required on the data source; guessing it would be
437
+ // worse than saying so.
438
+ expect(res.plan!.dataSources[0].hcl).toContain("# TODO: identify the resource");
439
+ expect(res.plan!.dataSources[0].hcl).not.toContain("api_version");
440
+ // The reference is still repointed, so the survivor plan is one edit away.
441
+ expect(readFileSync(join(out, "outputs.tf"), "utf-8")).toContain(
442
+ "value = data.kubernetes_resource.app_config.object.metadata.name",
443
+ );
223
444
  } finally {
224
445
  rmSync(dir, { recursive: true, force: true });
225
446
  }
226
447
  });
448
+ });
227
449
 
450
+ describe("carveBridge — formatting", () => {
228
451
  test("formatCarveBridge summarizes data sources, rewires, and safety", async () => {
229
452
  if (!parserAvailable) return;
230
453
  await withEstate(async (dir) => {
@@ -14,8 +14,8 @@ import { join, basename, relative, resolve } from "path";
14
14
  import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
15
15
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
16
16
  import { boundaryReport, type CarveReport } from "../../terraform/carve";
17
- import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
18
- import { identityAttrOf, canBridge } from "../../terraform/tier-map";
17
+ import { generateBridge, type BridgePlan, type CarvedDataSource } from "../../terraform/bridge";
18
+ import { identityAttrOf, canBridge, dataSourceShapeOf } from "../../terraform/tier-map";
19
19
  import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
20
20
  import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
21
21
 
@@ -76,13 +76,13 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
76
76
  if (!found) return { ok: false, error: `${select} not found in ${opts.from}` };
77
77
  report = found;
78
78
 
79
- // Refuse a carve set whose data source cannot be written as valid HCL: a
80
- // dotted identity attribute is a path into nested blocks, and a data body
81
- // is flat `attr = value`. `kubernetes_manifest` is the standing case — the
82
- // provider has no `kubernetes_manifest` data source either; its generic
83
- // read is `kubernetes_resource`, a different shape. Emit adopting the type
84
- // (#999) does not lift this: reading a manifest out of state and reading it
85
- // back through a data source are separate problems (#2034).
79
+ // Refuse a carve set no provider says how to read back. A dotted identity
80
+ // attribute is a path into nested values and a data body is flat
81
+ // `attr = value`, so such a type bridges only once its provider declares a
82
+ // data-source shape — the data type, its arguments and where each comes
83
+ // from in the carved body (#2034). `kubernetes_manifest` declares one now:
84
+ // `data "kubernetes_resource"`, built from the manifest's apiVersion,
85
+ // kind and metadata.
86
86
  const carvedTypes = report.carveSet.map((m) => m.type).filter((t): t is string => t !== undefined);
87
87
  const unbridgeable = [...new Set(carvedTypes.filter((t) => !canBridge(t)))];
88
88
  if (unbridgeable.length) {
@@ -91,21 +91,25 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
91
91
  error:
92
92
  `${select} cannot be bridged: ${unbridgeable
93
93
  .map((t) => `${t} identifies itself by \`${identityAttrOf(t)}\``)
94
- .join(", ")}, a path into nested blocks that a Terraform data source body cannot express. ` +
95
- `Bridging these types needs a data-source mapping — see chant issue #2034.`,
94
+ .join(", ")}, a path into nested values that a flat Terraform data source body cannot express, ` +
95
+ `and no carve provider declares a data-source shape for it. A provider contributes one through ` +
96
+ `\`dataSourceShapes\` (see \`kubernetes_manifest\` reading back as \`data "kubernetes_resource"\`, chant issue #2034).`,
96
97
  };
97
98
  }
98
99
 
99
- // Physical identities for the carved resources, for the data sources.
100
- const identities = new Map<string, CarvedIdentity>();
100
+ // How each carved resource is read back, and the literals to render the
101
+ // data-source body from. The shape comes from the provider, the values from
102
+ // the parsed block (`dataSourceValues`, a superset of the old identity).
103
+ const readBacks = new Map<string, CarvedDataSource>();
101
104
  for (const node of graph.nodes) {
102
- if (node.type && node.identity) {
103
- identities.set(node.address, { attr: identityAttrOf(node.type), value: node.identity });
104
- }
105
+ if (!node.type) continue;
106
+ const shape = dataSourceShapeOf(node.type);
107
+ if (!shape) continue;
108
+ readBacks.set(node.address, { shape, values: node.dataSourceValues ?? {} });
105
109
  }
106
110
 
107
111
  const files = listTfFiles(opts.from).map((path) => ({ path, content: readFileSync(path, "utf-8") }));
108
- plan = generateBridge(report, files, identities);
112
+ plan = generateBridge(report, files, readBacks);
109
113
  } catch (err) {
110
114
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
111
115
  return { ok: false, error: `Failed to build the bridge: ${err instanceof Error ? err.message : String(err)}` };
@@ -239,24 +239,31 @@ describe("carve emit — kubernetes_manifest (#999)", () => {
239
239
  });
240
240
  });
241
241
 
242
- test("bridge still refuses the carved manifest, so emit writes no half-bridged estate", async () => {
242
+ test("emit then bridge compose on the same manifest (#2034)", async () => {
243
243
  if (!parserAvailable) return;
244
244
  await withEstate(async (dir) => {
245
245
  const out = join(dir, "carveout");
246
246
  const emitted = await carveEmit(
247
- { from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
247
+ { from: dir, select: "kubernetes_manifest.web_cert", statePath: join(dir, "terraform.tfstate"), output: out },
248
248
  { plugins: [], liveImport: fakeImport() },
249
249
  );
250
250
  expect(emitted.ok).toBe(true);
251
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);
252
+ // Bridge used to refuse here: `manifest.metadata.name` is a path into the
253
+ // body, and a data-source body is flat `attr = value`. It now reads the
254
+ // manifest back through the shape the kubernetes provider declares, so
255
+ // the target the emit manifest recorded bridges without --select.
256
+ const bridged = await carveBridge({ from: dir, output: out });
257
+ expect(bridged.ok).toBe(true);
258
+ expect(bridged.plan!.target).toBe("kubernetes_manifest.web_cert");
259
+ expect(existsSync(join(out, "kubernetes_manifest-web_cert-runbook.md"))).toBe(true);
260
+
261
+ // Nothing reads the Certificate, so there is no data source to write —
262
+ // but its own block still has to leave the survivor source, or the next
263
+ // apply re-creates what `terraform state rm` released.
264
+ expect(bridged.plan!.dataSources).toEqual([]);
265
+ expect(bridged.plan!.excised).toEqual(["kubernetes_manifest.web_cert"]);
266
+ expect(readFileSync(join(out, "main.tf"), "utf-8")).not.toContain('resource "kubernetes_manifest" "web_cert"');
260
267
  });
261
268
  });
262
269
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
- import { lintCommand, isLintRule, loadPluginRules, type LintOptions } from "./lint";
2
+ import { lintCommand, isLintRule, loadPluginRules, LEXICON_RESOLUTION_RULE_ID, type LintOptions } from "./lint";
3
+ import { loadPlugins, resolveProjectLexicons } from "../plugins";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { join, resolve } from "node:path";
5
6
  import { tmpdir } from "node:os";
@@ -541,3 +542,147 @@ describe("lintCommand — git-ignored files are not linted", () => {
541
542
  expect(result.diagnostics.some((d) => d.file.includes("authored.ts") && d.ruleId === "EVL003")).toBe(true);
542
543
  });
543
544
  });
545
+
546
+ /**
547
+ * chant #2222 (epic #2215 sub-issue 7) — `chant lint` used to print "No
548
+ * problems found" and exit 0 on a project whose declared lexicon could not be
549
+ * resolved, the state `chant build` refuses outright. These pin both halves:
550
+ * the failure is now reported, and the one case the old `catch` legitimately
551
+ * covered (a project that declares no lexicon at all) still lints quietly.
552
+ */
553
+ describe("lintCommand — a declared lexicon that cannot be resolved (#2222)", () => {
554
+ let testDir: string;
555
+
556
+ beforeEach(async () => {
557
+ testDir = join(tmpdir(), `chant-lint-lexicon-${Date.now()}-${Math.random()}`);
558
+ await mkdir(join(testDir, "src"), { recursive: true });
559
+ await writeFile(join(testDir, "src", "clean.ts"), `export const x = 1;\n`);
560
+ process.env.NO_COLOR = "1";
561
+ });
562
+
563
+ afterEach(async () => {
564
+ await rm(testDir, { recursive: true, force: true });
565
+ delete process.env.NO_COLOR;
566
+ });
567
+
568
+ /** The message `chant build` prints after `error: ` for the same project. */
569
+ async function buildsMessageFor(dir: string): Promise<string> {
570
+ try {
571
+ await loadPlugins(await resolveProjectLexicons(dir));
572
+ } catch (err) {
573
+ return err instanceof Error ? err.message : String(err);
574
+ }
575
+ throw new Error(`expected ${dir} to fail lexicon resolution`);
576
+ }
577
+
578
+ test("a declared lexicon whose package is not installed fails the lint", async () => {
579
+ await writeFile(
580
+ join(testDir, "chant.config.ts"),
581
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
582
+ );
583
+
584
+ const result = await lintCommand({ path: testDir, format: "stylish" });
585
+
586
+ expect(result.success).toBe(false);
587
+ expect(result.errorCount).toBe(1);
588
+ const diag = result.diagnostics.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
589
+ expect(diag).toBeDefined();
590
+ expect(diag!.severity).toBe("error");
591
+ expect(diag!.file).toBe(join(testDir, "chant.config.ts"));
592
+ // Byte-for-byte what `chant build` reports, not a second wording.
593
+ expect(diag!.message).toBe(await buildsMessageFor(testDir));
594
+ expect(result.output).not.toContain("No problems found");
595
+ });
596
+
597
+ test("a chant.config that cannot be loaded at all fails the lint with its own error", async () => {
598
+ // The shape found in the wild: the config imports the lexicon package, so
599
+ // it throws before `lexicons` is ever read.
600
+ await writeFile(
601
+ join(testDir, "chant.config.ts"),
602
+ `import { thing } from "@intentius/chant-lexicon-not-a-real-lexicon";\n` +
603
+ `export default { lexicons: ["aws"], thing };\n`,
604
+ );
605
+
606
+ const result = await lintCommand({ path: testDir, format: "stylish" });
607
+
608
+ expect(result.success).toBe(false);
609
+ const diag = result.diagnostics.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
610
+ expect(diag).toBeDefined();
611
+ expect(diag!.message).toBe(await buildsMessageFor(testDir));
612
+ expect(diag!.message).toContain("@intentius/chant-lexicon-not-a-real-lexicon");
613
+ });
614
+
615
+ test("the failure reaches --format json", async () => {
616
+ await writeFile(
617
+ join(testDir, "chant.config.ts"),
618
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
619
+ );
620
+
621
+ const result = await lintCommand({ path: testDir, format: "json" });
622
+
623
+ const parsed = JSON.parse(result.output) as Array<Record<string, unknown>>;
624
+ const entry = parsed.find((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID);
625
+ expect(entry).toBeDefined();
626
+ expect(entry!.severity).toBe("error");
627
+ expect(entry!.message).toBe(await buildsMessageFor(testDir));
628
+ });
629
+
630
+ test("the failure reaches --format sarif as an error-level result", async () => {
631
+ await writeFile(
632
+ join(testDir, "chant.config.ts"),
633
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
634
+ );
635
+
636
+ const result = await lintCommand({ path: testDir, format: "sarif" });
637
+
638
+ const sarif = JSON.parse(result.output) as {
639
+ runs: Array<{ results: Array<{ ruleId: string; level: string; message: { text: string } }> }>;
640
+ };
641
+ const found = sarif.runs[0].results.find((r) => r.ruleId === LEXICON_RESOLUTION_RULE_ID);
642
+ expect(found).toBeDefined();
643
+ expect(found!.level).toBe("error");
644
+ expect(found!.message.text).toBe(await buildsMessageFor(testDir));
645
+ });
646
+
647
+ test("--fix does not drop the diagnostic", async () => {
648
+ await writeFile(
649
+ join(testDir, "chant.config.ts"),
650
+ `export default { lexicons: ["not-a-real-lexicon"] };\n`,
651
+ );
652
+
653
+ const result = await lintCommand({ path: testDir, format: "stylish", fix: true });
654
+
655
+ expect(result.success).toBe(false);
656
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(true);
657
+ });
658
+
659
+ // The case the swallowed `catch` was written for, and the one that must not
660
+ // change: no `chant.config.*`, no lexicon import in any source file. The
661
+ // detection fallback throws "No lexicon detected in infrastructure files",
662
+ // which is a project that lints under the core rules alone.
663
+ test("a project with no chant.config at all still lints clean", async () => {
664
+ const result = await lintCommand({ path: testDir, format: "stylish" });
665
+
666
+ expect(result.success).toBe(true);
667
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
668
+ expect(result.output).toContain("No problems found");
669
+ });
670
+
671
+ test("a chant.config that declares no lexicons still lints clean", async () => {
672
+ await writeFile(join(testDir, "chant.config.ts"), `export default {};\n`);
673
+
674
+ const result = await lintCommand({ path: testDir, format: "stylish" });
675
+
676
+ expect(result.success).toBe(true);
677
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
678
+ });
679
+
680
+ test("a declared lexicon that does resolve lints clean", async () => {
681
+ await writeFile(join(testDir, "chant.config.ts"), `export default { lexicons: ["aws"] };\n`);
682
+
683
+ const result = await lintCommand({ path: testDir, format: "stylish" });
684
+
685
+ expect(result.success).toBe(true);
686
+ expect(result.diagnostics.some((d) => d.ruleId === LEXICON_RESOLUTION_RULE_ID)).toBe(false);
687
+ });
688
+ });