@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
@@ -16,11 +16,22 @@
16
16
  * Every terraform `resource` block is `Terraform::Resource` to chant. A rule
17
17
  * keyed on that would apply to a VPC and an instance alike, so the nodes
18
18
  * `./request.ts` builds for the resolver carry the **provider type** as their
19
- * `kind` — `aws_subnet` — and the rules here are keyed the same way. That is
20
- * also why this catalog is not declared on the plugin as `referenceCatalog`:
21
- * `chant graph --live` builds its nodes with `kind: "Terraform::Resource"`
22
- * and attributes that carry no arguments at all, so declaring it there would
23
- * be a catalog that matched nothing and looked like coverage.
19
+ * `kind` — `aws_subnet` — and the rules here are keyed the same way.
20
+ *
21
+ * ## On the plugin (#4535)
22
+ *
23
+ * The plugin declares it as `referenceCatalog` since #4535, so a reader that
24
+ * asks every lexicon of a graph for its catalog (behold#1030's gate, before
25
+ * behold#1005 retires behold's name matcher) finds terraform's. What it
26
+ * matches depends on the nodes. A declared or live `chant graph` node is
27
+ * `Terraform::Resource`, with its provider type in `attrs.type` and no
28
+ * arguments in `attrs`, so no rule here reads it: those graphs keep the edges
29
+ * the HCL references and the state dependencies give them, and gain none. A
30
+ * node whose `kind` is the provider type and whose `attrs` are the block's
31
+ * arguments (`./request.ts`'s, an engine's instance roster) is what the rules
32
+ * are for. `chant graph --live` merges a catalog's edges with the
33
+ * observation's own since #4535; before, any catalog replaced them, which is
34
+ * what kept this one off the plugin.
24
35
  *
25
36
  * ## Two relations, and why the split matters to an engine
26
37
  *
@@ -329,8 +329,9 @@ function liveVerdictOf(address: string, plan: LivePlanIndex): LiveVerdict {
329
329
  kind: "unobserved",
330
330
  instances,
331
331
  // `no-credentials` is an observation reason and not a prediction one;
332
- // the credential wording stays in the detail.
333
- reason: unobserved.reason === "no-credentials" ? "read-failed" : unobserved.reason,
332
+ // the credential wording stays in the detail. `not-reached` is a
333
+ // stream's reason (#4451) and a live-plan omission never gives it.
334
+ reason: unobserved.reason === "no-credentials" || unobserved.reason === "not-reached" ? "read-failed" : unobserved.reason,
334
335
  detail: unobserved.detail,
335
336
  };
336
337
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A `count = 3` resource's stored change set carries three node ids (#4454).
3
+ *
4
+ * The graph is read in process the way `chant graph --format ir` builds it
5
+ * for a terraform root: the root's entities, `buildGraphIr`, then
6
+ * `withInstanceNodes`. The plan is a `show -json` shape with one
7
+ * `resource_changes` row per instance, as terraform prints a `count` block.
8
+ */
9
+
10
+ import { describe, expect, test } from "vitest";
11
+ import { composeChangeSet, verifyChangeSetDigest } from "@intentius/chant/change-set";
12
+ import { graphNodeFor, mapChangeSetToGraph } from "@intentius/chant/change-set-graph";
13
+ import { buildGraphIr, withInstanceNodes } from "@intentius/chant/graph-ir";
14
+ import { terraformChangeSetPart } from "./change-set";
15
+ import { literalInstanceKeys, parseTerraformRootContent } from "./hcl/parse";
16
+
17
+ const SOURCE = `resource "aws_eip" "pool" {
18
+ count = 3
19
+ domain = "vpc"
20
+ }
21
+
22
+ resource "aws_s3_bucket" "logs" {
23
+ bucket = "logs"
24
+ }
25
+ `;
26
+
27
+ const create = (address: string, index?: number) => ({
28
+ address,
29
+ mode: "managed",
30
+ type: address.split(".")[0],
31
+ name: address.split(".")[1]!.replace(/\[.*$/, ""),
32
+ ...(index !== undefined ? { index } : {}),
33
+ change: { actions: ["create"], before: null, after: { domain: "vpc" }, after_unknown: { id: true } },
34
+ });
35
+
36
+ const PLAN = {
37
+ format_version: "1.2",
38
+ resource_changes: [create("aws_eip.pool[0]", 0), create("aws_eip.pool[1]", 1), create("aws_eip.pool[2]", 2), create("aws_s3_bucket.logs")],
39
+ };
40
+
41
+ describe("instance nodes for a count block", () => {
42
+ test("a literal count gives the block three instance keys", () => {
43
+ expect(literalInstanceKeys({ count: 3 })).toEqual(["[0]", "[1]", "[2]"]);
44
+ expect(literalInstanceKeys({ count: "${var.n}" })).toBeUndefined();
45
+ expect(literalInstanceKeys({ for_each: { b: 1, a: 2 } })).toEqual(['["a"]', '["b"]']);
46
+ expect(literalInstanceKeys({ for_each: '${toset(["x", "y"])}' })).toEqual(['["x"]', '["y"]']);
47
+ });
48
+
49
+ test("the graph has the block node and one instance node per index, each naming the block", async () => {
50
+ const entities = await parseTerraformRootContent(SOURCE, "estate");
51
+ const ir = withInstanceNodes(buildGraphIr(entities), entities);
52
+ const pool = ir.nodes.filter((n) => n.id.startsWith("estate/aws_eip.pool"));
53
+ expect(pool.map((n) => [n.id, n.instanceOf ?? null, n.instanceKey ?? null])).toEqual([
54
+ ["estate/aws_eip.pool", null, null],
55
+ ["estate/aws_eip.pool[0]", "estate/aws_eip.pool", "[0]"],
56
+ ["estate/aws_eip.pool[1]", "estate/aws_eip.pool", "[1]"],
57
+ ["estate/aws_eip.pool[2]", "estate/aws_eip.pool", "[2]"],
58
+ ]);
59
+ expect(ir.groups.byStack?.estate).toEqual(expect.arrayContaining(["estate/aws_eip.pool[0]", "estate/aws_eip.pool[2]"]));
60
+ });
61
+
62
+ test("the stored change set carries three node ids for the three instances", async () => {
63
+ const entities = await parseTerraformRootContent(SOURCE, "estate");
64
+ const ir = withInstanceNodes(buildGraphIr(entities), entities);
65
+ const doc = composeChangeSet([terraformChangeSetPart({ member: "estate", plan: PLAN })]);
66
+ const mapped = mapChangeSetToGraph(doc, { member: "infra", at: null, ids: ir.nodes.map((n) => n.id) });
67
+
68
+ const pool = mapped.entries.filter((e) => e.type === "aws_eip");
69
+ expect(pool.map((e) => [e.address, e.node, e.instanceOf])).toEqual([
70
+ ["aws_eip.pool[0]", "infra/estate/aws_eip.pool[0]", "infra/estate/aws_eip.pool"],
71
+ ["aws_eip.pool[1]", "infra/estate/aws_eip.pool[1]", "infra/estate/aws_eip.pool"],
72
+ ["aws_eip.pool[2]", "infra/estate/aws_eip.pool[2]", "infra/estate/aws_eip.pool"],
73
+ ]);
74
+ expect(new Set(pool.map((e) => e.node)).size).toBe(3);
75
+ const logs = mapped.entries.find((e) => e.address === "aws_s3_bucket.logs")!;
76
+ expect(logs.node).toBe("infra/estate/aws_s3_bucket.logs");
77
+ expect(logs.instanceOf).toBeUndefined();
78
+ // Node ids are not hashed.
79
+ expect(mapped.digest).toBe(doc.digest);
80
+ expect(verifyChangeSetDigest(mapped)).toBe(true);
81
+ });
82
+
83
+ test("an instance the graph cannot count still gets its instance id, and a drift row the same", async () => {
84
+ const entities = await parseTerraformRootContent(SOURCE.replace("count = 3", "count = var.n"), "estate");
85
+ const ir = withInstanceNodes(buildGraphIr(entities), entities);
86
+ expect(ir.nodes.some((n) => n.instanceOf)).toBe(false);
87
+ const graph = { member: null, at: null, ids: ir.nodes.map((n) => n.id) };
88
+ expect(graphNodeFor({ member: "estate", planner: "terraform", address: "aws_eip.pool[1]" }, graph)).toMatchObject({
89
+ node: "estate/aws_eip.pool[1]",
90
+ instanceOf: "estate/aws_eip.pool",
91
+ });
92
+ // Under an expanded module call the module key sits on the call's segment (#4486).
93
+ expect(graphNodeFor({ member: "estate", planner: "terraform", address: "module.app[0].aws_eip.pool[1]" }, { ...graph, ids: ["estate/module.app/aws_eip.pool"] })).toEqual({
94
+ node: "estate/module.app[0]/aws_eip.pool[1]",
95
+ instanceOf: "estate/module.app/aws_eip.pool",
96
+ });
97
+ });
98
+ });
@@ -9,16 +9,19 @@
9
9
  * in a local module it calls (followed through modules that call modules),
10
10
  * or is one of its var files.
11
11
  *
12
- * The module calls are read with a regex over the root's `.tf` and `.tofu`
12
+ * The module calls are read with a regex over the root's HCL configuration
13
13
  * files, the way `./op/activities/live-detect.ts` reads a live block, so
14
- * this stays out of the HCL parser's dependency graph. Only a `source` that
14
+ * this stays out of the HCL parser's dependency graph. Which files those are
15
+ * follows the root's binary through the lexicon's one lister (chant#4457):
16
+ * `.tf`, plus `.tofu` for a tofu or choudoufu root. Only a `source` that
15
17
  * starts with `./` or `../` is followed; a registry or git source is a
16
18
  * version pin in the root's own files, which a change to it already touches.
17
19
  */
18
20
 
19
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { existsSync, readFileSync, statSync } from "node:fs";
20
22
  import { join, posix, relative, resolve, sep } from "node:path";
21
23
  import type { TerraformRootConfig } from "./config";
24
+ import { isJsonConfigFile, listConfigFilesSafe } from "./hcl/files";
22
25
 
23
26
  /** `source = "./..."` or `"../..."` inside a module block. Other sources are not on disk here. */
24
27
  const LOCAL_SOURCE = /\bsource\s*=\s*"(\.{1,2}\/[^"]*)"/g;
@@ -31,17 +34,11 @@ function projectRelative(projectRoot: string, dir: string): string {
31
34
  return rel === "" ? "." : posix.normalize(rel).replace(/\/+$/, "");
32
35
  }
33
36
 
34
- /** Local module directories `dir` calls, read from its HCL files. */
35
- function localModuleDirs(dir: string): string[] {
36
- let names: string[];
37
- try {
38
- names = readdirSync(dir);
39
- } catch {
40
- return [];
41
- }
37
+ /** Local module directories `dir` calls, read from the HCL configuration files `binary` reads. */
38
+ function localModuleDirs(dir: string, binary?: string): string[] {
42
39
  const out: string[] = [];
43
- for (const name of names) {
44
- if (!name.endsWith(".tf") && !name.endsWith(".tofu")) continue;
40
+ for (const name of listConfigFilesSafe(dir, binary)) {
41
+ if (isJsonConfigFile(name)) continue;
45
42
  let text: string;
46
43
  try {
47
44
  text = readFileSync(join(dir, name), "utf8");
@@ -61,7 +58,7 @@ function localModuleDirs(dir: string): string[] {
61
58
  * Every path, relative to `projectRoot`, whose change touches `root`: its
62
59
  * directory, each local module it reaches, and its var files.
63
60
  */
64
- export function rootWatchPaths(projectRoot: string, root: Pick<TerraformRootConfig, "dir" | "varFiles">): string[] {
61
+ export function rootWatchPaths(projectRoot: string, root: Pick<TerraformRootConfig, "dir" | "varFiles">, binary?: string): string[] {
65
62
  const rootDir = resolve(projectRoot, root.dir);
66
63
  const seen = new Set<string>();
67
64
  const queue = [rootDir];
@@ -69,7 +66,7 @@ export function rootWatchPaths(projectRoot: string, root: Pick<TerraformRootConf
69
66
  const dir = queue.shift()!;
70
67
  if (seen.has(dir)) continue;
71
68
  seen.add(dir);
72
- queue.push(...localModuleDirs(dir));
69
+ queue.push(...localModuleDirs(dir, binary));
73
70
  }
74
71
  const paths = [...seen].map((d) => projectRelative(projectRoot, d));
75
72
  for (const file of root.varFiles ?? []) paths.push(projectRelative(projectRoot, resolve(rootDir, file)));
@@ -91,11 +88,12 @@ export function changedRoots(
91
88
  projectRoot: string,
92
89
  roots: Record<string, Pick<TerraformRootConfig, "dir" | "varFiles">>,
93
90
  changedFiles: readonly string[],
91
+ binary?: string,
94
92
  ): string[] {
95
93
  const files = changedFiles.map((f) => posix.normalize(f));
96
94
  const touched: string[] = [];
97
95
  for (const [name, root] of Object.entries(roots)) {
98
- const watch = rootWatchPaths(projectRoot, root);
96
+ const watch = rootWatchPaths(projectRoot, root, binary);
99
97
  if (files.some((f) => watch.some((p) => covers(p, f)))) touched.push(name);
100
98
  }
101
99
  return touched.sort();
package/src/commands.ts CHANGED
@@ -22,9 +22,9 @@ import type { PinRoot } from "./pin";
22
22
  const USAGE =
23
23
  "usage: chant terraform pin-rollout --module <source> --from <pin> --to <pin> [--root <dir>]... [--depends-on <dir>=<dir>]... " +
24
24
  "[--ts-source <dir>=<file>]... [--canary <dir>]... [--waves-from <file> [--waves-prefix <dir>]] [--pull-request] " +
25
- "[--base <branch>] [--remote <name>] [--applied-check <name>] [--json]";
25
+ "[--base <branch>] [--remote <name>] [--applied-check <name>] [--binary <terraform|tofu|choudoufu>] [--json]";
26
26
 
27
- const VALUE_FLAGS = new Set(["--module", "--from", "--to", "--root", "--depends-on", "--ts-source", "--canary", "--waves-from", "--waves-prefix", "--base", "--remote", "--applied-check"]);
27
+ const VALUE_FLAGS = new Set(["--module", "--from", "--to", "--root", "--depends-on", "--ts-source", "--canary", "--waves-from", "--waves-prefix", "--base", "--remote", "--applied-check", "--binary"]);
28
28
  const BOOLEAN_FLAGS = new Set(["--pull-request", "--json"]);
29
29
 
30
30
  /** Parse the verb's flags into the activity's args. Throws on an unknown flag or a missing value. */
@@ -75,6 +75,7 @@ export function parsePinRolloutArgs(rawArgs: string[]): { args: TerraformPinRoll
75
75
  ...(one("--base") ? { base: one("--base")! } : {}),
76
76
  ...(one("--remote") ? { remote: one("--remote")! } : {}),
77
77
  ...(one("--applied-check") ? { appliedCheck: one("--applied-check")! } : {}),
78
+ ...(one("--binary") ? { binary: one("--binary")! } : {}),
78
79
  };
79
80
  return { args, json: flags.has("--json") };
80
81
  }
@@ -186,6 +187,6 @@ export const terraformCommands: CommandGroup = {
186
187
  description: "Terraform and OpenTofu roots: roll a module version out as one pin-bump PR per wave; write a run report from a saved plan",
187
188
  commands: [
188
189
  { name: "pin-rollout", description: "Read a pin-bump rollout's state and open the next wave's PR when it is due", handler: runPinRolloutVerb },
189
- { name: "report", description: "Write a terragucci.report/v1 run report and its index row from show -json of a composed plan", handler: runReportVerb },
190
+ { name: "report", description: "Write a terragucci.report/v1 run report, its change set and its index row from show -json of a composed plan", handler: runReportVerb },
190
191
  ],
191
192
  };
@@ -3,7 +3,10 @@
3
3
  * and run applies the plan a wave approved rather than planning again.
4
4
  */
5
5
 
6
- import { beforeEach, describe, expect, test, vi } from "vitest";
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7
10
 
8
11
  const calls: string[] = [];
9
12
  let applyResult: Record<string, unknown> = { applied: true };
@@ -15,6 +18,10 @@ let waveApply: Record<string, unknown> = { exitCode: 0, landed: true, message: "
15
18
  const waveApplyArgs: Array<Record<string, unknown>> = [];
16
19
  // #4300: the targets each plan was asked for.
17
20
  const planTargets: Array<string[] | undefined> = [];
21
+ // #4479: the `text` each plan was asked for.
22
+ const planTexts: Array<boolean | undefined> = [];
23
+ // #4463: a plan that fails, as one with a stand-in a validation block rejects does.
24
+ let planError: Error | undefined;
18
25
 
19
26
  vi.mock("../op/activities/terraform", () => ({
20
27
  choudoufuWaveRoot: vi.fn(async () => waveRoot),
@@ -25,7 +32,7 @@ vi.mock("../op/activities/terraform", () => ({
25
32
  rootDigest: `sha256:root-${args.root}`,
26
33
  planDigest: `jcs1-sha256:${args.root}`,
27
34
  changed: true,
28
- json: {},
35
+ json: planJson,
29
36
  changeSet: { member: { member: args.root, nativeDigest: `sha256:root-${args.root}` }, entries: [] },
30
37
  planSetFile: `/x/.chant/choudoufu-waves/${args.root}/plan-set.json`,
31
38
  root: `estates/${args.root}`,
@@ -40,9 +47,11 @@ vi.mock("../op/activities/terraform", () => ({
40
47
  calls.push(`init ${args.root}`);
41
48
  return { dir: `/x/${args.root}` };
42
49
  }),
43
- terraformPlan: vi.fn(async (args: { root: string; vars?: Record<string, unknown>; targets?: string[] }) => {
50
+ terraformPlan: vi.fn(async (args: { root: string; vars?: Record<string, unknown>; targets?: string[]; text?: boolean }) => {
44
51
  calls.push(`plan ${args.root} ${JSON.stringify(args.vars ?? {})}`);
45
52
  planTargets.push(args.targets);
53
+ planTexts.push(args.text);
54
+ if (planError) throw planError;
46
55
  return { planFile: "chant.tfplan", planDigest: `jcs1-sha256:${args.root}`, json: planJson, ...(changeSet ? { changeSet } : {}) };
47
56
  }),
48
57
  terraformApply: vi.fn(async (args: { root: string; planFile: string }) => {
@@ -67,6 +76,19 @@ beforeEach(() => {
67
76
  waveApply = { exitCode: 0, landed: true, message: "landed" };
68
77
  waveApplyArgs.length = 0;
69
78
  planTargets.length = 0;
79
+ planTexts.length = 0;
80
+ planError = undefined;
81
+ });
82
+
83
+ describe("terraform-apply plans with one show -json (#4479)", () => {
84
+ test("plan and run ask terraformPlan to skip the -no-color render, and plan takes outputs and shape off the one json", async () => {
85
+ planJson = { planned_values: { outputs: { id: { value: "x" } } }, resource_changes: [] };
86
+ const planned = await terraformApplyCapability.plan!({ env: "test", component: "e01" }, { root: "e01" });
87
+ await terraformApplyCapability.run({ env: "test", component: "e01" }, { root: "e01" });
88
+ expect(planTexts).toEqual([false, false]);
89
+ expect(planned.outputs).toEqual({ known: { id: "x" }, unknown: [] });
90
+ expect(planned.shape).toBeDefined();
91
+ });
70
92
  });
71
93
 
72
94
  describe("terraform-apply targets (#4300)", () => {
@@ -191,10 +213,35 @@ describe("terraform-apply", () => {
191
213
  planDigest: "jcs1-sha256:app",
192
214
  artifact: { planFile: "chant.tfplan", planDigest: "jcs1-sha256:app" },
193
215
  outputs: { known: {}, unknown: [] },
216
+ shape: { member: "app", entries: [], unknownInputs: [] },
194
217
  });
195
218
  expect(calls).toEqual(["init app", 'plan app {"vpc":"v-1"}']);
196
219
  });
197
220
 
221
+ test("plan returns the shape of the plan's show -json, on the plain path and the choudoufu set path (#4436)", async () => {
222
+ planJson = {
223
+ resource_changes: [
224
+ { address: "terraform_data.keep", type: "terraform_data", change: { actions: ["no-op"] } },
225
+ { address: "terraform_data.web", type: "terraform_data", change: { actions: ["create"], before: null, after: { input: "x" }, after_unknown: { id: true } } },
226
+ ],
227
+ };
228
+ const expected = {
229
+ member: "app",
230
+ entries: [{ logicalId: "terraform_data.web", action: "create", type: "terraform_data", properties: ["id", "input"], unknown: ["id"] }],
231
+ unknownInputs: [],
232
+ };
233
+ const plain = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app" });
234
+ expect(plain.shape).toEqual(expected);
235
+ waveRoot = { wave: true, root: "estates/app" };
236
+ const set = await terraformApplyCapability.plan!({ env: "test", component: "app" }, { root: "app" });
237
+ expect(set.shape).toEqual(expected);
238
+ });
239
+
240
+ test("a root waiting on unknown outputs that cannot be previewed returns no shape, so it stays uncovered (#4436)", async () => {
241
+ const planned = await terraformApplyCapability.plan!({ env: "test", component: "app", unresolvedInputs: ["net"] }, { root: "app", vars: { vpc: undefined } });
242
+ expect(planned.shape).toBeUndefined();
243
+ });
244
+
198
245
  test("plan answers the outputs the root will have, from the plan's planned values (#3999)", async () => {
199
246
  planJson = {
200
247
  planned_values: { outputs: { cidr: { sensitive: false, value: "10.1.0.0/16" }, id: { sensitive: false } } },
@@ -209,14 +256,21 @@ describe("terraform-apply", () => {
209
256
  expect(planned.artifact).toEqual({ planFile: "chant.tfplan", planDigest: "jcs1-sha256:net" });
210
257
  });
211
258
 
212
- test("a root whose vars read outputs not known yet is not planned: a hole names whose (#3999)", async () => {
259
+ test("a root whose vars read outputs not known yet and that cannot be previewed is not planned: a hole names whose (#3999, #4463)", async () => {
260
+ // No chant.config.* above the working directory, so the root's variables cannot be read.
213
261
  const ctx = { env: "test", component: "app", unresolvedInputs: ["net"] };
214
262
  const planned = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
215
263
  expect(calls).toEqual([]);
216
264
  expect(planned.artifact).toBeUndefined();
217
265
  expect(planned.outputs).toBeUndefined();
218
266
  expect(planned.changeSet?.entries).toEqual([]);
219
- expect(planned.changeSet?.member.holes).toEqual([{ address: "app", type: "terraform_root", reason: "inputs from net; planned again once they apply" }]);
267
+ expect(planned.changeSet?.member.holes).toEqual([
268
+ {
269
+ address: "app",
270
+ type: "terraform_root",
271
+ reason: 'inputs from net; planned again once they apply; no preview: no root named "app" in terraform.roots to read its variables from',
272
+ },
273
+ ]);
220
274
  expect(planned.planDigest).toBe(planned.changeSet?.member.planDigest);
221
275
  const again = await terraformApplyCapability.plan!(ctx, { root: "app", vars: { vpc: undefined, name: "app" } });
222
276
  expect(again.planDigest).toBe(planned.planDigest);
@@ -306,3 +360,116 @@ describe("terraform-apply, continued", () => {
306
360
  expect(isPlanMovedError(err)).toBe(false);
307
361
  });
308
362
  });
363
+
364
+ describe("terraform-apply previews a root outside a group with placeholders (#4463)", () => {
365
+ let project: string;
366
+
367
+ beforeEach(() => {
368
+ project = mkdtempSync(join(tmpdir(), "chant-tf-preview-"));
369
+ mkdirSync(join(project, "app"));
370
+ writeFileSync(join(project, "chant.config.json"), JSON.stringify({ lexicons: ["terraform"], terraform: { roots: { app: { dir: "app" } } } }));
371
+ writeFileSync(
372
+ join(project, "app", "variables.tf"),
373
+ 'variable "vpc_id" {\n type = string\n}\n\nvariable "name" {\n type = string\n}\n',
374
+ );
375
+ });
376
+
377
+ afterEach(() => rmSync(project, { recursive: true, force: true }));
378
+
379
+ const subnetPlan = (vpcAfter: unknown) => ({
380
+ configuration: {
381
+ root_module: {
382
+ resources: [
383
+ {
384
+ address: "aws_subnet.a",
385
+ mode: "managed",
386
+ type: "aws_subnet",
387
+ name: "a",
388
+ expressions: { vpc_id: { references: ["var.vpc_id"] }, tags: { references: ["var.name"] } },
389
+ },
390
+ ],
391
+ outputs: { subnet_vpc: { expression: { references: ["var.vpc_id"] } } },
392
+ },
393
+ },
394
+ resource_changes: [
395
+ {
396
+ address: "aws_subnet.a",
397
+ mode: "managed",
398
+ type: "aws_subnet",
399
+ name: "a",
400
+ change: { actions: ["create"], before: null, after: { vpc_id: vpcAfter, tags: { Name: "app" } }, after_unknown: { id: true } },
401
+ },
402
+ ],
403
+ planned_values: { outputs: { subnet_vpc: { value: vpcAfter } } },
404
+ output_changes: { subnet_vpc: { actions: ["create"], after: vpcAfter, after_unknown: false } },
405
+ });
406
+
407
+ const ctx = () => ({ env: "test", component: "app", unresolvedInputs: ["net"] });
408
+ const input = () => ({ root: "app", cwd: project, vars: { vpc_id: undefined, name: "app" } });
409
+
410
+ test("plans with a typed stand-in, marks what it drives unknown, and says which reads were placeholders", async () => {
411
+ planJson = subnetPlan("known-after-apply");
412
+ const planned = await terraformApplyCapability.plan!(ctx(), input());
413
+ expect(calls).toEqual(["init app", 'plan app {"vpc_id":"known-after-apply","name":"app"}']);
414
+ expect(planned.changeSet?.member.holes).toEqual([]);
415
+ expect(planned.changeSet?.member.placeholders).toEqual(["var.vpc_id"]);
416
+ expect(planned.changeSet?.member.planDigest).toBe(planned.planDigest);
417
+ const attrs = planned.changeSet?.entries[0]?.attributes ?? [];
418
+ expect(attrs.find((a) => a.path === "vpc_id")).toEqual({ path: "vpc_id", unknown: true });
419
+ // No stand-in value reaches the note.
420
+ expect(JSON.stringify(planned.changeSet)).not.toContain("known-after-apply");
421
+ expect(planned.outputs).toEqual({ known: {}, unknown: ["subnet_vpc"] });
422
+ expect(planned.shape?.unknownInputs).toEqual(["net"]);
423
+ expect(planned.shape?.entries[0]).toMatchObject({ logicalId: "aws_subnet.a", action: "create", unknown: ["id", "vpc_id"] });
424
+ // The artifact is the shape, never a plan file a stand-in could apply from.
425
+ expect(planned.artifact).toMatchObject({ kind: "terraform-apply-shape", member: "app" });
426
+ expect((planned.artifact as { planFile?: string }).planFile).toBeUndefined();
427
+ const again = await terraformApplyCapability.plan!(ctx(), input());
428
+ expect(again.planDigest).toBe(planned.planDigest);
429
+ });
430
+
431
+ test("a placeholder plan that fails leaves the member a hole, and the hole says why (ruling 2026-10-25)", async () => {
432
+ planError = new Error("terraform plan failed in /x/app (exit 1)\nError: Invalid value for variable\n on variables.tf line 1: vpc_id must start with vpc-");
433
+ const planned = await terraformApplyCapability.plan!(ctx(), input());
434
+ expect(planned.artifact).toBeUndefined();
435
+ expect(planned.shape).toBeUndefined();
436
+ expect(planned.changeSet?.entries).toEqual([]);
437
+ expect(planned.changeSet?.member.placeholders).toBeUndefined();
438
+ const [hole] = planned.changeSet?.member.holes ?? [];
439
+ expect(hole?.reason).toMatch(/^inputs from net; planned again once they apply; no preview: the plan with stand-in values failed: /);
440
+ expect(hole?.reason).toContain("Invalid value for variable");
441
+ });
442
+
443
+ test("a count over a stand-in leaves the member a hole, and the hole says why", async () => {
444
+ const plan = subnetPlan("known-after-apply") as Record<string, any>;
445
+ plan.configuration.root_module.resources[0].count_expression = { references: ["var.vpc_id"] };
446
+ planJson = plan;
447
+ const planned = await terraformApplyCapability.plan!(ctx(), input());
448
+ expect(planned.changeSet?.member.holes[0]?.reason).toContain("no preview: the count or for_each of aws_subnet.a reads a stand-in");
449
+ });
450
+
451
+ test("run holds the real plan to the preview's shape: a match applies it", async () => {
452
+ planJson = subnetPlan("known-after-apply");
453
+ const planned = await terraformApplyCapability.plan!(ctx(), input());
454
+ calls.length = 0;
455
+ planJson = subnetPlan("vpc-123");
456
+ const out = await terraformApplyCapability.run({ env: "test", component: "app", plans: { app: planned.artifact } }, { root: "app", cwd: project, vars: { vpc_id: "vpc-123", name: "app" } });
457
+ expect(calls).toEqual(["init app", 'plan app {"vpc_id":"vpc-123","name":"app"}', "apply app chant.tfplan", "output app"]);
458
+ expect(out.fromWavePlan).toBe(false);
459
+ });
460
+
461
+ test("run refuses a real plan that goes beyond the preview's shape, and applies nothing", async () => {
462
+ planJson = subnetPlan("known-after-apply");
463
+ const planned = await terraformApplyCapability.plan!(ctx(), input());
464
+ calls.length = 0;
465
+ const real = subnetPlan("vpc-123") as Record<string, any>;
466
+ real.resource_changes.push({ address: "aws_route.r", mode: "managed", type: "aws_route", name: "r", change: { actions: ["create"], before: null, after: { cidr: "0.0.0.0/0" }, after_unknown: {} } });
467
+ planJson = real;
468
+ const err = await terraformApplyCapability
469
+ .run({ env: "test", component: "app", plans: { app: planned.artifact } }, { root: "app", cwd: project, vars: { vpc_id: "vpc-123", name: "app" } })
470
+ .catch((e: unknown) => e);
471
+ expect(isPlanMovedError(err)).toBe(true);
472
+ expect((err as Error).message).toContain("aws_route.r");
473
+ expect(calls.some((c) => c.startsWith("apply"))).toBe(false);
474
+ });
475
+ });