@intentius/chant 0.58.0 → 0.60.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 (169) 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/build-params.d.ts +2 -2
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/build.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts +13 -0
  10. package/dist/cli/commands/lint.d.ts.map +1 -1
  11. package/dist/cli/handlers/lint.d.ts.map +1 -1
  12. package/dist/cli/handlers/operator.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts +0 -16
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/plugins.d.ts +22 -0
  17. package/dist/cli/plugins.d.ts.map +1 -1
  18. package/dist/cli/registry.d.ts +14 -0
  19. package/dist/cli/registry.d.ts.map +1 -1
  20. package/dist/components/cli-support.d.ts +4 -1
  21. package/dist/components/cli-support.d.ts.map +1 -1
  22. package/dist/components/component.d.ts +19 -4
  23. package/dist/components/component.d.ts.map +1 -1
  24. package/dist/components/driver.d.ts +8 -2
  25. package/dist/components/driver.d.ts.map +1 -1
  26. package/dist/components/pilots/alb-ecs.pilot.d.ts +2 -2
  27. package/dist/components/verbs/run-agent.d.ts +1 -7
  28. package/dist/components/verbs/run-agent.d.ts.map +1 -1
  29. package/dist/config.d.ts +4 -4
  30. package/dist/detectLexicon.d.ts +13 -0
  31. package/dist/detectLexicon.d.ts.map +1 -1
  32. package/dist/lexicon.d.ts +213 -3
  33. package/dist/lexicon.d.ts.map +1 -1
  34. package/dist/lifecycle/gate-ledger.d.ts +9 -1
  35. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  36. package/dist/lifecycle/observe.d.ts +4 -4
  37. package/dist/lint/rules/comp/comp004-gate-needs-durable-runtime.d.ts.map +1 -1
  38. package/dist/op/activities/index.d.ts +2 -2
  39. package/dist/op/activities/index.d.ts.map +1 -1
  40. package/dist/op/activities/reconcile.d.ts +134 -2
  41. package/dist/op/activities/reconcile.d.ts.map +1 -1
  42. package/dist/op/builders.d.ts +2 -2
  43. package/dist/op/builders.d.ts.map +1 -1
  44. package/dist/op/change-signal.d.ts +91 -0
  45. package/dist/op/change-signal.d.ts.map +1 -0
  46. package/dist/op/composites/apply-op.d.ts +7 -2
  47. package/dist/op/composites/apply-op.d.ts.map +1 -1
  48. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  49. package/dist/op/gate-name.d.ts +40 -0
  50. package/dist/op/gate-name.d.ts.map +1 -0
  51. package/dist/op/gate-summary.d.ts +55 -0
  52. package/dist/op/gate-summary.d.ts.map +1 -0
  53. package/dist/op/index.d.ts +6 -2
  54. package/dist/op/index.d.ts.map +1 -1
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/op-ir.d.ts +2 -1
  57. package/dist/op/op-ir.d.ts.map +1 -1
  58. package/dist/op/operator.d.ts +63 -0
  59. package/dist/op/operator.d.ts.map +1 -1
  60. package/dist/op/types.d.ts +18 -3
  61. package/dist/op/types.d.ts.map +1 -1
  62. package/dist/params.d.ts +1 -1
  63. package/dist/project-root.d.ts +2 -2
  64. package/dist/terraform/bridge.d.ts +26 -7
  65. package/dist/terraform/bridge.d.ts.map +1 -1
  66. package/dist/terraform/carve-provider.d.ts +28 -2
  67. package/dist/terraform/carve-provider.d.ts.map +1 -1
  68. package/dist/terraform/data-source-shape.d.ts +77 -0
  69. package/dist/terraform/data-source-shape.d.ts.map +1 -0
  70. package/dist/terraform/graph.d.ts.map +1 -1
  71. package/dist/terraform/providers/kubernetes.d.ts +5 -0
  72. package/dist/terraform/providers/kubernetes.d.ts.map +1 -1
  73. package/dist/terraform/tier-map.d.ts +16 -4
  74. package/dist/terraform/tier-map.d.ts.map +1 -1
  75. package/dist/terraform/types.d.ts +8 -0
  76. package/dist/terraform/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/audit/core.ts +25 -3
  79. package/src/audit/discover.ts +122 -14
  80. package/src/build-params.ts +2 -2
  81. package/src/cli/commands/audit.test.ts +5 -3
  82. package/src/cli/commands/audit.ts +5 -1
  83. package/src/cli/commands/build.test.ts +39 -1
  84. package/src/cli/commands/build.ts +22 -8
  85. package/src/cli/commands/carve-bridge.test.ts +237 -14
  86. package/src/cli/commands/carve-bridge.ts +21 -17
  87. package/src/cli/commands/carve-emit-k8s.test.ts +17 -10
  88. package/src/cli/commands/lint.test.ts +297 -1
  89. package/src/cli/commands/lint.ts +119 -11
  90. package/src/cli/handlers/graph.test.ts +4 -4
  91. package/src/cli/handlers/graph.ts +11 -11
  92. package/src/cli/handlers/lint.test.ts +107 -0
  93. package/src/cli/handlers/lint.ts +30 -0
  94. package/src/cli/handlers/operator.ts +81 -0
  95. package/src/cli/handlers/run.test.ts +132 -3
  96. package/src/cli/handlers/run.ts +87 -3
  97. package/src/cli/main.test.ts +9 -11
  98. package/src/cli/main.ts +5 -27
  99. package/src/cli/plugins.test.ts +68 -2
  100. package/src/cli/plugins.ts +39 -0
  101. package/src/cli/registry.ts +14 -0
  102. package/src/components/README.md +2 -2
  103. package/src/components/SPRAWL-VALIDATION.md +5 -5
  104. package/src/components/__fixtures__/neo4j-fanout.json +1 -1
  105. package/src/components/cli-support.test.ts +18 -7
  106. package/src/components/cli-support.ts +8 -3
  107. package/src/components/component-schema.test.ts +18 -2
  108. package/src/components/component.schema.json +17 -4
  109. package/src/components/component.test.ts +2 -2
  110. package/src/components/component.ts +25 -5
  111. package/src/components/config-defaults.test.ts +2 -2
  112. package/src/components/driver.test.ts +20 -1
  113. package/src/components/driver.ts +12 -5
  114. package/src/components/pilots/README.md +1 -1
  115. package/src/components/pilots/alb-ecs.pilot.ts +2 -2
  116. package/src/components/pilots/neo4j-fanout.pilot.ts +3 -3
  117. package/src/components/verbs/run-agent.test.ts +19 -0
  118. package/src/components/verbs/run-agent.ts +1 -7
  119. package/src/config.ts +4 -4
  120. package/src/detectLexicon.ts +18 -1
  121. package/src/discovery/fold-import.test.ts +2 -2
  122. package/src/discovery/fold-import.ts +3 -3
  123. package/src/fold/foldable-helpers.ts +1 -1
  124. package/src/graph-ops.test.ts +1 -1
  125. package/src/lexicon.ts +221 -3
  126. package/src/lifecycle/gate-ledger.ts +12 -1
  127. package/src/lifecycle/observe.test.ts +2 -2
  128. package/src/lifecycle/observe.ts +8 -8
  129. package/src/lifecycle/release-ledger.test.ts +2 -2
  130. package/src/lint/pipeline-change-gate.test.ts +2 -2
  131. package/src/lint/rules/comp/comp.test.ts +26 -0
  132. package/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +4 -2
  133. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +1 -1
  134. package/src/op/activities/index.ts +9 -2
  135. package/src/op/activities/reconcile.test.ts +320 -2
  136. package/src/op/activities/reconcile.ts +423 -2
  137. package/src/op/builders.ts +3 -3
  138. package/src/op/change-signal.test.ts +117 -0
  139. package/src/op/change-signal.ts +169 -0
  140. package/src/op/composites/apply-op.ts +14 -4
  141. package/src/op/composites/composites.test.ts +17 -4
  142. package/src/op/composites/reconcile-op.ts +7 -4
  143. package/src/op/effect-step.test.ts +3 -3
  144. package/src/op/gate-name.test.ts +65 -0
  145. package/src/op/gate-name.ts +60 -0
  146. package/src/op/gate-summary.test.ts +62 -0
  147. package/src/op/gate-summary.ts +96 -0
  148. package/src/op/index.ts +9 -2
  149. package/src/op/local-executor.test.ts +16 -1
  150. package/src/op/local-executor.ts +4 -3
  151. package/src/op/op-ir.test.ts +12 -1
  152. package/src/op/op-ir.ts +5 -3
  153. package/src/op/op-verb-class.test.ts +2 -2
  154. package/src/op/op.test.ts +2 -2
  155. package/src/op/operator.test.ts +368 -0
  156. package/src/op/operator.ts +141 -5
  157. package/src/op/runtimes/local.test.ts +1 -1
  158. package/src/op/types.ts +23 -3
  159. package/src/params.ts +1 -1
  160. package/src/project-root.ts +2 -2
  161. package/src/terraform/aws-resources.test.ts +13 -4
  162. package/src/terraform/bridge.test.ts +22 -9
  163. package/src/terraform/bridge.ts +89 -28
  164. package/src/terraform/carve-provider.ts +38 -2
  165. package/src/terraform/data-source-shape.ts +95 -0
  166. package/src/terraform/graph.ts +35 -7
  167. package/src/terraform/providers/kubernetes.ts +48 -2
  168. package/src/terraform/tier-map.ts +21 -6
  169. package/src/terraform/types.ts +8 -0
