@intentius/chant-lexicon-terraform 0.124.52 → 0.124.54

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 (336) hide show
  1. package/README.md +7 -6
  2. package/dist/__fixtures__/pr-loop/estate.component.d.ts +7 -0
  3. package/dist/__fixtures__/pr-loop/estate.component.d.ts.map +1 -0
  4. package/dist/__fixtures__/pr-loop/estate.component.js +7 -0
  5. package/dist/__fixtures__/report/generate.d.ts +26 -0
  6. package/dist/__fixtures__/report/generate.d.ts.map +1 -0
  7. package/dist/__fixtures__/report/generate.js +58 -0
  8. package/dist/applied-state.d.ts +27 -0
  9. package/dist/applied-state.d.ts.map +1 -0
  10. package/dist/applied-state.js +44 -0
  11. package/dist/backend-config.d.ts +53 -0
  12. package/dist/backend-config.d.ts.map +1 -0
  13. package/dist/backend-config.js +136 -0
  14. package/dist/behaviour/kinds.d.ts.map +1 -1
  15. package/dist/behaviour/kinds.js +4 -0
  16. package/dist/behaviour/predict.d.ts.map +1 -1
  17. package/dist/behaviour/predict.js +6 -2
  18. package/dist/behaviour/reference-catalog.d.ts +16 -5
  19. package/dist/behaviour/reference-catalog.d.ts.map +1 -1
  20. package/dist/behaviour/reference-catalog.js +16 -5
  21. package/dist/behaviour/request.d.ts.map +1 -1
  22. package/dist/behaviour/request.js +3 -2
  23. package/dist/changed-roots.d.ts +6 -4
  24. package/dist/changed-roots.d.ts.map +1 -1
  25. package/dist/changed-roots.js +14 -18
  26. package/dist/commands.d.ts.map +1 -1
  27. package/dist/commands.js +4 -3
  28. package/dist/components/capability-plugin.d.ts +65 -3
  29. package/dist/components/capability-plugin.d.ts.map +1 -1
  30. package/dist/components/capability-plugin.js +197 -13
  31. package/dist/components/compose-group.d.ts +145 -0
  32. package/dist/components/compose-group.d.ts.map +1 -0
  33. package/dist/components/compose-group.js +407 -0
  34. package/dist/components/placeholder-preview.d.ts +78 -0
  35. package/dist/components/placeholder-preview.d.ts.map +1 -0
  36. package/dist/components/placeholder-preview.js +254 -0
  37. package/dist/compose/apply-events.d.ts +46 -0
  38. package/dist/compose/apply-events.d.ts.map +1 -0
  39. package/dist/compose/apply-events.js +77 -0
  40. package/dist/compose/estate.d.ts +194 -0
  41. package/dist/compose/estate.d.ts.map +1 -0
  42. package/dist/compose/estate.js +363 -0
  43. package/dist/compose/hcl.d.ts +99 -0
  44. package/dist/compose/hcl.d.ts.map +1 -0
  45. package/dist/compose/hcl.js +302 -0
  46. package/dist/compose/module-address.d.ts +24 -0
  47. package/dist/compose/module-address.d.ts.map +1 -0
  48. package/dist/compose/module-address.js +43 -0
  49. package/dist/compose/root.d.ts +127 -0
  50. package/dist/compose/root.d.ts.map +1 -0
  51. package/dist/compose/root.js +586 -0
  52. package/dist/compose/run.d.ts +183 -0
  53. package/dist/compose/run.d.ts.map +1 -0
  54. package/dist/compose/run.js +447 -0
  55. package/dist/compose/slice.d.ts +102 -0
  56. package/dist/compose/slice.d.ts.map +1 -0
  57. package/dist/compose/slice.js +249 -0
  58. package/dist/compose/state-address.d.ts +28 -0
  59. package/dist/compose/state-address.d.ts.map +1 -0
  60. package/dist/compose/state-address.js +154 -0
  61. package/dist/compose/state-merge.d.ts +52 -0
  62. package/dist/compose/state-merge.d.ts.map +1 -0
  63. package/dist/compose/state-merge.js +135 -0
  64. package/dist/compose.d.ts +179 -0
  65. package/dist/compose.d.ts.map +1 -0
  66. package/dist/compose.js +482 -0
  67. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  68. package/dist/composites/terraform-apply-op.js +7 -3
  69. package/dist/config.d.ts +59 -13
  70. package/dist/config.d.ts.map +1 -1
  71. package/dist/config.js +43 -9
  72. package/dist/describe-resources.d.ts +74 -12
  73. package/dist/describe-resources.d.ts.map +1 -1
  74. package/dist/describe-resources.js +374 -66
  75. package/dist/drift-read.d.ts +33 -0
  76. package/dist/drift-read.d.ts.map +1 -0
  77. package/dist/drift-read.js +243 -0
  78. package/dist/drift-record.d.ts +147 -0
  79. package/dist/drift-record.d.ts.map +1 -0
  80. package/dist/drift-record.js +383 -0
  81. package/dist/hcl/edges.d.ts.map +1 -1
  82. package/dist/hcl/edges.js +24 -1
  83. package/dist/hcl/files.d.ts +61 -0
  84. package/dist/hcl/files.d.ts.map +1 -0
  85. package/dist/hcl/files.js +221 -0
  86. package/dist/hcl/instances.d.ts +82 -0
  87. package/dist/hcl/instances.d.ts.map +1 -0
  88. package/dist/hcl/instances.js +390 -0
  89. package/dist/hcl/parse.d.ts +81 -9
  90. package/dist/hcl/parse.d.ts.map +1 -1
  91. package/dist/hcl/parse.js +114 -16
  92. package/dist/hcl/roots.d.ts.map +1 -1
  93. package/dist/hcl/roots.js +5 -0
  94. package/dist/hcl/suppressions.d.ts.map +1 -1
  95. package/dist/hcl/suppressions.js +8 -2
  96. package/dist/index.d.ts +11 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +21 -2
  99. package/dist/integrity.json +3 -3
  100. package/dist/live-plan-reasons.d.ts +21 -0
  101. package/dist/live-plan-reasons.d.ts.map +1 -0
  102. package/dist/live-plan-reasons.js +47 -0
  103. package/dist/lsp/option-keys.d.ts.map +1 -1
  104. package/dist/lsp/option-keys.js +2 -0
  105. package/dist/manifest.json +1 -1
  106. package/dist/op/activities/index.d.ts +6 -2
  107. package/dist/op/activities/index.d.ts.map +1 -1
  108. package/dist/op/activities/index.js +5 -1
  109. package/dist/op/activities/live-detect.d.ts +4 -3
  110. package/dist/op/activities/live-detect.d.ts.map +1 -1
  111. package/dist/op/activities/live-detect.js +57 -32
  112. package/dist/op/activities/pin-rollout.d.ts +2 -0
  113. package/dist/op/activities/pin-rollout.d.ts.map +1 -1
  114. package/dist/op/activities/pin-rollout.js +1 -0
  115. package/dist/op/activities/terraform.d.ts +153 -5
  116. package/dist/op/activities/terraform.d.ts.map +1 -1
  117. package/dist/op/activities/terraform.js +205 -20
  118. package/dist/op/activity-contracts.d.ts +27 -0
  119. package/dist/op/activity-contracts.d.ts.map +1 -1
  120. package/dist/op/activity-contracts.js +19 -1
  121. package/dist/op/builders.d.ts +7 -1
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/builders.js +9 -0
  124. package/dist/pin/edit.d.ts.map +1 -1
  125. package/dist/pin/edit.js +35 -0
  126. package/dist/pin/rollout.d.ts +2 -0
  127. package/dist/pin/rollout.d.ts.map +1 -1
  128. package/dist/pin/rollout.js +14 -11
  129. package/dist/plan-shape.d.ts +30 -0
  130. package/dist/plan-shape.d.ts.map +1 -0
  131. package/dist/plan-shape.js +125 -0
  132. package/dist/plugin.d.ts.map +1 -1
  133. package/dist/plugin.js +32 -18
  134. package/dist/report.d.ts +15 -6
  135. package/dist/report.d.ts.map +1 -1
  136. package/dist/report.js +28 -55
  137. package/dist/skills/chant-terraform.md +1 -1
  138. package/dist/state/azure-blob.d.ts +68 -0
  139. package/dist/state/azure-blob.d.ts.map +1 -0
  140. package/dist/state/azure-blob.js +201 -0
  141. package/dist/state/backend.d.ts +158 -0
  142. package/dist/state/backend.d.ts.map +1 -0
  143. package/dist/state/backend.js +406 -0
  144. package/dist/state/exec.d.ts +39 -0
  145. package/dist/state/exec.d.ts.map +1 -0
  146. package/dist/state/exec.js +80 -0
  147. package/dist/state/gcs.d.ts +62 -0
  148. package/dist/state/gcs.d.ts.map +1 -0
  149. package/dist/state/gcs.js +224 -0
  150. package/dist/state/index.d.ts +15 -0
  151. package/dist/state/index.d.ts.map +1 -0
  152. package/dist/state/index.js +14 -0
  153. package/dist/state/locks.d.ts +105 -0
  154. package/dist/state/locks.d.ts.map +1 -0
  155. package/dist/state/locks.js +130 -0
  156. package/dist/state/object-store.d.ts +37 -0
  157. package/dist/state/object-store.d.ts.map +1 -0
  158. package/dist/state/object-store.js +20 -0
  159. package/dist/state/root-states.d.ts +131 -0
  160. package/dist/state/root-states.d.ts.map +1 -0
  161. package/dist/state/root-states.js +241 -0
  162. package/dist/state/s3.d.ts +68 -0
  163. package/dist/state/s3.d.ts.map +1 -0
  164. package/dist/state/s3.js +170 -0
  165. package/dist/state/store.d.ts +51 -0
  166. package/dist/state/store.d.ts.map +1 -0
  167. package/dist/state/store.js +185 -0
  168. package/dist/terragrunt/affected.d.ts.map +1 -1
  169. package/dist/terragrunt/affected.js +2 -1
  170. package/package.json +7 -3
  171. package/src/__fixtures__/ACCEPTANCE.md +69 -7
  172. package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
  173. package/src/__fixtures__/compose/state-app.json +66 -0
  174. package/src/__fixtures__/compose/state-edge.json +40 -0
  175. package/src/__fixtures__/compose/state-net.json +58 -0
  176. package/src/__fixtures__/compose/three-app.plan.json +184 -0
  177. package/src/__fixtures__/compose/three-composed.plan.json +509 -0
  178. package/src/__fixtures__/compose/three-edge.plan.json +144 -0
  179. package/src/__fixtures__/compose/three-net.plan.json +174 -0
  180. package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
  181. package/src/__fixtures__/compose/working-after-apply.json +145 -0
  182. package/src/__fixtures__/config-blocks/app/main.tf +61 -0
  183. package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
  184. package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
  185. package/src/__fixtures__/config-files/shadow/main.tf +3 -0
  186. package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
  187. package/src/__fixtures__/config-files/shadow/other.tf +3 -0
  188. package/src/__fixtures__/config-files/tf/main.tf +11 -0
  189. package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
  190. package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
  191. package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
  192. package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
  193. package/src/__fixtures__/drift-record/README.md +11 -0
  194. package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
  195. package/src/__fixtures__/instance-forms/app/main.tf +110 -0
  196. package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
  197. package/src/__fixtures__/instance-vars/app/main.tf +52 -0
  198. package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
  199. package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
  200. package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
  201. package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
  202. package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
  203. package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
  204. package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
  205. package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
  206. package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
  207. package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
  208. package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
  209. package/src/__fixtures__/pr-loop/README.md +50 -0
  210. package/src/__fixtures__/pr-loop/chant.config.json +13 -0
  211. package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
  212. package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
  213. package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
  214. package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
  215. package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
  216. package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
  217. package/src/__fixtures__/report/applied.show.json +51 -0
  218. package/src/__fixtures__/report/generate.ts +63 -0
  219. package/src/__fixtures__/report/plan.show.json +200 -0
  220. package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
  221. package/src/__fixtures__/report/tf-apply/report.json +338 -0
  222. package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
  223. package/src/__fixtures__/report/tf-plan/report.json +320 -0
  224. package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
  225. package/src/applied-state.ts +52 -0
  226. package/src/backend-config.test.ts +105 -0
  227. package/src/backend-config.ts +133 -0
  228. package/src/behaviour/kinds.ts +4 -0
  229. package/src/behaviour/predict.ts +5 -2
  230. package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
  231. package/src/behaviour/reference-catalog.ts +16 -5
  232. package/src/behaviour/request.ts +3 -2
  233. package/src/change-set-instances.test.ts +98 -0
  234. package/src/changed-roots.ts +14 -16
  235. package/src/commands.ts +4 -3
  236. package/src/components/capability-plugin.test.ts +172 -5
  237. package/src/components/capability-plugin.ts +212 -11
  238. package/src/components/change-set-graph.e2e.test.ts +11 -5
  239. package/src/components/compose-group.test.ts +486 -0
  240. package/src/components/compose-group.ts +489 -0
  241. package/src/components/demo-composed-roots.e2e.test.ts +440 -0
  242. package/src/components/member-outputs.test.ts +76 -0
  243. package/src/components/placeholder-preview.test.ts +147 -0
  244. package/src/components/placeholder-preview.ts +257 -0
  245. package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
  246. package/src/components/pr-loop.tofu.test.ts +112 -56
  247. package/src/compose/apply-events.test.ts +62 -0
  248. package/src/compose/apply-events.ts +100 -0
  249. package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
  250. package/src/compose/estate.test.ts +421 -0
  251. package/src/compose/estate.ts +443 -0
  252. package/src/compose/hcl.ts +311 -0
  253. package/src/compose/module-address.ts +49 -0
  254. package/src/compose/root.test.ts +416 -0
  255. package/src/compose/root.ts +592 -0
  256. package/src/compose/run.test.ts +440 -0
  257. package/src/compose/run.tofu.test.ts +176 -0
  258. package/src/compose/run.ts +527 -0
  259. package/src/compose/slice.test.ts +184 -0
  260. package/src/compose/slice.ts +290 -0
  261. package/src/compose/state-address.ts +157 -0
  262. package/src/compose/state-merge.test.ts +131 -0
  263. package/src/compose/state-merge.ts +148 -0
  264. package/src/compose.test.ts +248 -0
  265. package/src/compose.ts +627 -0
  266. package/src/composites/terraform-apply-op.test.ts +9 -0
  267. package/src/composites/terraform-apply-op.ts +7 -3
  268. package/src/config-file-listers.test.ts +265 -0
  269. package/src/config.ts +44 -9
  270. package/src/describe-resources.engine-facts.test.ts +412 -0
  271. package/src/describe-resources.live-module-keyed.test.ts +290 -0
  272. package/src/describe-resources.ts +416 -70
  273. package/src/drift-read.ts +284 -0
  274. package/src/drift-record.test.ts +223 -0
  275. package/src/drift-record.ts +455 -0
  276. package/src/hcl/config-blocks.test.ts +174 -0
  277. package/src/hcl/edges.ts +21 -1
  278. package/src/hcl/files.ts +234 -0
  279. package/src/hcl/instance-forms.test.ts +94 -0
  280. package/src/hcl/instances.test.ts +137 -0
  281. package/src/hcl/instances.ts +422 -0
  282. package/src/hcl/parse.test.ts +139 -1
  283. package/src/hcl/parse.ts +149 -16
  284. package/src/hcl/roots.ts +6 -0
  285. package/src/hcl/suppressions.ts +8 -2
  286. package/src/index.ts +76 -0
  287. package/src/live-plan-reasons.ts +54 -0
  288. package/src/lsp/completions.test.ts +1 -1
  289. package/src/lsp/option-keys.ts +2 -0
  290. package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
  291. package/src/op/activities/index.ts +9 -0
  292. package/src/op/activities/live-detect.ts +55 -26
  293. package/src/op/activities/pin-rollout.ts +3 -0
  294. package/src/op/activities/registry.test.ts +2 -1
  295. package/src/op/activities/terraform.test.ts +55 -0
  296. package/src/op/activities/terraform.ts +266 -22
  297. package/src/op/activity-contracts.test.ts +1 -0
  298. package/src/op/activity-contracts.ts +24 -1
  299. package/src/op/builders.ts +14 -0
  300. package/src/pin/bundle.test.ts +2 -0
  301. package/src/pin/edit.ts +30 -0
  302. package/src/pin/rollout.ts +16 -11
  303. package/src/plan-shape.test.ts +148 -0
  304. package/src/plan-shape.ts +128 -0
  305. package/src/plugin.ts +33 -19
  306. package/src/report.test.ts +82 -1
  307. package/src/report.ts +31 -54
  308. package/src/signal-only-root.test.ts +72 -0
  309. package/src/skills/chant-terraform.md +1 -1
  310. package/src/state/azure-blob.ts +223 -0
  311. package/src/state/backend-verdict.test.ts +187 -0
  312. package/src/state/backend.test.ts +127 -0
  313. package/src/state/backend.ts +488 -0
  314. package/src/state/exec.ts +111 -0
  315. package/src/state/gcs.ts +254 -0
  316. package/src/state/index.ts +15 -0
  317. package/src/state/locks.test.ts +84 -0
  318. package/src/state/locks.ts +201 -0
  319. package/src/state/object-store.ts +35 -0
  320. package/src/state/root-states-floci.e2e.test.ts +66 -0
  321. package/src/state/root-states.test.ts +462 -0
  322. package/src/state/root-states.ts +309 -0
  323. package/src/state/s3.ts +193 -0
  324. package/src/state/store.ts +225 -0
  325. package/src/terragrunt/affected.ts +2 -1
  326. package/src/workspace-kinds.test.ts +4 -17
  327. package/workspace-kinds.json +1 -24
  328. package/dist/workspace-member.d.ts +0 -30
  329. package/dist/workspace-member.d.ts.map +0 -1
  330. package/dist/workspace-member.js +0 -91
  331. package/schemas/pulumi.schema.json +0 -34
  332. package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
  333. package/src/testdata/pulumi-member/stack-export.json +0 -53
  334. package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
  335. package/src/workspace-member.test.ts +0 -89
  336. package/src/workspace-member.ts +0 -126
@@ -45,12 +45,14 @@ import { tmpdir } from "node:os";
45
45
  import { promisify } from "node:util";
46
46
  import { resolve, dirname, join, relative, sep } from "node:path";
47
47
  import { loadChantConfigUpward } from "@intentius/chant/config";
48
+ import { findWorkspaceRoot } from "@intentius/chant/project-root";
48
49
  import { liveReadKey, memoLiveRead } from "@intentius/chant/live-read-session";
49
50
  import { terraformPlanDigest } from "../../plan-digest";
50
51
  import { choudoufuSetPlanParts, plannerForBinary, terraformChangeSetPart } from "../../change-set";
51
52
  import type { ChangeSetPart } from "@intentius/chant/change-set";
52
53
  import type { TerraformConfig, TerraformRootConfig } from "../../config";
53
54
  import { detectLiveEstate } from "./live-detect";
55
+ import { backendConfigValues } from "../../backend-config";
54
56
  import {
55
57
  parseAdoptionCommands,
56
58
  readAdoptionLedger,
@@ -116,6 +118,36 @@ export const MIN_CHOUDOUFU_VERSION = "0.15.0";
116
118
  */
117
119
  export const MIN_CHOUDOUFU_WAVES_VERSION = "0.22.0";
118
120
 
121
+ /**
122
+ * The choudoufu release a composed run of estates needs (#4445): a root with
123
+ * no `live` block that calls estate directories as modules, planned and
124
+ * applied as one walk, with a `terraform_estate_outputs` read of another
125
+ * estate in the run answered inside it (choudoufu#2059, first released in
126
+ * v0.31.0). Only the composed estate run (`compose/estate.ts`) checks it;
127
+ * below it the members plan wave by wave. A dev build names no release and
128
+ * passes, as for the other floors.
129
+ */
130
+ export const MIN_CHOUDOUFU_ESTATES_VERSION = "0.31.0";
131
+
132
+ /**
133
+ * The choudoufu release whose `live-graph -json -from-plan=<file>` prints the
134
+ * engine graph document (chant#4520's design section 4, choudoufu#2285), the
135
+ * one `describeResources()` reads engine facts from on a live root
136
+ * (chant#4528). choudoufu's changelog lists the command under
137
+ * `v0.32.0 (Unreleased)`, so this is the version it releases as. The chant
138
+ * side of the same contract, the first chant that writes those facts, is
139
+ * `ENGINE_FACTS_FLOOR` in core's engine-facts.ts, which is NEXT_RELEASE
140
+ * until the cut.
141
+ *
142
+ * Not a refusal. Facts are for people and never feed a decision, so a
143
+ * choudoufu without the command (a usage error, see
144
+ * {@link classifyLiveGraphFailure}) leaves `facts` absent with one note
145
+ * naming this version, and the rest of the read is unchanged. The check is
146
+ * the capability, not the version string: a local or dev build names a
147
+ * version below this or none at all, and still carries the command.
148
+ */
149
+ export const MIN_CHOUDOUFU_GRAPH_VERSION = "0.32.0";
150
+
119
151
  /**
120
152
  * Where a choudoufu root's wave files live, relative to the project root:
121
153
  * `<root>/plan-set.json` (the set plan document chant planned and the
@@ -191,6 +223,14 @@ export interface TerraformPlanArgs extends TerraformRootArgs {
191
223
  * of that file changes nothing else.
192
224
  */
193
225
  targets?: string[];
226
+ /**
227
+ * `false` skips `show -no-color`, the human-readable render (#4479). The
228
+ * plan then runs one `show` process, `show -json`, whose document gives the
229
+ * digest, the change set and everything a caller derives from `json`, and
230
+ * {@link TerraformPlanResult.text} is `""`. For a caller that never reads
231
+ * the text, such as the `terraform-apply` capability. Default: `true`.
232
+ */
233
+ text?: boolean;
194
234
  }