@@ -123,7 +123,7 @@ describe("the local op runtime", () => {
123
123
 
124
124
  const handle = await runtime.start(
125
125
  op("gated", [
126
- { kind: "gate", signalName: "approve-prod" },
126
+ { kind: "gate", gate: "approve-prod" },
127
127
  { kind: "activity", fn: "after", args: {} },
128
128
  ]),
129
129
  {},
package/src/op/types.ts CHANGED
@@ -170,13 +170,33 @@ export interface EffectStep {
170
170
  description?: string;
171
171
  }
172
172
 
173
- export interface GateStep {
173
+ /** Everything on a gate step except the key that names it. */
174
+ export interface GateStepBase {
174
175
  kind: "gate";
175
- /** The gate's name — what `chant approve <op> <gate>` resolves. */
176
- signalName: string;
177
176
  /** How long a recorded pending gate stays valid, as a duration string. Default: "48h". */
178
177
  timeout?: string;
179
178
  /** Human-readable description of the action required to unblock this gate. */
180
179
  description?: string;
181
180
  }
182
181
 
182
+ /**
183
+ * A human approval decided against the gate ledger. The name lives on `gate`;
184
+ * `signalName` is the key it carried through 0.58.0 and is still accepted
185
+ * (#2202) — read both through `gateName()` in `./gate-name.ts` rather than
186
+ * reaching for either key directly.
187
+ */
188
+ export type GateStep = GateStepBase &
189
+ (
190
+ | {
191
+ /** The gate's name — what `chant approve <op> <gate>` resolves. */
192
+ gate: string;
193
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
194
+ signalName?: string;
195
+ }
196
+ | {
197
+ gate?: undefined;
198
+ /** @deprecated Renamed to `gate` in #2202. Accepted through 0.59.0, removed in 0.60.0. */
199
+ signalName: string;
200
+ }
201
+ );
202
+
package/src/params.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * is a deploy-time CloudFormation parameter: it emits a `Parameters:` block and
7
7
  * resolves when the STACK deploys. A build-time parameter resolves before the
8
8
  * template is even synthesized — its value can change WHICH resources are
9
- * produced at all (loomster's `LOOM_TIER` selecting `light` vs `production` vs
9
+ * produced at all (a `tier` parameter selecting `light` vs `production` vs
10
10
  * `production-ha`), which a deploy-time `Parameter` structurally cannot do.
11
11
  *
12
12
  * Project source never reads `process.env` directly to vary a build — that
@@ -12,8 +12,8 @@ import { dirname, join, resolve } from "path";
12
12
  * `src/<stack>` layout — `chant build src/<stack>` two or more levels below
13
13
  * the project root — silently never found the root config: `buildParams`'
14
14
  * declared `env:` mappings went inert, `ownership`/`lint.policies`/etc quietly
15
- * fell back to defaults, and nothing warned (loomster#162: `LOOM_TIER`/
16
- * `LOOM_ENV` inert under every `npm run synth:*` for two releases).
15
+ * fell back to defaults, and nothing warned. That failure ran under every
16
+ * `npm run synth:*` of one project for two releases before anyone noticed it.
17
17
  *
18
18
  * `findProjectConfig` is the one walk every config-discovery call site now
19
19
  * shares. It stops at the first of:
@@ -1,6 +1,6 @@
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 { tierMap, foldParentOf, identityAttrOf, canBridge, canCarveEmit, carveEmitTypes } from "./tier-map";
3
+ import { tierMap, foldParentOf, identityAttrOf, canBridge, dataSourceShapeOf, canCarveEmit, carveEmitTypes } from "./tier-map";
4
4
  import { canAdoptFromState } from "./adopt-state";
5
5
 
6
6
  describe("AWS carve-out table", () => {
@@ -27,13 +27,22 @@ describe("AWS carve-out table", () => {
27
27
  for (const t of carveEmitTypes()) expect(canAdoptFromState(t)).toBe(true);
28
28
  });
29
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.
30
+ test("canBridge follows the data-source shape, not the identity attribute (#2015, #2034)", () => {
31
+ // A data source body is flat `attr = value`; a dotted path is not HCL. So a
32
+ // dotted identity attribute bridges only through a declared shape, which is
33
+ // what `kubernetes_manifest` has: `data "kubernetes_resource"`.
32
34
  expect(identityAttrOf("kubernetes_manifest")).toContain(".");
33
- expect(canBridge("kubernetes_manifest")).toBe(false);
35
+ expect(canBridge("kubernetes_manifest")).toBe(true);
36
+ expect(dataSourceShapeOf("kubernetes_manifest")!.type).toBe("kubernetes_resource");
37
+ // A plain identity attribute implies its own same-type shape.
34
38
  expect(canBridge("aws_s3_bucket")).toBe(true);
39
+ expect(dataSourceShapeOf("aws_s3_bucket")).toEqual({
40
+ type: "aws_s3_bucket",
41
+ args: [{ name: "bucket", from: "bucket", required: true }],
42
+ });
35
43
  // No identity entry at all is fine — the bridge writes a TODO comment.
36
44
  expect(canBridge("random_pet")).toBe(true);
45
+ expect(dataSourceShapeOf("random_pet")).toEqual({ type: "random_pet" });
37
46
  for (const t of AWS_CARVE_TYPES) expect(canBridge(t.tfType)).toBe(true);
38
47
  });
39
48
 
@@ -1,7 +1,8 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { buildFixtureGraph } from "./__fixtures__/build-graph";
3
3
  import { boundaryReport } from "./carve";
4
- import { generateBridge, type CarvedIdentity } from "./bridge";
4
+ import { generateBridge, type CarvedDataSource } from "./bridge";
5
+ import { dataSourceShapeOf } from "./tier-map";
5
6
  import type { Hcl2JsonTree } from "./types";
6
7
 
7
8
  const workedExample: Hcl2JsonTree = {
@@ -35,8 +36,10 @@ const API_TF = `resource "aws_lambda_function" "api" {
35
36
  }
36
37
  `;
37
38
 
38
- const identities = new Map<string, CarvedIdentity>([
39
- ["aws_s3_bucket.assets", { attr: "bucket", value: "myapp-assets-prod" }],
39
+ // The read-back for the bucket: the shape its identity attribute implies, plus
40
+ // the literal the graph resolved out of the block.
41
+ const identities = new Map<string, CarvedDataSource>([
42
+ ["aws_s3_bucket.assets", { shape: dataSourceShapeOf("aws_s3_bucket"), values: { bucket: "myapp-assets-prod" } }],
40
43
  ]);
41
44
 
42
45
  describe("generateBridge — inbound (data-source rewrite)", () => {
@@ -51,18 +54,28 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
51
54
  );
52
55
  });
53
56
 
54
- test("never renders a dotted identity attribute into the data body (#2015)", () => {
57
+ test("a type with no shape gets a TODO body, never a dotted attr assignment (#2015)", () => {
55
58
  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);
59
+ // A dotted identity attribute implies no shape at all — `canBridge` refuses
60
+ // such a type before the bridge runs — but an empty read-back still has to
61
+ // produce a block Terraform can parse, not `manifest.metadata.name = "x"`.
62
+ const noShape = new Map<string, CarvedDataSource>([["aws_s3_bucket.assets", {}]]);
63
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], noShape);
60
64
 
61
- // `manifest.metadata.name = "demo-config"` is not valid HCL.
62
65
  expect(plan.dataSources[0].hcl).not.toContain("manifest.metadata.name =");
63
66
  expect(plan.dataSources[0].hcl).toContain("# TODO: identify the resource");
64
67
  });
65
68
 
69
+ test("a shape whose required argument has no literal falls back to the TODO (#2034)", () => {
70
+ const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
71
+ const unresolved = new Map<string, CarvedDataSource>([
72
+ ["aws_s3_bucket.assets", { shape: dataSourceShapeOf("aws_s3_bucket"), values: {} }],
73
+ ]);
74
+ const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], unresolved);
75
+ // A `data "aws_s3_bucket" "assets" {}` with no `bucket` would not plan.
76
+ expect(plan.dataSources[0].hcl).toContain("# TODO: identify the resource");
77
+ });
78
+
66
79
  test("rewrites survivor references to the data source", () => {
67
80
  const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
68
81
  const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
@@ -7,6 +7,9 @@
7
7
  * - inbound edge → the survivor loses its reference to the carved resource.
8
8
  * Bridge: add a `data` source for the (now chant-managed) resource and
9
9
  * rewrite the survivor's `type.name.attr` references to `data.type.name.attr`.
10
+ * The provider's data-source shape (`data-source-shape.ts`, #2034) decides
11
+ * both halves — the data type and body, and the attribute path survivors
12
+ * read through, which need not be the resource's own.
10
13
  * Required immediately, or `terraform plan` errors on the dangling ref.
11
14
  * An `output` block reading the carved resource is such a survivor (#1638):
12
15
  * same data source, same textual rewrite, applied to the output's value.
@@ -22,18 +25,32 @@
22
25
  */
23
26
 
24
27
  import { deferredParamName, type CarveReport } from "./carve";
28
+ import { rewriteReadPath, type DataSourceShape, type ShapeField } from "./data-source-shape";
25
29
  import { exciseResourceBlocks, type ExciseTarget } from "./excise";
26
30
 
27
- export interface CarvedIdentity {
28
- /** The HCL identity attribute, e.g. `bucket` or `name`. */
29
- attr?: string;
30
- /** Its literal value, e.g. `myapp-assets-prod`. */
31
- value?: string;
31
+ /** How one carved resource is read back, and the literals to render it with. */
32
+ export interface CarvedDataSource {
33
+ /**
34
+ * The type's data-source shape (`dataSourceShapeOf`): the `data` type, its
35
+ * arguments and where each comes from, and the survivor path translation.
36
+ */
37
+ shape?: DataSourceShape;
38
+ /**
39
+ * Literals resolved from the carved block, keyed by the shape field's source
40
+ * path — `{ bucket: "myapp-assets-prod" }`, or for a manifest
41
+ * `{ "manifest.apiVersion": "v1", "manifest.metadata.name": "app-config" }`.
42
+ */
43
+ values?: Record<string, string>;
32
44
  }
33
45
 
34
46
  export interface DataSourceBlock {
35
47
  /** Carved resource address the data source stands in for. */
36
48
  address: string;
49
+ /**
50
+ * The `data` type. Usually the carved resource's own type; the shape can
51
+ * name another, as `kubernetes_manifest` reads back through
52
+ * `data "kubernetes_resource"` (#2034).
53
+ */
37
54
  type: string;
38
55
  name: string;
39
56
  hcl: string;
@@ -73,22 +90,64 @@ export interface BridgePlan {
73
90
  runbook: string;
74
91
  }
75
92
 
76
- /** `aws_s3_bucket.assets` → a regex matching that reference head, not already `data.`-prefixed. */
93
+ /**
94
+ * `aws_s3_bucket.assets` → a regex matching that reference head plus the
95
+ * attribute path that follows it, not already `data.`-prefixed. The path is
96
+ * captured because the data source may expose it under a different attribute:
97
+ * a survivor reading `kubernetes_manifest.app_config.manifest.data.x` has to
98
+ * come out as `data.kubernetes_resource.app_config.object.data.x`.
99
+ */
77
100
  function referenceRegex(address: string): RegExp {
78
101
  const escaped = address.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
102
  // (?<!data\.) — do not double-prefix an existing data source reference.
80
- return new RegExp(`(?<!data\\.)\\b${escaped}\\b`, "g");
103
+ return new RegExp(`(?<!data\\.)\\b${escaped}\\b((?:\\.[A-Za-z_][A-Za-z0-9_-]*)*)`, "g");
81
104
  }
82
105
 
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(".");
87
- const body =
88
- flat && identity.value !== undefined
89
- ? ` ${identity.attr} = ${JSON.stringify(identity.value)}`
90
- : ` # TODO: identify the resource (its name/id was interpolated in the source)`;
91
- return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${body}\n}`;
106
+ const IDENTIFY_TODO = " # TODO: identify the resource (its name/id was interpolated in the source)";
107
+
108
+ /** `terraform fmt` aligns the `=` of consecutive assignments; so do we. */
109
+ function alignedAssignments(pairs: Array<[string, string]>, indent: string): string[] {
110
+ const width = Math.max(...pairs.map(([key]) => key.length));
111
+ return pairs.map(([key, value]) => `${indent}${key.padEnd(width)} = ${JSON.stringify(value)}`);
112
+ }
113
+
114
+ /** Resolved `[name, value]` pairs, or null when a required field has no literal. */
115
+ function resolveFields(fields: readonly ShapeField[], values: Record<string, string>): Array<[string, string]> | null {
116
+ const pairs: Array<[string, string]> = [];
117
+ for (const field of fields) {
118
+ const value = values[field.from];
119
+ if (value === undefined) {
120
+ if (field.required) return null;
121
+ continue;
122
+ }
123
+ pairs.push([field.name, value]);
124
+ }
125
+ return pairs;
126
+ }
127
+
128
+ /**
129
+ * The `data` block standing in for the carved resource. A shape whose required
130
+ * arguments the carved block did not supply as literals renders the TODO body
131
+ * instead of a block `terraform validate` would reject.
132
+ */
133
+ function dataSourceHcl(shape: DataSourceShape, name: string, values: Record<string, string>): string {
134
+ const body: string[] = [];
135
+ const args = resolveFields(shape.args ?? [], values);
136
+ if (args === null) return todoBlock(shape.type, name);
137
+ if (args.length) body.push(...alignedAssignments(args, " "));
138
+ for (const block of shape.blocks ?? []) {
139
+ const fields = resolveFields(block.fields, values);
140
+ if (fields === null) return todoBlock(shape.type, name);
141
+ if (!fields.length) continue;
142
+ if (body.length) body.push("");
143
+ body.push(` ${block.name} {`, ...alignedAssignments(fields, " "), " }");
144
+ }
145
+ if (!body.length) body.push(IDENTIFY_TODO);
146
+ return `data ${JSON.stringify(shape.type)} ${JSON.stringify(name)} {\n${body.join("\n")}\n}`;
147
+ }
148
+
149
+ function todoBlock(type: string, name: string): string {
150
+ return `data ${JSON.stringify(type)} ${JSON.stringify(name)} {\n${IDENTIFY_TODO}\n}`;
92
151
  }
93
152
 
94
153
  /**
@@ -96,26 +155,26 @@ function dataSourceHcl(type: string, name: string, identity: CarvedIdentity): st
96
155
  *
97
156
  * @param report the boundary report (from `boundaryReport`)
98
157
  * @param files every `.tf` file in the estate, as { path, content }
99
- * @param identities physical identity per carved address, for the data sources
158
+ * @param readBacks how each carved address is read back as a data source, and
159
+ * the literals to render it with (`dataSourceShapeOf` +
160
+ * the graph node's `dataSourceValues`)
100
161
  */
101
162
  export function generateBridge(
102
163
  report: CarveReport,
103
164
  files: Array<{ path: string; content: string }>,
104
- identities: Map<string, CarvedIdentity>,
165
+ readBacks: Map<string, CarvedDataSource>,
105
166
  ): BridgePlan {
106
167
  // Carved resources that something still depends on need a data source.
107
- const carvedWithInbound = new Map<string, { type: string; name: string }>();
168
+ const carvedWithInbound = new Map<string, { type: string; name: string; readBack: CarvedDataSource }>();
108
169
  for (const e of report.inbound) {
109
170
  const [type, ...rest] = e.carved.split(".");
110
- carvedWithInbound.set(e.carved, { type, name: rest.join(".") });
171
+ carvedWithInbound.set(e.carved, { type, name: rest.join("."), readBack: readBacks.get(e.carved) ?? {} });
111
172
  }
112
173
 
113
- const dataSources: DataSourceBlock[] = [...carvedWithInbound.entries()].map(([address, { type, name }]) => ({
114
- address,
115
- type,
116
- name,
117
- hcl: dataSourceHcl(type, name, identities.get(address) ?? {}),
118
- }));
174
+ const dataSources: DataSourceBlock[] = [...carvedWithInbound.entries()].map(([address, { type, name, readBack }]) => {
175
+ const shape = readBack.shape ?? { type };
176
+ return { address, type: shape.type, name, hcl: dataSourceHcl(shape, name, readBack.values ?? {}) };
177
+ });
119
178
 
120
179
  // Excise the carve set's own blocks (#998): after `terraform state rm`, a
121
180
  // block left behind would re-create the resource on the next apply. Then
@@ -129,8 +188,10 @@ export function generateBridge(
129
188
  const rewrites: FileRewrite[] = files.map(({ path, content }) => {
130
189
  const excision = exciseResourceBlocks(content, exciseTargets);
131
190
  let rewritten = excision.content;
132
- for (const address of carvedWithInbound.keys()) {
133
- rewritten = rewritten.replace(referenceRegex(address), `data.${address}`);
191
+ for (const [address, { type, name, readBack }] of carvedWithInbound) {
192
+ const shape = readBack.shape;
193
+ const head = `data.${shape?.type ?? type}.${name}`;
194
+ rewritten = rewritten.replace(referenceRegex(address), (_match, path: string) => head + rewriteReadPath(shape, path));
134
195
  }
135
196
  return { path, original: content, rewritten, changed: rewritten !== content, excised: excision.excised };
136
197
  });
@@ -28,6 +28,7 @@
28
28
  * not require plugin loading for the paths that run without one.
29
29
  */
30
30
 
31
+ import { identityAttrShape, type DataSourceShape } from "./data-source-shape";
31
32
  import { BUILTIN_CARVE_PROVIDERS } from "./providers";
32
33
  import type { StateResource } from "./state";
33
34
 
@@ -95,10 +96,20 @@ export interface CarveProvider {
95
96
  readonly tiers: Readonly<Record<string, TierInfo>>;
96
97
  /**
97
98
  * Terraform type → the HCL attribute carrying the physical name. A dotted
98
- * entry is a path into nested blocks (`manifest.metadata.name`); the bridge
99
- * refuses those, since a data-source body is flat `attr = value`.
99
+ * entry is a path into nested values (`manifest.metadata.name`), which a
100
+ * flat `attr = value` body cannot express, so it implies no data-source
101
+ * shape on its own — see {@link dataSourceShapes}.
100
102
  */
101
103
  readonly identityAttrs?: Readonly<Record<string, string>>;
104
+ /**
105
+ * Terraform type → how `carve bridge` reads it back as a `data` source
106
+ * (#2034): the data-source type, where each argument comes from in the
107
+ * carved body, and how a survivor's attribute path translates. Needed when
108
+ * the data source is not the resource's own type, or when its body is more
109
+ * than one flat argument. A type with a plain identity attribute and no
110
+ * entry here gets that attribute's shape ({@link identityAttrShape}).
111
+ */
112
+ readonly dataSourceShapes?: Readonly<Record<string, DataSourceShape>>;
102
113
  /**
103
114
  * Sub-resource Terraform type → parent type, for types Terraform splits out
104
115
  * of the resource the native spec keeps them in.
@@ -169,6 +180,7 @@ interface RegistryIndex {
169
180
  prefixes: Array<{ prefix: string; provider: CarveProvider }>;
170
181
  tiers: Record<string, TierInfo>;
171
182
  identityAttrs: Record<string, string>;
183
+ dataSourceShapes: Record<string, DataSourceShape>;
172
184
  foldsInto: Record<string, string>;
173
185
  /** Terraform type → the provider that emits it. */
174
186
  emitters: Map<string, CarveProvider>;
@@ -182,6 +194,7 @@ function index(): RegistryIndex {
182
194
  prefixes: [],
183
195
  tiers: {},
184
196
  identityAttrs: {},
197
+ dataSourceShapes: {},
185
198
  foldsInto: {},
186
199
  emitters: new Map(),
187
200
  };
@@ -189,6 +202,7 @@ function index(): RegistryIndex {
189
202
  for (const prefix of provider.tfTypePrefixes) built.prefixes.push({ prefix, provider });
190
203
  Object.assign(built.tiers, provider.tiers);
191
204
  if (provider.identityAttrs) Object.assign(built.identityAttrs, provider.identityAttrs);
205
+ if (provider.dataSourceShapes) Object.assign(built.dataSourceShapes, provider.dataSourceShapes);
192
206
  if (provider.foldsInto) Object.assign(built.foldsInto, provider.foldsInto);
193
207
  for (const tfType of provider.emitTypes ?? []) built.emitters.set(tfType, provider);
194
208
  }
@@ -235,3 +249,25 @@ export function carveIdentityAttr(tfType: string): string | undefined {
235
249
  export function carveFoldParent(tfType: string): string | undefined {
236
250
  return index().foldsInto[tfType];
237
251
  }
252
+
253
+ /**
254
+ * How `carve bridge` reads this type back as a `data` source (#2034), or
255
+ * undefined when it cannot be read back at all — which is what makes
256
+ * `canBridge` refuse it.
257
+ *
258
+ * Resolution order:
259
+ * 1. a shape the owning provider declared for the type;
260
+ * 2. otherwise the shape a plain identity attribute implies (same type, one
261
+ * required argument) — the path every AWS and google type takes;
262
+ * 3. otherwise undefined, when the identity attribute is a dotted path into
263
+ * nested values and no provider declared a shape for it;
264
+ * 4. and for a type with no identity attribute at all, the bare data-source
265
+ * type: the bridge still emits a block, with a TODO for the body.
266
+ */
267
+ export function carveDataSourceShape(tfType: string): DataSourceShape | undefined {
268
+ const declared = index().dataSourceShapes[tfType];
269
+ if (declared) return declared;
270
+ const attr = index().identityAttrs[tfType];
271
+ if (attr === undefined) return { type: tfType };
272
+ return identityAttrShape(tfType, attr);
273
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * How a carved Terraform type is read back as a `data` source (#2034).
3
+ *
4
+ * `carve bridge` replaces a carved resource with a data source the survivors
5
+ * read instead. Until now a carve provider said only which attribute carries
6
+ * the physical name (`identityAttrs`), and the bridge assumed the data source
7
+ * was the same type as the resource, with that one attribute in its body:
8
+ *
9
+ * data "aws_s3_bucket" "assets" { bucket = "myapp-assets-prod" }
10
+ *
11
+ * That assumption holds for AWS and google, and breaks for Kubernetes twice
12
+ * over. `kubernetes_manifest` names itself by `manifest.metadata.name`, a path
13
+ * into a nested value that a flat `attr = value` body cannot express; and the
14
+ * kubernetes provider ships no `kubernetes_manifest` data source at all
15
+ * (verified against hashicorp/kubernetes v3.2.1: 27 data sources, none a
16
+ * manifest). Its generic read is `data "kubernetes_resource"`, with a
17
+ * different type, different arguments, a nested `metadata` block, and its own
18
+ * attribute path for survivors to read through.
19
+ *
20
+ * So a provider contributes a *shape*: the data-source type, where each
21
+ * argument comes from in the carved body, and how a survivor's attribute path
22
+ * translates. An identity attribute is one instance of that shape
23
+ * ({@link identityAttrShape}), so nothing that bridges today changes.
24
+ */
25
+
26
+ /** One argument of the data-source body, and where its value comes from. */
27
+ export interface ShapeField {
28
+ /** Argument name in the emitted data source, e.g. `api_version`. */
29
+ name: string;
30
+ /**
31
+ * Dotted path into the carved resource's HCL body the literal is read from,
32
+ * e.g. `manifest.apiVersion`. Nested blocks are walked by segment.
33
+ */
34
+ from: string;
35
+ /**
36
+ * A shape that cannot resolve a required field renders the TODO body rather
37
+ * than a data source missing an argument Terraform needs.
38
+ */
39
+ required?: boolean;
40
+ }
41
+
42
+ /** A nested block in the data-source body, e.g. `metadata { name = "x" }`. */
43
+ export interface ShapeBlock {
44
+ name: string;
45
+ fields: readonly ShapeField[];
46
+ }
47
+
48
+ export interface DataSourceShape {
49
+ /** The `data` type standing in for the carved resource, e.g. `kubernetes_resource`. */
50
+ type: string;
51
+ /** Flat `name = value` arguments of the body. */
52
+ args?: readonly ShapeField[];
53
+ /** Nested `name { ... }` blocks of the body. */
54
+ blocks?: readonly ShapeBlock[];
55
+ /**
56
+ * The carved resource's top-level attribute → the data source's, for the
57
+ * survivor rewrite. `kubernetes_manifest` exposes both `manifest` (what the
58
+ * config declared) and `object` (the API server's read-back), and
59
+ * `kubernetes_resource` exposes only `object`, so both map to `object`. An
60
+ * attribute with no entry passes through unchanged, which is what every
61
+ * same-type identity-attribute shape wants.
62
+ */
63
+ readAttrs?: Readonly<Record<string, string>>;
64
+ }
65
+
66
+ /**
67
+ * The shape a plain identity attribute implies: the same type as the resource,
68
+ * one required argument, no path translation. Returns undefined for a dotted
69
+ * attribute, which is a path into nested values and needs a declared shape.
70
+ */
71
+ export function identityAttrShape(tfType: string, attr: string): DataSourceShape | undefined {
72
+ if (attr.includes(".")) return undefined;
73
+ return { type: tfType, args: [{ name: attr, from: attr, required: true }] };
74
+ }
75
+
76
+ /** Every source path the shape reads, for the graph to resolve out of the carved block. */
77
+ export function shapeSourcePaths(shape: DataSourceShape): string[] {
78
+ const paths = (shape.args ?? []).map((f) => f.from);
79
+ for (const block of shape.blocks ?? []) for (const f of block.fields) paths.push(f.from);
80
+ return [...new Set(paths)];
81
+ }
82
+
83
+ /**
84
+ * Translate a survivor's attribute path onto the data source. `path` is the
85
+ * text after the address, leading dot included (`.manifest.data.log_level`) or
86
+ * empty. Only the first segment is mapped: everything under it is the object's
87
+ * own structure, which the data source reproduces.
88
+ */
89
+ export function rewriteReadPath(shape: DataSourceShape | undefined, path: string): string {
90
+ if (!path || !shape?.readAttrs) return path;
91
+ const [head, ...rest] = path.slice(1).split(".");
92
+ const mapped = shape.readAttrs[head];
93
+ if (mapped === undefined) return path;
94
+ return `.${[mapped, ...rest].join(".")}`;
95
+ }
@@ -11,7 +11,8 @@
11
11
  * graph building stays unit-testable on hand-written fixtures.
12
12
  */
13
13
 
14
- import { identityAttrOf } from "./tier-map";
14
+ import { shapeSourcePaths } from "./data-source-shape";
15
+ import { dataSourceShapeOf, identityAttrOf } from "./tier-map";
15
16
  import type { Hcl2JsonTree, TfEdge, TfGraph, TfNode } from "./types";
16
17
 
17
18
  /**
@@ -142,16 +143,14 @@ function blockHasMeta(block: unknown, key: string): boolean {
142
143
  }
143
144
 
144
145
  /**
145
- * The resource's physical name, if its identity attribute is a plain literal
146
- * (not interpolated). A dotted identity attribute walks nested blocks —
146
+ * The literal string at a dotted path into a block, or undefined when the path
147
+ * misses or lands on an interpolation. A dotted path walks nested blocks —
147
148
  * hcl2json renders a nested block as a one-element array, so arrays step
148
149
  * through their first element (`manifest.metadata.name`).
149
150
  */
150
- function literalIdentity(block: unknown, type: string): string | undefined {
151
- const attr = identityAttrOf(type);
152
- if (!attr || !block || typeof block !== "object") return undefined;
151
+ function literalAt(block: unknown, path: string): string | undefined {
153
152
  let value: unknown = block;
154
- for (const segment of attr.split(".")) {
153
+ for (const segment of path.split(".")) {
155
154
  if (Array.isArray(value)) value = value[0];
156
155
  if (!value || typeof value !== "object") return undefined;
157
156
  value = (value as Record<string, unknown>)[segment];
@@ -160,6 +159,34 @@ function literalIdentity(block: unknown, type: string): string | undefined {
160
159
  return value;
161
160
  }
162
161
 
162
+ /**
163
+ * The resource's physical name, if its identity attribute is a plain literal
164
+ * (not interpolated).
165
+ */
166
+ function literalIdentity(block: unknown, type: string): string | undefined {
167
+ const attr = identityAttrOf(type);
168
+ if (!attr || !block || typeof block !== "object") return undefined;
169
+ return literalAt(block, attr);
170
+ }
171
+
172
+ /**
173
+ * The literals the type's data-source shape reads out of the carved block
174
+ * (#2034), keyed by the shape field's source path. `carve bridge` renders the
175
+ * data-source body from these; a required field missing here is what makes it
176
+ * write a TODO instead of a block Terraform would reject.
177
+ */
178
+ function dataSourceValues(block: unknown, type: string): Record<string, string> | undefined {
179
+ if (!block || typeof block !== "object") return undefined;
180
+ const shape = dataSourceShapeOf(type);
181
+ if (!shape) return undefined;
182
+ const values: Record<string, string> = {};
183
+ for (const path of shapeSourcePaths(shape)) {
184
+ const value = literalAt(block, path);
185
+ if (value !== undefined) values[path] = value;
186
+ }
187
+ return Object.keys(values).length ? values : undefined;
188
+ }
189
+
163
190
  /**
164
191
  * Build the dependency graph from a merged hcl2json tree.
165
192
  *
@@ -202,6 +229,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
202
229
  instances: 1,
203
230
  hasDynamic: dynamic || touchesData,
204
231
  identity: literalIdentity(block, type),
232
+ dataSourceValues: dataSourceValues(block, type),
205
233
  });
206
234
  }
207
235
  }