195
235
 
196
236
  export interface TerraformApplyArgs extends TerraformRootArgs {
@@ -280,6 +320,13 @@ export interface TerraformInitResult {
280
320
  dir: string;
281
321
  /** Workspace the run selected via `TF_WORKSPACE`, when the root names one. */
282
322
  workspace?: string;
323
+ /**
324
+ * The root module directory relative to the chant workspace root, with `/`
325
+ * separators (chant#4482), when the root sits inside a workspace. A
326
+ * TerraformApplyOp publishes it as the `TerraformRootPath` run outcome, so a
327
+ * ledger read can name the member whose directory holds the root it applied.
328
+ */
329
+ rootPath?: string;
283
330
  }
284
331
 
285
332
  /** Counts projected out of a plan's `resource_changes`. */
@@ -304,7 +351,7 @@ export interface TerraformPlanResult extends PlanChangeCounts {
304
351
  dir: string;
305
352
  /** `terraform show -json <planFile>`, parsed. */
306
353
  json: unknown;
307
- /** `terraform show -no-color <planFile>` — the human-readable plan. */
354
+ /** `terraform show -no-color <planFile>` — the human-readable plan. `""` when the plan ran with `text: false`. */
308
355
  text: string;
309
356
  /**
310
357
  * This plan's identity (#2300) — {@link terraformPlanDigest} over
@@ -537,19 +584,24 @@ export function terraformEnvironment(root?: Pick<TerraformRootConfig, "workspace
537
584
  };
538
585
  }
539
586
 
540
- /** `terraform init`, with the root's `backendConfig` as `-backend-config=k=v` flags. */
587
+ /**
588
+ * `terraform init`, with the root's `backendConfigFiles` as
589
+ * `-backend-config=<path>` flags and then its `backendConfig` as
590
+ * `-backend-config=k=v` flags (chant#4459): init applies later flags over
591
+ * earlier ones, so a pair overrides a file. A path is passed as written; init
592
+ * runs in the root's directory, which a relative one resolves against.
593
+ */
541
594
  export function terraformInitCommand(opts: {
542
595
  binary: string;
543
596
  backendConfig?: Record<string, string>;
597
+ backendConfigFiles?: readonly string[];
544
598
  upgrade?: boolean;
545
599
  reconfigure?: boolean;
546
600
  }): string {
547
601
  const parts = [opts.binary, "init", "-input=false"];
548
602
  if (opts.upgrade) parts.push("-upgrade");
549
603
  if (opts.reconfigure) parts.push("-reconfigure");
550
- for (const [key, value] of Object.entries(opts.backendConfig ?? {})) {
551
- parts.push(`-backend-config=${quoteArg(`${key}=${value}`)}`);
552
- }
604
+ for (const value of backendConfigValues(opts)) parts.push(`-backend-config=${quoteArg(value)}`);
553
605
  return parts.join(" ");
554
606
  }
555
607
 
@@ -629,6 +681,37 @@ export function choudoufuLivePlanCommand(opts: {
629
681
  return parts.join(" ");
630
682
  }
631
683
 
684
+ /**
685
+ * `live-graph -json -from-plan=<file> [-estate=<estate>]` (choudoufu#2285).
686
+ * No `DIR` argument: like {@link choudoufuLiveCheckCommand}, the activity
687
+ * runs in the root directory, which is DIR's default. `-estate` follows
688
+ * {@link choudoufuLivePlanCommand}'s rule: only for a root that does not
689
+ * name its own estate. `-history` is never passed: record versions take a
690
+ * listing per record and `s3:ListBucketVersions`, and an observation read
691
+ * has no use for them (the #4520 ruling, Q6: opt-in).
692
+ */
693
+ export function choudoufuLiveGraphCommand(opts: { binary: string; fromPlan: string; estate?: string }): string {
694
+ const parts = [opts.binary, "live-graph", "-json", `-from-plan=${quoteArg(opts.fromPlan)}`];
695
+ if (opts.estate !== undefined) parts.push(`-estate=${quoteArg(opts.estate)}`);
696
+ return parts.join(" ");
697
+ }
698
+
699
+ /**
700
+ * Whether a failed `live-graph` run is a choudoufu that has no such command
701
+ * or flag (`"unsupported"`), or a real failure (`"failed"`). Older builds
702
+ * answer an unknown command two ways: the new CLI prints `OpenTofu has no
703
+ * command named "live-graph"` and exits 1 (cmd/choudoufu/command_main.go),
704
+ * and the older mitchellh/cli path prints its usage and exits 127. A build
705
+ * whose `live-graph` predates `-from-plan` fails the flag parse with `flag
706
+ * provided but not defined`.
707
+ */
708
+ export function classifyLiveGraphFailure(code: unknown, output: string): "unsupported" | "failed" {
709
+ if (code === 127) return "unsupported";
710
+ if (/no command named "?live-graph"?/i.test(output)) return "unsupported";
711
+ if (/flag provided but not defined: -(?:from-plan|json|estate)\b/.test(output)) return "unsupported";
712
+ return "failed";
713
+ }
714
+
632
715
  /** `live-ls -estate=<estate> -json [-consistent]`. */
633
716
  export function choudoufuLiveLsCommand(opts: { binary: string; estate: string; consistent?: boolean }): string {
634
717
  const parts = [opts.binary, "live-ls", `-estate=${quoteArg(opts.estate)}`, "-json"];
@@ -917,8 +1000,8 @@ function report(stdout: string, stderr: string): void {
917
1000
  // ── Activities ──────────────────────────────────────────────────────────────
918
1001
 
919
1002
  /**
920
- * `terraform init` in the named root, with the root's `backendConfig` supplied
921
- * as `-backend-config` flags. Uses the longInfra profile: init downloads
1003
+ * `terraform init` in the named root, with the root's `backendConfigFiles` and
1004
+ * `backendConfig` supplied as `-backend-config` flags. Uses the longInfra profile: init downloads
922
1005
  * providers and modules, a network call of unpredictable size.
923
1006
  */
924
1007
  export async function terraformInit(
@@ -929,19 +1012,55 @@ export async function terraformInit(
929
1012
  const cmd = terraformInitCommand({
930
1013
  binary,
931
1014
  ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}),
1015
+ ...(root.backendConfigFiles ? { backendConfigFiles: root.backendConfigFiles } : {}),
932
1016
  ...(args.upgrade ? { upgrade: true } : {}),
933
1017
  ...(args.reconfigure ? { reconfigure: true } : {}),
934
1018
  });
935
1019
 
936
- const { stdout, stderr } = await run(cmd, dir, terraformEnvironment(root), signal);
937
- report(stdout, stderr);
1020
+ // Known once the root resolves, before init runs (chant#4512), so a failed
1021
+ // init still publishes them and the ledger names the root it was for.
1022
+ const rootPath = workspaceRelativePath(dir);
1023
+ const result: TerraformInitResult = { dir, ...(root.workspace ? { workspace: root.workspace } : {}), ...(rootPath !== undefined ? { rootPath } : {}) };
1024
+
1025
+ let out: { stdout: string; stderr: string };
1026
+ try {
1027
+ out = await run(cmd, dir, terraformEnvironment(root), signal);
1028
+ } catch (err) {
1029
+ throw withKnownOutputs(err, result);
1030
+ }
1031
+ report(out.stdout, out.stderr);
1032
+ return result;
1033
+ }
1034
+
1035
+ /**
1036
+ * `err` carrying `outputs` as the outputs the activity knew when it failed
1037
+ * (chant#4512): the `knownOutputs` property core's executor reads a failed
1038
+ * step's outcome attributes from (core's `withKnownOutputs`). It is set here
1039
+ * rather than imported so the lexicon needs no newer core; an older executor
1040
+ * reads the error as any other failure.
1041
+ */
1042
+ function withKnownOutputs(err: unknown, outputs: object): Error {
1043
+ const e = err instanceof Error ? err : new Error(String(err));
1044
+ return Object.assign(e, { knownOutputs: outputs });
1045
+ }
938
1046
 
939
- return { dir, ...(root.workspace ? { workspace: root.workspace } : {}) };
1047
+ /**
1048
+ * `dir` relative to the chant workspace that holds it, `/`-separated, or
1049
+ * undefined outside a workspace or outside its root (chant#4482). The
1050
+ * workspace root `"."` stands for itself.
1051
+ */
1052
+ export function workspaceRelativePath(dir: string): string | undefined {
1053
+ const found = findWorkspaceRoot(dir);
1054
+ if (!found) return undefined;
1055
+ const rel = relative(found.dir, dir);
1056
+ if (rel.startsWith("..")) return undefined;
1057
+ return rel === "" ? "." : rel.split(sep).join("/");
940
1058
  }
941
1059
 
942
1060
  /**
943
1061
  * `terraform plan -detailed-exitcode -out=<planFile>` in the named root, then
944
- * `terraform show` over the written plan in both `-json` and `-no-color` form.
1062
+ * `terraform show` over the written plan in both `-json` and `-no-color` form
1063
+ * (`-json` alone with `text: false`).
945
1064
  *
946
1065
  * The exit code is the answer, not an error condition: 0 is a plan with no
947
1066
  * changes, 2 is a plan with changes, and anything else, 1 included, is a
@@ -989,7 +1108,7 @@ export async function terraformPlan(
989
1108
  }
990
1109
 
991
1110
  const jsonRun = await run(terraformShowCommand({ binary, json: true, planFile }), dir, env, signal);
992
- const textRun = await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal);
1111
+ const text = args.text === false ? "" : (await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal)).stdout;
993
1112
  const json: unknown = JSON.parse(jsonRun.stdout);
994
1113
 
995
1114
  return {
@@ -997,7 +1116,7 @@ export async function terraformPlan(
997
1116
  planFile,
998
1117
  dir,
999
1118
  json,
1000
- text: textRun.stdout,
1119
+ text,
1001
1120
  planDigest: terraformPlanDigest(json),
1002
1121
  changeSet: terraformChangeSetPart({
1003
1122
  member: args.root,
@@ -1092,7 +1211,11 @@ export async function terraformOutputs(
1092
1211
  args: TerraformRootArgs,
1093
1212
  signal?: AbortSignal,
1094
1213
  ): Promise<Record<string, unknown>> {
1095
- const { binary, root, dir } = await resolveRoot(args, signal);
1214
+ return outputsIn(await resolveRoot(args, signal), signal);
1215
+ }
1216
+
1217
+ /** `<binary> output -json` in a resolved root, as `{ name: value }`. */
1218
+ async function outputsIn({ binary, root, dir }: ResolvedRoot, signal?: AbortSignal): Promise<Record<string, unknown>> {
1096
1219
  const { stdout } = await run(`${binary} output -json`, dir, terraformEnvironment(root), signal);
1097
1220
  const parsed = JSON.parse(stdout || "{}") as Record<string, { value?: unknown }>;
1098
1221
  return Object.fromEntries(Object.entries(parsed).map(([name, entry]) => [name, entry?.value]));
@@ -1174,7 +1297,7 @@ export async function choudoufuWaveRoot(
1174
1297
  if (resolved.root.varFiles && resolved.root.varFiles.length > 0) {
1175
1298
  return { wave: false, reason: "the root has var files, which live-plan-set does not take" };
1176
1299
  }
1177
- if (resolved.root.backendConfig && Object.keys(resolved.root.backendConfig).length > 0) {
1300
+ if ((resolved.root.backendConfig && Object.keys(resolved.root.backendConfig).length > 0) || (resolved.root.backendConfigFiles?.length ?? 0) > 0) {
1178
1301
  return { wave: false, reason: "the root has backend config, which live-plan-set does not take" };
1179
1302
  }
1180
1303
  const root = choudoufuSetRootPath(resolved.projectRoot, resolved.dir);
@@ -1275,13 +1398,17 @@ export interface ChoudoufuPlanSetResult extends ChoudoufuPlanSet {
1275
1398
  * `live-wave-apply` hands back to choudoufu with the set digest.
1276
1399
  */
1277
1400
  export async function choudoufuPlanSet(args: TerraformRootArgs, signal?: AbortSignal): Promise<ChoudoufuPlanSetResult> {
1278
- const resolved = await resolveRoot(args, signal);
1401
+ return planSetIn(await resolveRoot(args, signal), args.root, signal);
1402
+ }
1403
+
1404
+ /** {@link choudoufuPlanSet} over a resolved root, `key` naming it in errors, in the waves directory and as the change-set member. */
1405
+ async function planSetIn(resolved: ResolvedRoot, key: string, signal?: AbortSignal): Promise<ChoudoufuPlanSetResult> {
1279
1406
  const { binary, root, dir, projectRoot } = resolved;
1280
- if (binary !== "choudoufu") throw new Error(`choudoufuPlanSet: root "${args.root}" runs ${binary}, not choudoufu`);
1407
+ if (binary !== "choudoufu") throw new Error(`choudoufuPlanSet: root "${key}" runs ${binary}, not choudoufu`);
1281
1408
  await ensureChoudoufuWavesVersion(binary, signal);
1282
1409
  const rootPath = choudoufuSetRootPath(projectRoot, dir);
1283
- if (rootPath === undefined) throw new Error(`choudoufuPlanSet: root "${args.root}" (${dir}) is not inside ${projectRoot}`);
1284
- const wavesDir = choudoufuWavesDir(projectRoot, args.root);
1410
+ if (rootPath === undefined) throw new Error(`choudoufuPlanSet: root "${key}" (${dir}) is not inside ${projectRoot}`);
1411
+ const wavesDir = choudoufuWavesDir(projectRoot, key);
1285
1412
  mkdirSync(wavesDir, { recursive: true });
1286
1413
  const cmd = choudoufuPlanSetCommand({ binary, root: rootPath, outDir: fromProject(projectRoot, join(wavesDir, "out")) });
1287
1414
 
@@ -1299,12 +1426,64 @@ export async function choudoufuPlanSet(args: TerraformRootArgs, signal?: AbortSi
1299
1426
  stdout = failure.stdout ?? "";
1300
1427
  }
1301
1428
 
1302
- const parsed = parseChoudoufuPlanSet(stdout, { member: args.root, root: rootPath });
1429
+ const parsed = parseChoudoufuPlanSet(stdout, { member: key, root: rootPath });
1303
1430
  const planSetFile = join(wavesDir, "plan-set.json");
1304
1431
  writeFileSync(planSetFile, stdout);
1305
1432
  return { ...parsed, planSetFile, root: rootPath };
1306
1433
  }
1307
1434
 
1435
+ // ── A choudoufu workspace member's outputs (#4455) ─────────────────────────
1436
+
1437
+ /**
1438
+ * A choudoufu member of the workspace, as `member-outputs` names it: not a
1439
+ * `terraform.roots` entry of the reading project but a directory the
1440
+ * workspace declaration gives. Its binary is `choudoufu`, it has no var
1441
+ * files, workspace or backend config, and it runs from `projectRoot`, a
1442
+ * directory above it (the workspace root), since `live-plan-set` plans only
1443
+ * roots inside the directory it runs in.
1444
+ */
1445
+ export interface ChoudoufuMemberArgs {
1446
+ /** The member's name: the waves directory's name, and the plan's member in errors. */
1447
+ member: string;
1448
+ /** The member's directory, absolute. */
1449
+ dir: string;
1450
+ /** Where `live-plan-set` runs from, absolute: a directory above `dir`. */
1451
+ projectRoot: string;
1452
+ }
1453
+
1454
+ async function resolveMemberRoot(args: ChoudoufuMemberArgs, signal?: AbortSignal): Promise<ResolvedRoot> {
1455
+ const binary = "choudoufu";
1456
+ await ensureChoudoufuVersion(binary, signal);
1457
+ const dir = resolve(args.dir);
1458
+ const estate = detectLiveEstate(dir);
1459
+ return { binary, root: { dir }, dir, ...(estate !== undefined ? { estate } : {}), live: estate !== undefined, projectRoot: resolve(args.projectRoot) };
1460
+ }
1461
+
1462
+ /**
1463
+ * The member's outputs as applied (#4455): `choudoufu init`, then `choudoufu
1464
+ * output -json` in its directory, the read the `terraform-apply` capability's
1465
+ * `outputs` makes for a root. A live estate's outputs come from its record
1466
+ * store.
1467
+ */
1468
+ export async function choudoufuMemberOutputs(args: ChoudoufuMemberArgs, signal?: AbortSignal): Promise<Record<string, unknown>> {
1469
+ const resolved = await resolveMemberRoot(args, signal);
1470
+ const { stdout, stderr } = await run(terraformInitCommand({ binary: resolved.binary }), resolved.dir, terraformEnvironment(resolved.root), signal);
1471
+ report(stdout, stderr);
1472
+ return outputsIn(resolved, signal);
1473
+ }
1474
+
1475
+ /**
1476
+ * The member planned as a one-root set (#4455), the way a choudoufu root in
1477
+ * a gated wave is ({@link choudoufuPlanSet}): `live-plan-set -json` from
1478
+ * `projectRoot`. The document's embedded plan carries `planned_values` and
1479
+ * `output_changes`, which say what the member's outputs will be.
1480
+ */
1481
+ export async function choudoufuMemberPlanSet(args: ChoudoufuMemberArgs, signal?: AbortSignal): Promise<ChoudoufuPlanSetResult> {
1482
+ const resolved = await resolveMemberRoot(args, signal);
1483
+ if (!resolved.live) throw new Error(`choudoufu member "${args.member}" (${resolved.dir}) declares no live estate, so live-plan-set cannot plan it`);
1484
+ return planSetIn(resolved, args.member, signal);
1485
+ }
1486
+
1308
1487
  /**
1309
1488
  * What a choudoufu root keeps in the fan-out's attempt record (#3459):
1310
1489
  * choudoufu's resume file, under the set digest it was written for.
@@ -1514,10 +1693,11 @@ export function terraformPlanReadCommand(opts: { binary: string; planFile: strin
1514
1693
  * The one line a person runs to initialise a root chant refuses to plan,
1515
1694
  * with the root's `backendConfig` carried, so it can be pasted as printed.
1516
1695
  */
1517
- export function terraformInitLine(opts: { binary: string; dir: string; backendConfig?: Record<string, string> }): string {
1696
+ export function terraformInitLine(opts: { binary: string; dir: string; backendConfig?: Record<string, string>; backendConfigFiles?: readonly string[] }): string {
1518
1697
  const init = terraformInitCommand({
1519
1698
  binary: opts.binary,
1520
1699
  ...(opts.backendConfig ? { backendConfig: opts.backendConfig } : {}),
1700
+ ...(opts.backendConfigFiles ? { backendConfigFiles: opts.backendConfigFiles } : {}),
1521
1701
  });
1522
1702
  return init.replace(`${opts.binary} init`, `${opts.binary} -chdir=${quoteArg(opts.dir)} init`);
1523
1703
  }
@@ -1629,7 +1809,12 @@ export async function terraformPlanRead(
1629
1809
  ): Promise<TerraformPlanReadResult> {
1630
1810
  const { binary, root, dir } = await resolveRoot(args, signal);
1631
1811
  const env = terraformEnvironment(root);
1632
- const initLine = terraformInitLine({ binary, dir, ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}) });
1812
+ const initLine = terraformInitLine({
1813
+ binary,
1814
+ dir,
1815
+ ...(root.backendConfig ? { backendConfig: root.backendConfig } : {}),
1816
+ ...(root.backendConfigFiles ? { backendConfigFiles: root.backendConfigFiles } : {}),
1817
+ });
1633
1818
  const notInitialised = (detail: string): TerraformPlanReadError =>
1634
1819
  new TerraformPlanReadError(
1635
1820
  "not-initialised",
@@ -1912,6 +2097,65 @@ export async function choudoufuLivePlan(
1912
2097
  };
1913
2098
  }
1914
2099
 
2100
+ /** What {@link choudoufuLiveGraph} resolved. */
2101
+ export type ChoudoufuLiveGraphResult =
2102
+ | {
2103
+ /** choudoufu printed the engine graph document. */
2104
+ supported: true;
2105
+ /** The document, parsed: a GraphIR v1 with `meta.engineGraph` (chant#4527's schema). */
2106
+ json: unknown;
2107
+ /** Absolute path of the root module directory. */
2108
+ dir: string;
2109
+ }
2110
+ | {
2111
+ /** This choudoufu has no `live-graph -from-plan` (older than {@link MIN_CHOUDOUFU_GRAPH_VERSION}). */
2112
+ supported: false;
2113
+ /** choudoufu's own first line, for the note. */
2114
+ detail: string;
2115
+ dir: string;
2116
+ };
2117
+
2118
+ export interface ChoudoufuLiveGraphArgs extends TerraformRootArgs {
2119
+ /** Absolute path of the `live-plan -json` document this graph takes its facts from. */
2120
+ fromPlan: string;
2121
+ /** The estate (`-estate`), for a root that does not declare its own. */
2122
+ estate?: string;
2123
+ }
2124
+
2125
+ /**
2126
+ * `choudoufu live-graph -json -from-plan=<file>` (choudoufu#2285, read by
2127
+ * chant#4528): the root's graph as one engine graph document, its facts taken
2128
+ * from a `live-plan -json` document this process already read. No second live
2129
+ * read: choudoufu builds the graph from the configuration and that file
2130
+ * alone, starts no provider and calls no cloud, so facts and verdicts come
2131
+ * from the same refresh (choudoufu #1014).
2132
+ *
2133
+ * A usage error ({@link classifyLiveGraphFailure}) resolves `supported:
2134
+ * false` rather than throwing, since the caller degrades to no facts; any
2135
+ * other failure throws with choudoufu's output. Warnings (an empty scope
2136
+ * estate, a plan without `observed_at`) go to stderr and are echoed like
2137
+ * every other activity's.
2138
+ */
2139
+ export async function choudoufuLiveGraph(args: ChoudoufuLiveGraphArgs, signal?: AbortSignal): Promise<ChoudoufuLiveGraphResult> {
2140
+ const resolved = await resolveRoot(args, signal);
2141
+ const { binary, dir } = resolved;
2142
+ const env = terraformEnvironment(resolved.root);
2143
+ const estateFlag: { estate?: string } = resolved.estate === undefined && args.estate !== undefined ? { estate: args.estate } : {};
2144
+ const cmd = choudoufuLiveGraphCommand({ binary, fromPlan: args.fromPlan, ...estateFlag });
2145
+ try {
2146
+ const { stdout, stderr } = await run(cmd, dir, env, signal);
2147
+ if (stderr) console.error(stderr);
2148
+ return { supported: true, json: JSON.parse(liveDocumentFrom(stdout)), dir };
2149
+ } catch (err) {
2150
+ const failure = err as ExecFailure;
2151
+ if (typeof failure.code !== "number") throw err;
2152
+ const output = `${failure.stderr ?? ""}\n${failure.stdout ?? ""}`;
2153
+ const detail = output.trim().split("\n").find((line) => line.trim() !== "")?.trim() ?? `exit ${failure.code}`;
2154
+ if (classifyLiveGraphFailure(failure.code, output) === "unsupported") return { supported: false, detail, dir };
2155
+ throw new Error(`${binary} live-graph failed in ${dir} (exit ${failure.code})${detail ? `\n${output.trim()}` : ""}`);
2156
+ }
2157
+ }
2158
+
1915
2159
  /**
1916
2160
  * `choudoufu live-ls -estate=<estate> -json [-consistent]`: every resource
1917
2161
  * the account holds under the estate, read straight from the Resource Groups
@@ -23,6 +23,7 @@ const ACTIVITY_NAMES = [
23
23
  "choudoufuLiveLs",
24
24
  "choudoufuLiveCheck",
25
25
  "choudoufuAdopt",
26
+ "choudoufuPlanSet",
26
27
  "terraformPinRollout",
27
28
  ];
28
29
 
@@ -66,7 +66,8 @@ export const terraformInitContract = activityContract(
66
66
  upgrade: z.boolean().optional(),
67
67
  reconfigure: z.boolean().optional(),
68
68
  }),
69
- z.object({ dir: z.string(), workspace: z.string().optional() }),
69
+ // `rootPath` (#4482) is what TerraformApplyOp's Init step publishes as `TerraformRootPath`; OPS012 checks it here.
70
+ z.object({ dir: z.string(), workspace: z.string().optional(), rootPath: z.string().optional() }),
70
71
  rootEntity,
71
72
  );
72
73
 
@@ -181,6 +182,28 @@ export const choudoufuAdoptContract = activityContract(
181
182
  rootEntity,
182
183
  );
183
184
 
185
+ /**
186
+ * `choudoufuPlanSet` (#4483): one live choudoufu root planned as a one-root
187
+ * set. `changeSet` is the root's part, the field an Op hands to
188
+ * `composeChangeSet`; `planDigest` is what a gate binds; `planSetFile` and
189
+ * `setDigest` are what a later `live-wave-apply` takes.
190
+ */
191
+ export const choudoufuPlanSetContract = activityContract(
192
+ "choudoufuPlanSet",
193
+ z.strictObject({ ...rootArgs }),
194
+ z.object({
195
+ setDigest: z.string(),
196
+ rootDigest: z.string(),
197
+ planDigest: z.string(),
198
+ changed: z.boolean(),
199
+ json: z.unknown(),
200
+ changeSet: z.object({ member: z.object({ member: z.string(), planDigest: z.string().nullable() }).passthrough(), entries: z.array(z.unknown()) }),
201
+ planSetFile: z.string(),
202
+ root: z.string(),
203
+ }),
204
+ rootEntity,
205
+ );
206
+
184
207
  /** One root of a pin-bump rollout ({@link PinRoot} in `../pin/waves.ts`). */
185
208
  const pinRoot = z.strictObject({ root: z.string(), dependsOn: z.array(z.string()).optional(), tsSource: z.string().optional() });
186
209
 
@@ -31,6 +31,7 @@ import type {
31
31
  ChoudoufuLiveLsArgs,
32
32
  ChoudoufuLiveCheckArgs,
33
33
  ChoudoufuAdoptArgs,
34
+ TerraformRootArgs,
34
35
  } from "./activities/terraform";
35
36
  import type { TerraformPinRolloutArgs } from "./activities/pin-rollout";
36
37
 
@@ -186,6 +187,19 @@ export const terraformShow = (
186
187
  );
187
188
  };
188
189
 
190
+ /**
191
+ * `choudoufuPlanSet` (#4483): `live-plan-set -json` over one live choudoufu
192
+ * root. Hand `step.out.changeSet` from each root's step to `composeChangeSet`
193
+ * to make one change-set document over several roots, such as one per cloud.
194
+ */
195
+ export const choudoufuPlanSet = (
196
+ root: string,
197
+ opts?: WithStepRefs<Omit<TerraformRootArgs, "root">> & StepOpts,
198
+ ): NamedActivityStep => {
199
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
200
+ return activity("choudoufuPlanSet", { root, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
201
+ };
202
+
189
203
  /**
190
204
  * One run of a pin-bump rollout (#3189). `opts` is
191
205
  * {@link TerraformPinRolloutArgs} itself. It names no root: the rollout finds
@@ -38,6 +38,8 @@ describe("the pin bundle", () => {
38
38
  expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
39
39
  }
40
40
  expect(inputs.map((i) => i.replace(/^.*?(packages|lexicons)\//, "$1/")).sort()).toEqual([
41
+ // The one config-file lister (chant#4457): node built-ins only, kept apart from the HCL parser.
42
+ "lexicons/terraform/src/hcl/files.ts",
41
43
  "lexicons/terraform/src/pin/edit.ts",
42
44
  "lexicons/terraform/src/pin/forge.ts",
43
45
  "lexicons/terraform/src/pin/index.ts",
package/src/pin/edit.ts CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  import type { Hcl2Json } from "@intentius/chant/terraform/hcl2json";
26
26
  import { movePin, type PinMove } from "./source";
27
+ import { isJsonConfigFile, jsonConfigTree } from "../hcl/files";
27
28
  import { escapeHcl, scanBlocks, unescapeHcl, type ScannedAttribute, type ScannedBlock } from "./scan";
28
29
 
29
30
  /** What to move: a module, named by its source without the pin, from one pin to another. */
@@ -99,6 +100,7 @@ interface Splice {
99
100
  * is reported with its reason and left as it was.
100
101
  */
101
102
  export async function editPins(text: string, file: string, request: PinRequest, parser: Hcl2Json): Promise<PinEditResult> {
103
+ if (isJsonConfigFile(file)) return jsonPins(text, file, request);
102
104
  const terragrunt = isTerragruntFile(file);
103
105
  const tree = (await parser.parse(file, text)) as Tree;
104
106
  const blocks = scanBlocks(text, file);
@@ -155,3 +157,31 @@ export async function editPins(text: string, file: string, request: PinRequest,
155
157
  function rewriteLiteral(raw: string, was: string, value: string): string {
156
158
  return raw === was ? value : escapeHcl(value);
157
159
  }
160
+
161
+ /**
162
+ * The calls of the module in a `.tf.json` or `.tofu.json` file (chant#4457).
163
+ * The pin edit splices HCL text and has no splice for JSON syntax, so a call
164
+ * that would move is refused by name and the file is left as it is; a call
165
+ * already at the new pin, or at another one, reads as it does in HCL.
166
+ */
167
+ function jsonPins(text: string, file: string, request: PinRequest): PinEditResult {
168
+ const calls: PinCallResult[] = [];
169
+ let tree: Record<string, unknown>;
170
+ try {
171
+ tree = jsonConfigTree(text);
172
+ } catch (err) {
173
+ return { content: text, calls: [{ outcome: "refused", reason: `not JSON the binary reads: ${err instanceof Error ? err.message : String(err)}`, file, call: "(file)" }] };
174
+ }
175
+ const modules = (tree.module && typeof tree.module === "object" ? tree.module : {}) as Record<string, unknown>;
176
+ for (const [name, bodies] of Object.entries(modules)) {
177
+ for (const body of Array.isArray(bodies) ? (bodies as Record<string, unknown>[]) : []) {
178
+ if (typeof body.source !== "string") continue;
179
+ const move = movePin(body.source, typeof body.version === "string" ? body.version : undefined, request);
180
+ if (!move) continue;
181
+ const at = { file, call: `module.${name}` };
182
+ if (move.outcome === "moved") calls.push({ outcome: "refused", reason: "the call is in Terraform's JSON syntax, which the pin edit does not write; move its pin by hand", ...at });
183
+ else calls.push({ ...move, ...at });
184
+ }
185
+ }
186
+ return { content: text, calls };
187
+ }
@@ -48,6 +48,7 @@ import { editPins, type PinCallResult, type PinEditResult, type PinRequest } fro
48
48
  import { checkPinRequest, moduleOf } from "./source";
49
49
  import { planPinWaves, restrictWaves, terragruntDependencies, type PinRoot, type PinWave } from "./waves";
50
50
  import { ghPinForge, type PinForge } from "./forge";
51
+ import { isConfigFileFor, listConfigFilesSafe } from "../hcl/files";
51
52
 
52
53
  const execFileAsync = /* @__PURE__ */ promisify(execFile);
53
54
 
@@ -83,6 +84,8 @@ export interface PinRolloutOptions extends PinRequest {
83
84
  editTs?: TsPinEditor;
84
85
  /** Markdown appended to a wave's PR body, such as the grouped plan summary of #3188 once a caller has the wave's plans. */
85
86
  planSummary?: (wave: PinWave) => Promise<string | undefined>;
87
+ /** The binary the roots run: `tofu` or `choudoufu` reads `.tofu` files too (chant#4457). Default `terraform`. */
88
+ binary?: string;
86
89
  }
87
90
 
88
91
  /** Where one root's pin stood on the base branch. */
@@ -169,22 +172,24 @@ export function pinWaveBranch(request: PinRequest, wave: number): string {
169
172
  return `chant/pin/${slug(request.module)}/${slug(request.to)}/wave-${wave}`;
170
173
  }
171
174
 
172
- /** The files the pin edit reads in a root directory: its `.tf` files and its `terragrunt.hcl`. */
173
- function rootFiles(worktree: string, root: string): string[] {
175
+ /**
176
+ * The files the pin edit reads in a root directory: the configuration files
177
+ * the root's binary reads (the lexicon's one lister, chant#4457) and its
178
+ * `terragrunt.hcl`.
179
+ */
180
+ function rootFiles(worktree: string, root: string, binary?: string): string[] {
174
181
  const dir = join(worktree, root);
175
182
  if (!existsSync(dir)) return [];
176
- return readdirSync(dir)
177
- .filter((f) => f.endsWith(".tf") || f === "terragrunt.hcl")
178
- .sort()
179
- .map((f) => posix.join(root, f));
183
+ const names = [...listConfigFilesSafe(dir, binary), ...(existsSync(join(dir, "terragrunt.hcl")) ? ["terragrunt.hcl"] : [])];
184
+ return names.sort().map((f) => posix.join(root, f));
180
185
  }
181
186
 
182
- /** Every directory under the worktree with a `.tf` file or a `terragrunt.hcl`. */
183
- function candidateRoots(worktree: string): string[] {
187
+ /** Every directory under the worktree with a configuration file the binary reads, or a `terragrunt.hcl`. */
188
+ function candidateRoots(worktree: string, binary?: string): string[] {
184
189
  const out: string[] = [];
185
190
  const walk = (rel: string) => {
186
191
  const entries = readdirSync(join(worktree, rel), { withFileTypes: true });
187
- if (entries.some((e) => e.isFile() && (e.name.endsWith(".tf") || e.name === "terragrunt.hcl"))) out.push(rel || ".");
192
+ if (entries.some((e) => e.isFile() && (isConfigFileFor(e.name, binary) || e.name === "terragrunt.hcl"))) out.push(rel || ".");
188
193
  for (const e of entries) if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(rel ? posix.join(rel, e.name) : e.name);
189
194
  };
190
195
  walk("");
@@ -201,7 +206,7 @@ async function editRoot(worktree: string, root: PinRoot, request: PinRequest, op
201
206
  return { state: { root: root.root, state: "refused", files, calls, reason: `generated from ${root.tsSource}, and this caller has no TypeScript editor` }, edits };
202
207
  }
203
208
  } else {
204
- files = rootFiles(worktree, root.root);
209
+ files = rootFiles(worktree, root.root, options.binary);
205
210
  }
206
211
  for (const file of files) {
207
212
  const path = join(worktree, file);
@@ -302,7 +307,7 @@ export async function runPinRollout(options: PinRolloutOptions): Promise<PinRoll
302
307
  await run("git", ["worktree", "add", "--quiet", "--detach", worktree, baseRef], repo);
303
308
  try {
304
309
  // 1. Every root's pin on the base branch.
305
- const declared = (options.roots ?? candidateRoots(worktree).map((root): PinRoot => ({ root }))).map((r) => ({
310
+ const declared = (options.roots ?? candidateRoots(worktree, options.binary).map((root): PinRoot => ({ root }))).map((r) => ({
306
311
  ...r,
307
312
  root: dirOf(r.root),
308
313
  ...(r.dependsOn ? { dependsOn: r.dependsOn.map(dirOf) } : {}),