@intentius/chant-lexicon-terraform 0.124.51 → 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 (338) 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 +5 -0
  27. package/dist/commands.d.ts.map +1 -1
  28. package/dist/commands.js +87 -4
  29. package/dist/components/capability-plugin.d.ts +65 -3
  30. package/dist/components/capability-plugin.d.ts.map +1 -1
  31. package/dist/components/capability-plugin.js +197 -13
  32. package/dist/components/compose-group.d.ts +145 -0
  33. package/dist/components/compose-group.d.ts.map +1 -0
  34. package/dist/components/compose-group.js +407 -0
  35. package/dist/components/placeholder-preview.d.ts +78 -0
  36. package/dist/components/placeholder-preview.d.ts.map +1 -0
  37. package/dist/components/placeholder-preview.js +254 -0
  38. package/dist/compose/apply-events.d.ts +46 -0
  39. package/dist/compose/apply-events.d.ts.map +1 -0
  40. package/dist/compose/apply-events.js +77 -0
  41. package/dist/compose/estate.d.ts +194 -0
  42. package/dist/compose/estate.d.ts.map +1 -0
  43. package/dist/compose/estate.js +363 -0
  44. package/dist/compose/hcl.d.ts +99 -0
  45. package/dist/compose/hcl.d.ts.map +1 -0
  46. package/dist/compose/hcl.js +302 -0
  47. package/dist/compose/module-address.d.ts +24 -0
  48. package/dist/compose/module-address.d.ts.map +1 -0
  49. package/dist/compose/module-address.js +43 -0
  50. package/dist/compose/root.d.ts +127 -0
  51. package/dist/compose/root.d.ts.map +1 -0
  52. package/dist/compose/root.js +586 -0
  53. package/dist/compose/run.d.ts +183 -0
  54. package/dist/compose/run.d.ts.map +1 -0
  55. package/dist/compose/run.js +447 -0
  56. package/dist/compose/slice.d.ts +102 -0
  57. package/dist/compose/slice.d.ts.map +1 -0
  58. package/dist/compose/slice.js +249 -0
  59. package/dist/compose/state-address.d.ts +28 -0
  60. package/dist/compose/state-address.d.ts.map +1 -0
  61. package/dist/compose/state-address.js +154 -0
  62. package/dist/compose/state-merge.d.ts +52 -0
  63. package/dist/compose/state-merge.d.ts.map +1 -0
  64. package/dist/compose/state-merge.js +135 -0
  65. package/dist/compose.d.ts +179 -0
  66. package/dist/compose.d.ts.map +1 -0
  67. package/dist/compose.js +482 -0
  68. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  69. package/dist/composites/terraform-apply-op.js +7 -3
  70. package/dist/config.d.ts +59 -13
  71. package/dist/config.d.ts.map +1 -1
  72. package/dist/config.js +43 -9
  73. package/dist/describe-resources.d.ts +74 -12
  74. package/dist/describe-resources.d.ts.map +1 -1
  75. package/dist/describe-resources.js +374 -66
  76. package/dist/drift-read.d.ts +33 -0
  77. package/dist/drift-read.d.ts.map +1 -0
  78. package/dist/drift-read.js +243 -0
  79. package/dist/drift-record.d.ts +147 -0
  80. package/dist/drift-record.d.ts.map +1 -0
  81. package/dist/drift-record.js +383 -0
  82. package/dist/hcl/edges.d.ts.map +1 -1
  83. package/dist/hcl/edges.js +24 -1
  84. package/dist/hcl/files.d.ts +61 -0
  85. package/dist/hcl/files.d.ts.map +1 -0
  86. package/dist/hcl/files.js +221 -0
  87. package/dist/hcl/instances.d.ts +82 -0
  88. package/dist/hcl/instances.d.ts.map +1 -0
  89. package/dist/hcl/instances.js +390 -0
  90. package/dist/hcl/parse.d.ts +81 -9
  91. package/dist/hcl/parse.d.ts.map +1 -1
  92. package/dist/hcl/parse.js +114 -16
  93. package/dist/hcl/roots.d.ts.map +1 -1
  94. package/dist/hcl/roots.js +5 -0
  95. package/dist/hcl/suppressions.d.ts.map +1 -1
  96. package/dist/hcl/suppressions.js +8 -2
  97. package/dist/index.d.ts +11 -2
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +21 -2
  100. package/dist/integrity.json +3 -3
  101. package/dist/live-plan-reasons.d.ts +21 -0
  102. package/dist/live-plan-reasons.d.ts.map +1 -0
  103. package/dist/live-plan-reasons.js +47 -0
  104. package/dist/lsp/option-keys.d.ts.map +1 -1
  105. package/dist/lsp/option-keys.js +2 -0
  106. package/dist/manifest.json +1 -1
  107. package/dist/op/activities/index.d.ts +6 -2
  108. package/dist/op/activities/index.d.ts.map +1 -1
  109. package/dist/op/activities/index.js +5 -1
  110. package/dist/op/activities/live-detect.d.ts +4 -3
  111. package/dist/op/activities/live-detect.d.ts.map +1 -1
  112. package/dist/op/activities/live-detect.js +57 -32
  113. package/dist/op/activities/pin-rollout.d.ts +2 -0
  114. package/dist/op/activities/pin-rollout.d.ts.map +1 -1
  115. package/dist/op/activities/pin-rollout.js +1 -0
  116. package/dist/op/activities/terraform.d.ts +153 -5
  117. package/dist/op/activities/terraform.d.ts.map +1 -1
  118. package/dist/op/activities/terraform.js +205 -20
  119. package/dist/op/activity-contracts.d.ts +27 -0
  120. package/dist/op/activity-contracts.d.ts.map +1 -1
  121. package/dist/op/activity-contracts.js +19 -1
  122. package/dist/op/builders.d.ts +7 -1
  123. package/dist/op/builders.d.ts.map +1 -1
  124. package/dist/op/builders.js +9 -0
  125. package/dist/pin/edit.d.ts.map +1 -1
  126. package/dist/pin/edit.js +35 -0
  127. package/dist/pin/rollout.d.ts +2 -0
  128. package/dist/pin/rollout.d.ts.map +1 -1
  129. package/dist/pin/rollout.js +14 -11
  130. package/dist/plan-shape.d.ts +30 -0
  131. package/dist/plan-shape.d.ts.map +1 -0
  132. package/dist/plan-shape.js +125 -0
  133. package/dist/plugin.d.ts.map +1 -1
  134. package/dist/plugin.js +32 -18
  135. package/dist/report.d.ts +216 -0
  136. package/dist/report.d.ts.map +1 -0
  137. package/dist/report.js +666 -0
  138. package/dist/skills/chant-terraform.md +1 -1
  139. package/dist/state/azure-blob.d.ts +68 -0
  140. package/dist/state/azure-blob.d.ts.map +1 -0
  141. package/dist/state/azure-blob.js +201 -0
  142. package/dist/state/backend.d.ts +158 -0
  143. package/dist/state/backend.d.ts.map +1 -0
  144. package/dist/state/backend.js +406 -0
  145. package/dist/state/exec.d.ts +39 -0
  146. package/dist/state/exec.d.ts.map +1 -0
  147. package/dist/state/exec.js +80 -0
  148. package/dist/state/gcs.d.ts +62 -0
  149. package/dist/state/gcs.d.ts.map +1 -0
  150. package/dist/state/gcs.js +224 -0
  151. package/dist/state/index.d.ts +15 -0
  152. package/dist/state/index.d.ts.map +1 -0
  153. package/dist/state/index.js +14 -0
  154. package/dist/state/locks.d.ts +105 -0
  155. package/dist/state/locks.d.ts.map +1 -0
  156. package/dist/state/locks.js +130 -0
  157. package/dist/state/object-store.d.ts +37 -0
  158. package/dist/state/object-store.d.ts.map +1 -0
  159. package/dist/state/object-store.js +20 -0
  160. package/dist/state/root-states.d.ts +131 -0
  161. package/dist/state/root-states.d.ts.map +1 -0
  162. package/dist/state/root-states.js +241 -0
  163. package/dist/state/s3.d.ts +68 -0
  164. package/dist/state/s3.d.ts.map +1 -0
  165. package/dist/state/s3.js +170 -0
  166. package/dist/state/store.d.ts +51 -0
  167. package/dist/state/store.d.ts.map +1 -0
  168. package/dist/state/store.js +185 -0
  169. package/dist/terragrunt/affected.d.ts.map +1 -1
  170. package/dist/terragrunt/affected.js +2 -1
  171. package/package.json +7 -3
  172. package/src/__fixtures__/ACCEPTANCE.md +69 -7
  173. package/src/__fixtures__/compose/apply-failed-in-app.jsonl +12 -0
  174. package/src/__fixtures__/compose/state-app.json +66 -0
  175. package/src/__fixtures__/compose/state-edge.json +40 -0
  176. package/src/__fixtures__/compose/state-net.json +58 -0
  177. package/src/__fixtures__/compose/three-app.plan.json +184 -0
  178. package/src/__fixtures__/compose/three-composed.plan.json +509 -0
  179. package/src/__fixtures__/compose/three-edge.plan.json +144 -0
  180. package/src/__fixtures__/compose/three-net.plan.json +174 -0
  181. package/src/__fixtures__/compose/unknown-composed.plan.json +228 -0
  182. package/src/__fixtures__/compose/working-after-apply.json +145 -0
  183. package/src/__fixtures__/config-blocks/app/main.tf +61 -0
  184. package/src/__fixtures__/config-blocks/app/modules/network/main.tf +4 -0
  185. package/src/__fixtures__/config-blocks/app-json/main.tf.json +39 -0
  186. package/src/__fixtures__/config-files/shadow/main.tf +3 -0
  187. package/src/__fixtures__/config-files/shadow/main.tofu +3 -0
  188. package/src/__fixtures__/config-files/shadow/other.tf +3 -0
  189. package/src/__fixtures__/config-files/tf/main.tf +11 -0
  190. package/src/__fixtures__/config-files/tf-json/main.tf.json +18 -0
  191. package/src/__fixtures__/config-files/tofu/main.tofu +11 -0
  192. package/src/__fixtures__/config-files/tofu-json/main.tofu.json +18 -0
  193. package/src/__fixtures__/config-files/tofu-only/main.tofu +3 -0
  194. package/src/__fixtures__/drift-record/README.md +11 -0
  195. package/src/__fixtures__/drift-record/live-plan-sections.json +27 -0
  196. package/src/__fixtures__/instance-forms/app/main.tf +110 -0
  197. package/src/__fixtures__/instance-forms/app/modules/svc/main.tf +3 -0
  198. package/src/__fixtures__/instance-vars/app/main.tf +52 -0
  199. package/src/__fixtures__/instance-vars/app/modules/app/main.tf +12 -0
  200. package/src/__fixtures__/instance-vars/app/modules/edge/main.tf +3 -0
  201. package/src/__fixtures__/instance-vars/app/terraform.tfvars +1 -0
  202. package/src/__fixtures__/live-module-keyed/live-graph.json +259 -0
  203. package/src/__fixtures__/live-module-keyed/live-plan.json +87 -0
  204. package/src/__fixtures__/live-module-keyed/root/estate.chdf.hcl +1 -0
  205. package/src/__fixtures__/live-module-keyed/root/main.tf +21 -0
  206. package/src/__fixtures__/live-module-keyed/root/modules/app/main.tf +17 -0
  207. package/src/__fixtures__/live-module-keyed/root/modules/edge/main.tf +7 -0
  208. package/src/__fixtures__/live-module-keyed/root/versions.tf +30 -0
  209. package/src/__fixtures__/live-module-keyed/show-state.json +146 -0
  210. package/src/__fixtures__/pr-loop/README.md +50 -0
  211. package/src/__fixtures__/pr-loop/chant.config.json +13 -0
  212. package/src/__fixtures__/pr-loop/estate.component.ts +10 -0
  213. package/src/__fixtures__/pr-loop/roots/a/main.tf +11 -0
  214. package/src/__fixtures__/pr-loop/roots/app/main.tf +19 -0
  215. package/src/__fixtures__/pr-loop/roots/b/main.tf +7 -0
  216. package/src/__fixtures__/pr-loop/roots/dns/main.tf +3 -0
  217. package/src/__fixtures__/pr-loop/roots/net/main.tf +7 -0
  218. package/src/__fixtures__/report/applied.show.json +51 -0
  219. package/src/__fixtures__/report/generate.ts +63 -0
  220. package/src/__fixtures__/report/plan.show.json +200 -0
  221. package/src/__fixtures__/report/tf-apply/change-set.json +170 -0
  222. package/src/__fixtures__/report/tf-apply/report.json +338 -0
  223. package/src/__fixtures__/report/tf-plan/change-set.json +170 -0
  224. package/src/__fixtures__/report/tf-plan/report.json +320 -0
  225. package/src/__fixtures__/three-cloud-azure.graph.json +290 -0
  226. package/src/applied-state.ts +52 -0
  227. package/src/backend-config.test.ts +105 -0
  228. package/src/backend-config.ts +133 -0
  229. package/src/behaviour/kinds.ts +4 -0
  230. package/src/behaviour/predict.ts +5 -2
  231. package/src/behaviour/reference-catalog.plugin.test.ts +65 -0
  232. package/src/behaviour/reference-catalog.ts +16 -5
  233. package/src/behaviour/request.ts +3 -2
  234. package/src/change-set-instances.test.ts +98 -0
  235. package/src/changed-roots.ts +14 -16
  236. package/src/commands.ts +84 -4
  237. package/src/components/capability-plugin.test.ts +172 -5
  238. package/src/components/capability-plugin.ts +212 -11
  239. package/src/components/change-set-graph.e2e.test.ts +11 -5
  240. package/src/components/compose-group.test.ts +486 -0
  241. package/src/components/compose-group.ts +489 -0
  242. package/src/components/demo-composed-roots.e2e.test.ts +440 -0
  243. package/src/components/member-outputs.test.ts +76 -0
  244. package/src/components/placeholder-preview.test.ts +147 -0
  245. package/src/components/placeholder-preview.ts +257 -0
  246. package/src/components/pr-loop-compose.tofu.e2e.test.ts +363 -0
  247. package/src/components/pr-loop.tofu.test.ts +112 -56
  248. package/src/compose/apply-events.test.ts +62 -0
  249. package/src/compose/apply-events.ts +100 -0
  250. package/src/compose/estate.choudoufu.e2e.test.ts +112 -0
  251. package/src/compose/estate.test.ts +421 -0
  252. package/src/compose/estate.ts +443 -0
  253. package/src/compose/hcl.ts +311 -0
  254. package/src/compose/module-address.ts +49 -0
  255. package/src/compose/root.test.ts +416 -0
  256. package/src/compose/root.ts +592 -0
  257. package/src/compose/run.test.ts +440 -0
  258. package/src/compose/run.tofu.test.ts +176 -0
  259. package/src/compose/run.ts +527 -0
  260. package/src/compose/slice.test.ts +184 -0
  261. package/src/compose/slice.ts +290 -0
  262. package/src/compose/state-address.ts +157 -0
  263. package/src/compose/state-merge.test.ts +131 -0
  264. package/src/compose/state-merge.ts +148 -0
  265. package/src/compose.test.ts +248 -0
  266. package/src/compose.ts +627 -0
  267. package/src/composites/terraform-apply-op.test.ts +9 -0
  268. package/src/composites/terraform-apply-op.ts +7 -3
  269. package/src/composites/terraform-pin-rollout-op.test.ts +2 -2
  270. package/src/config-file-listers.test.ts +265 -0
  271. package/src/config.ts +44 -9
  272. package/src/describe-resources.engine-facts.test.ts +412 -0
  273. package/src/describe-resources.live-module-keyed.test.ts +290 -0
  274. package/src/describe-resources.ts +416 -70
  275. package/src/drift-read.ts +284 -0
  276. package/src/drift-record.test.ts +223 -0
  277. package/src/drift-record.ts +455 -0
  278. package/src/hcl/config-blocks.test.ts +174 -0
  279. package/src/hcl/edges.ts +21 -1
  280. package/src/hcl/files.ts +234 -0
  281. package/src/hcl/instance-forms.test.ts +94 -0
  282. package/src/hcl/instances.test.ts +137 -0
  283. package/src/hcl/instances.ts +422 -0
  284. package/src/hcl/parse.test.ts +139 -1
  285. package/src/hcl/parse.ts +149 -16
  286. package/src/hcl/roots.ts +6 -0
  287. package/src/hcl/suppressions.ts +8 -2
  288. package/src/index.ts +76 -0
  289. package/src/live-plan-reasons.ts +54 -0
  290. package/src/lsp/completions.test.ts +1 -1
  291. package/src/lsp/option-keys.ts +2 -0
  292. package/src/op/activities/choudoufu-plan-set-op.test.ts +111 -0
  293. package/src/op/activities/index.ts +9 -0
  294. package/src/op/activities/live-detect.ts +55 -26
  295. package/src/op/activities/pin-rollout.ts +3 -0
  296. package/src/op/activities/registry.test.ts +2 -1
  297. package/src/op/activities/terraform.test.ts +55 -0
  298. package/src/op/activities/terraform.ts +266 -22
  299. package/src/op/activity-contracts.test.ts +1 -0
  300. package/src/op/activity-contracts.ts +24 -1
  301. package/src/op/builders.ts +14 -0
  302. package/src/pin/bundle.test.ts +2 -0
  303. package/src/pin/edit.ts +30 -0
  304. package/src/pin/rollout.ts +16 -11
  305. package/src/plan-shape.test.ts +148 -0
  306. package/src/plan-shape.ts +128 -0
  307. package/src/plugin.ts +33 -19
  308. package/src/report.test.ts +423 -0
  309. package/src/report.ts +752 -0
  310. package/src/signal-only-root.test.ts +72 -0
  311. package/src/skills/chant-terraform.md +1 -1
  312. package/src/state/azure-blob.ts +223 -0
  313. package/src/state/backend-verdict.test.ts +187 -0
  314. package/src/state/backend.test.ts +127 -0
  315. package/src/state/backend.ts +488 -0
  316. package/src/state/exec.ts +111 -0
  317. package/src/state/gcs.ts +254 -0
  318. package/src/state/index.ts +15 -0
  319. package/src/state/locks.test.ts +84 -0
  320. package/src/state/locks.ts +201 -0
  321. package/src/state/object-store.ts +35 -0
  322. package/src/state/root-states-floci.e2e.test.ts +66 -0
  323. package/src/state/root-states.test.ts +462 -0
  324. package/src/state/root-states.ts +309 -0
  325. package/src/state/s3.ts +193 -0
  326. package/src/state/store.ts +225 -0
  327. package/src/terragrunt/affected.ts +2 -1
  328. package/src/workspace-kinds.test.ts +4 -17
  329. package/workspace-kinds.json +1 -24
  330. package/dist/workspace-member.d.ts +0 -30
  331. package/dist/workspace-member.d.ts.map +0 -1
  332. package/dist/workspace-member.js +0 -91
  333. package/schemas/pulumi.schema.json +0 -34
  334. package/src/testdata/pulumi-member/Pulumi.yaml +0 -2
  335. package/src/testdata/pulumi-member/stack-export.json +0 -53
  336. package/src/testdata/pulumi-unexported/Pulumi.yaml +0 -2
  337. package/src/workspace-member.test.ts +0 -89
  338. package/src/workspace-member.ts +0 -126
@@ -120,6 +120,14 @@
120
120
  * not-observed, then foreign, then adoptable, then owned, and a block whose
121
121
  * instances are all absent is absent. Partial knowledge is not knowledge,
122
122
  * which is the same rule choudoufu applies to its own `INCOMPLETE_BLOCK`.
123
+ * The precedence is core's `TERRAFORM_INSTANCE_ROLLUP` and the rollup is
124
+ * core's `rollupInstances` (`@intentius/chant/instance-nodes`, #4454), the
125
+ * one place it is written.
126
+ *
127
+ * The block's verdict is not the whole answer. A present block also carries
128
+ * each instance's own verdict on `instances`, keyed as Terraform prints it
129
+ * (`[0]`, `["a"]`), which the live graph draws as an instance node under the
130
+ * block node (#4454). The state read does the same with the rows it found.
123
131
  *
124
132
  * ## What is readable at all
125
133
  *
@@ -195,6 +203,31 @@
195
203
  * The cost is two refreshes of the estate per read, run one after the other
196
204
  * so they never share the root's cache file.
197
205
  *
206
+ * ## Engine facts on a live root (#4528)
207
+ *
208
+ * After `live-plan -json` answers, the same document is handed to `choudoufu
209
+ * live-graph -json -from-plan=<file>` (choudoufu#2285), which builds the
210
+ * root's graph from the configuration and that file alone: no second live
211
+ * read, so the facts and the verdicts above come from one refresh (choudoufu
212
+ * #1014, the #4520 ruling Q3). `<file>` is the `<root>.live-plan.json`
213
+ * written under {@link TERRAFORM_LIVE_PLAN_DIR_ENV} when the caller asked
214
+ * for it, and otherwise a copy in a temporary directory removed afterwards.
215
+ *
216
+ * The document's node ids are choudoufu's addresses (the ruling's Q2). A
217
+ * block node maps to its entity with {@link entityKeyFor}; an instance node
218
+ * (`instanceOf` set) maps to its block's entity, and its facts go on that
219
+ * block's `instances` entry of the same address, from which core's
220
+ * `observedInstanceNodeId` draws the instance node. Each node's `facts` land
221
+ * on `ResourceMetadata.facts` or `InstanceObservation.facts`, and edges with
222
+ * a grain land on the observation's `edges`, endpoints mapped the same way
223
+ * ({@link readEngineGraph}). Facts are never read for a decision: ownership
224
+ * and the change set keep reading the verdicts above.
225
+ *
226
+ * A choudoufu without the command, older than
227
+ * `MIN_CHOUDOUFU_GRAPH_VERSION`, answers with a usage error: `facts` stay
228
+ * absent, the root gets one note, and the rest of the read is unchanged. Any
229
+ * other failure of the graph read degrades the same way, with its own note.
230
+ *
198
231
  * ## Nothing from `values` is surfaced
199
232
  *
200
233
  * A state row's `values` are the resource's full attribute set, secrets
@@ -223,11 +256,23 @@ import {
223
256
  type UnobservedReason,
224
257
  } from "@intentius/chant/observation";
225
258
  import type { OwnershipMarker } from "@intentius/chant/ownership";
226
- import { mkdirSync, writeFileSync } from "node:fs";
259
+ import {
260
+ observedInstanceNodeId,
261
+ rollupInstances,
262
+ terraformInstancePath,
263
+ TERRAFORM_INSTANCE_ROLLUP,
264
+ type InstanceObservation,
265
+ } from "@intentius/chant/instance-nodes";
266
+ import type { EdgeGrain, EngineFacts } from "@intentius/chant/engine-facts";
267
+ import type { IREdge } from "@intentius/chant/graph-ir";
268
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
269
+ import { tmpdir } from "node:os";
227
270
  import { isAbsolute, join } from "node:path";
228
271
  import {
272
+ choudoufuLiveGraph,
229
273
  choudoufuLiveLs,
230
274
  choudoufuLivePlanDocument,
275
+ MIN_CHOUDOUFU_GRAPH_VERSION,
231
276
  terraformInit,
232
277
  terraformPlanRead,
233
278
  terraformShow,
@@ -235,6 +280,7 @@ import {
235
280
  } from "./op/activities/terraform";
236
281
  import { blockAddressOf, indexPlan, planAttributesFor, plannedAttributesFor, type PlanIndex } from "./plan-observation";
237
282
  import type { TerraformObserveDepth } from "./config";
283
+ import { LIVE_PLAN_OMISSION_REASONS, readsAsCredentials } from "./live-plan-reasons";
238
284
  import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE, callersOfKey } from "./hcl/parse";
239
285
 
240
286
  // The channel keys live in their own module so `plugin.ts` can declare
@@ -242,6 +288,7 @@ import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE, callersOfKey } from "./hcl/parse
242
288
  // this is where they are used.
243
289
  export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
244
290
  export { TERRAFORM_LIVE_MARKER_KEYS } from "./live-ownership";
291
+ export { LIVE_PLAN_OMISSION_REASONS } from "./live-plan-reasons";
245
292
 
246
293
  /** One row out of `values.root_module.resources[]`, at any module depth. */
247
294
  export interface StateResourceRow {
@@ -329,10 +376,24 @@ export function classifyStateOwnership(address: string, index: StateIndex): "own
329
376
  return index.rows.has(address) ? "owned" : "unknown";
330
377
  }
331
378
 
379
+ /**
380
+ * True when `instance` is the module call `address` itself or sits beneath it,
381
+ * at any instance key. `address` is a declared module address with no keys
382
+ * (`module.app`, `module.outer.module.inner`); a state or live-plan address
383
+ * carries them (`module.app[0]`, `module.outer["a"].module.inner[1].aws_eip.x`),
384
+ * so the keys come off before the prefix test (#4513).
385
+ */
386
+ function underModule(instance: string, address: string): boolean {
387
+ const block = blockAddressOf(instance);
388
+ return block === address || block.startsWith(`${address}.`);
389
+ }
390
+
332
391
  /** True when the state carries at least one resource beneath `module.<name>`. */
333
392
  function moduleIsLive(address: string, index: StateIndex): boolean {
334
- if (index.modules.has(address)) return true;
335
- for (const key of index.rows.keys()) if (key.startsWith(`${address}.`)) return true;
393
+ // A keyed call's child_modules[] entries are `module.app[0]`, never
394
+ // `module.app`, and so are its rows' prefixes (#4513).
395
+ for (const module of index.modules) if (underModule(module, address)) return true;
396
+ for (const key of index.rows.keys()) if (underModule(key, address)) return true;
336
397
  return false;
337
398
  }
338
399
 
@@ -500,43 +561,6 @@ export function indexLivePlan(document: unknown): LivePlanIndex {
500
561
  return index;
501
562
  }
502
563
 
503
- /**
504
- * choudoufu's omission reason codes, mapped onto chant's total vocabulary
505
- * (`internal/live/projection/result.go`). `"absent"` is not an
506
- * {@link UnobservedReason}: it is the tri-state's OBSERVED-ABSENT, the one
507
- * omission that is a claim of knowledge rather than of ignorance.
508
- *
509
- * A code this table does not know maps to `read-failed`, which is the safe
510
- * direction: no NOT-OBSERVED verdict ever becomes a create or a delete.
511
- */
512
- export const LIVE_PLAN_OMISSION_REASONS: Readonly<Record<string, UnobservedReason | "absent">> = {
513
- // The provider was asked and reported, normally, that nothing is there.
514
- ABSENT: "absent",
515
- // The provider errored, or could not be reached in a usable order.
516
- FAILED: "read-failed",
517
- PARENT_UNAVAILABLE: "read-failed",
518
- CYCLE: "read-failed",
519
- INCOMPLETE_BLOCK: "read-failed",
520
- LISTED_NOT_IMPORTABLE: "read-failed",
521
- // Something exists at the identity and does not carry this estate's marker.
522
- // Normally answered by the `unowned[]` section before this table is read;
523
- // this entry is what happens if a document ever carries the omission alone.
524
- UNOWNED: "read-failed",
525
- // The mode cannot name, list or tag this instance at all: a server-assigned
526
- // identity with no marker to find it by, a resource with no cloud object to
527
- // read, or an address another declared instance has taken over.
528
- NEEDS_DISCOVERY: "unsupported-kind",
529
- UNREADABLE: "unsupported-kind",
530
- SUPERSEDED: "unsupported-kind",
531
- };
532
-
533
- /** Does this text name a credentials or authorization failure rather than any other error? */
534
- function readsAsCredentials(text: string): boolean {
535
- return /\b(no valid credential|credentials?|not authorized|unauthorized|accessdenied|access denied|expiredtoken|invalidclienttokenid|signature)\b/i.test(
536
- text,
537
- );
538
- }
539
-
540
564
  /** The chant verdict for one omission: `no-credentials` when its detail says so. */
541
565
  function omissionVerdict(row: LivePlanOmissionRow): UnobservedReason | "absent" {
542
566
  const mapped = LIVE_PLAN_OMISSION_REASONS[row.reason] ?? "read-failed";
@@ -550,14 +574,16 @@ function omissionVerdict(row: LivePlanOmissionRow): UnobservedReason | "absent"
550
574
  * #2360), which places the same blocks against the same document.
551
575
  */
552
576
  export function instancesOf(address: string, index: LivePlanIndex): string[] {
553
- return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`));
577
+ // `blockAddressOf` strips every key, so an instance under an expanded
578
+ // module call (`module.app[0].aws_eip.pool[1]`) is the block's too (#4488).
579
+ return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`) || blockAddressOf(a) === address);
554
580
  }
555
581
 
556
582
  /** True when the document names any instance under `module.<name>`, at any depth. */
557
583
  function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
558
- return index.addresses.filter(
559
- (a) => a === address || a.startsWith(`${address}.`) || a.startsWith(`${address}[`),
560
- );
584
+ // Keys stripped first, so a nested call under a keyed one
585
+ // (`module.outer[0].module.inner["a"].…`) is a member too (#4513).
586
+ return index.addresses.filter((a) => underModule(a, address));
561
587
  }
562
588
 
563
589
  /** One instance's verdict, before a block aggregates its instances. */
@@ -608,6 +634,52 @@ export function classifyLiveInstance(address: string, index: LivePlanIndex): Ins
608
634
  return { kind: "absent" };
609
635
  }
610
636
 
637
+ /**
638
+ * Where one instance address of the declared block `address` sits: its own
639
+ * key and the expanded module path (`@intentius/chant/instance-nodes`), or
640
+ * undefined when it is the block's unexpanded address or another block's.
641
+ * An instance under an expanded module call carries `scope` (#4488), so the
642
+ * live graph draws it under the same id the declared graph lists.
643
+ */
644
+ function instanceKeyAndScope(address: string, instance: string): { key?: string; scope?: string } | undefined {
645
+ if (instance === address || blockAddressOf(instance) !== address) return undefined;
646
+ const path = terraformInstancePath(instance);
647
+ if (!path) return undefined;
648
+ return { ...(path.key !== undefined ? { key: path.key } : {}), ...(path.scope !== undefined ? { scope: path.scope } : {}) };
649
+ }
650
+
651
+ /**
652
+ * Each instance's own verdict (#4454), keyed as Terraform prints it, for the
653
+ * block's `instances`. An instance under an expanded module call carries its
654
+ * module path as `scope` (#4488) and its own key when the block is expanded too.
655
+ */
656
+ function liveInstanceObservations(address: string, instances: readonly string[], verdicts: readonly InstanceVerdict[]): InstanceObservation[] {
657
+ const out: InstanceObservation[] = [];
658
+ instances.forEach((a, i) => {
659
+ const at = instanceKeyAndScope(address, a);
660
+ if (at === undefined) return;
661
+ const v = verdicts[i]!;
662
+ switch (v.kind) {
663
+ case "owned":
664
+ out.push({ ...at, address: a, verdict: "present", ownership: "owned", status: "bound", ...(v.row.identity ? { physicalId: v.row.identity } : {}) });
665
+ break;
666
+ case "foreign":
667
+ out.push({ ...at, address: a, verdict: "present", ownership: "foreign", status: "unowned", ...(v.row.identity ? { physicalId: v.row.identity } : {}) });
668
+ break;
669
+ case "adoptable":
670
+ out.push({ ...at, address: a, verdict: "present", ownership: "unknown", status: "adoptable", ...(v.row.identity ? { physicalId: v.row.identity } : {}) });
671
+ break;
672
+ case "absent":
673
+ out.push({ ...at, address: a, verdict: "absent" });
674
+ break;
675
+ case "unobserved":
676
+ out.push({ ...at, address: a, verdict: "not-observed", reason: v.reason, detail: v.detail });
677
+ break;
678
+ }
679
+ });
680
+ return out;
681
+ }
682
+
611
683
  /**
612
684
  * The declared block's verdict, aggregated over its instances in the
613
685
  * precedence the module doc states: not-observed, then foreign, then
@@ -627,17 +699,22 @@ function readLiveResource(
627
699
 
628
700
  const verdicts = instances.map((a) => classifyLiveInstance(a, index));
629
701
  const attributes: Record<string, unknown> = { address, root, estate: index.estate };
630
- if (instances.length > 1 || instances[0] !== address) attributes.instances = instances;
702
+ const expanded = instances.length > 1 || instances[0] !== address;
703
+ if (expanded) attributes.instances = instances;
704
+ const perInstance = expanded ? liveInstanceObservations(address, instances, verdicts) : [];
705
+ const withInstances = perInstance.length > 0 ? { instances: perInstance } : {};
631
706
 
632
- const unobserved = verdicts.find((v) => v.kind === "unobserved");
633
- if (unobserved && unobserved.kind === "unobserved") {
634
- return { unobserved: { reason: unobserved.reason, detail: unobserved.detail }, queried };
707
+ const top = rollupInstances(verdicts, TERRAFORM_INSTANCE_ROLLUP);
708
+
709
+ if (top?.kind === "unobserved") {
710
+ return { unobserved: { reason: top.reason, detail: top.detail }, queried };
635
711
  }
636
712
 
637
- const foreign = verdicts.find((v) => v.kind === "foreign");
638
- if (foreign && foreign.kind === "foreign") {
713
+ const foreign = top;
714
+ if (foreign?.kind === "foreign") {
639
715
  return {
640
716
  present: {
717
+ ...withInstances,
641
718
  type: entity.type,
642
719
  physicalId: foreign.row.identity ?? address,
643
720
  status: "unowned",
@@ -652,8 +729,8 @@ function readLiveResource(
652
729
  };
653
730
  }
654
731
 
655
- const adoptable = verdicts.find((v) => v.kind === "adoptable");
656
- if (adoptable && adoptable.kind === "adoptable") {
732
+ const adoptable = top;
733
+ if (adoptable?.kind === "adoptable") {
657
734
  // `unknown`, not `foreign`: the resource matches the declaration and one
658
735
  // tag write claims it. The two values that write carries are the whole
659
736
  // adoption proposal #2105 builds its Op on, so they ride the metadata
@@ -661,6 +738,7 @@ function readLiveResource(
661
738
  // surfaced, because nothing was read off this resource: it carries none.
662
739
  return {
663
740
  present: {
741
+ ...withInstances,
664
742
  type: entity.type,
665
743
  physicalId: adoptable.row.identity ?? address,
666
744
  status: "adoptable",
@@ -683,13 +761,14 @@ function readLiveResource(
683
761
  };
684
762
  }
685
763
 
764
+ if (top?.kind !== "owned") return { absent: true, queried };
686
765
  const owned = verdicts.filter((v) => v.kind === "owned") as Array<{ kind: "owned"; row: LivePlanBoundRow }>;
687
- if (owned.length === 0) return { absent: true, queried };
688
766
 
689
767
  const sources = [...new Set(owned.map((v) => v.row.source ?? "unknown"))].sort();
690
768
  const byMarker = sources.length === 1 && sources[0] === "marker";
691
769
  return {
692
770
  present: {
771
+ ...withInstances,
693
772
  type: entity.type,
694
773
  physicalId: owned.length === 1 ? (owned[0]!.row.identity ?? address) : address,
695
774
  status: "bound",
@@ -732,24 +811,46 @@ export function livePlanDocumentFile(root: string): string {
732
811
  return `${root.replace(/[^A-Za-z0-9._-]/g, "_")}.live-plan.json`;
733
812
  }
734
813
 
735
- /** Write a live root's document where {@link TERRAFORM_LIVE_PLAN_DIR_ENV} asks, if it asks. */
736
- function handLivePlanDocument(root: string, json: unknown): void {
814
+ /**
815
+ * Write a live root's document where {@link TERRAFORM_LIVE_PLAN_DIR_ENV} asks,
816
+ * if it asks. The path written, or undefined when nothing was.
817
+ */
818
+ function handLivePlanDocument(root: string, json: unknown): string | undefined {
737
819
  const out = process.env[TERRAFORM_LIVE_PLAN_DIR_ENV];
738
- if (!out || !isAbsolute(out)) return;
820
+ if (!out || !isAbsolute(out)) return undefined;
739
821
  try {
740
822
  mkdirSync(out, { recursive: true });
741
- writeFileSync(join(out, livePlanDocumentFile(root)), JSON.stringify(json));
823
+ const file = join(out, livePlanDocumentFile(root));
824
+ writeFileSync(file, JSON.stringify(json));
825
+ return file;
742
826
  } catch {
743
827
  // Best effort: the read's answer does not depend on the copy.
828
+ return undefined;
744
829
  }
745
830
  }
746
831
 
832
+ /**
833
+ * What a live root's bind leaves for the engine graph read (#4528): the
834
+ * `live-plan -json` document it indexed, the file it was handed to under
835
+ * {@link TERRAFORM_LIVE_PLAN_DIR_ENV} if any, and the estate.
836
+ */
837
+ interface LivePlanHandoff {
838
+ json?: unknown;
839
+ file?: string;
840
+ estate?: string;
841
+ }
842
+
747
843
  /**
748
844
  * One live root's reader: `live-plan -json`, then address matching against
749
845
  * the document. `terraform show` is never run here, and there is no state
750
846
  * file to run it over.
751
847
  */
752
- function liveAdapter(root: string, cwd: string | undefined, deps: TerraformReadDeps): ObserverAdapter<LivePlanIndex> {
848
+ function liveAdapter(
849
+ root: string,
850
+ cwd: string | undefined,
851
+ deps: TerraformReadDeps,
852
+ handoff: LivePlanHandoff = {},
853
+ ): ObserverAdapter<LivePlanIndex> {
753
854
  const where = cwd ? { cwd } : {};
754
855
  let dir: string | undefined;
755
856
 
@@ -757,8 +858,12 @@ function liveAdapter(root: string, cwd: string | undefined, deps: TerraformReadD
757
858
  async bind(): Promise<LivePlanIndex> {
758
859
  const planned = await deps.livePlan({ root, ...where });
759
860
  dir = planned.dir;
760
- handLivePlanDocument(root, planned.json);
761
- return indexLivePlan(planned.json);
861
+ const file = handLivePlanDocument(root, planned.json);
862
+ const index = indexLivePlan(planned.json);
863
+ handoff.json = planned.json;
864
+ handoff.estate = planned.estate ?? index.estate;
865
+ if (file) handoff.file = file;
866
+ return index;
762
867
  },
763
868
 
764
869
  classifyBindFailure(err) {
@@ -817,6 +922,204 @@ function liveAdapter(root: string, cwd: string | undefined, deps: TerraformReadD
817
922
  };
818
923
  }
819
924
 
925
+ /* ─────────────────────── engine facts (#4528) ──────────────────────────── */
926
+
927
+ /** {@link readEngineGraph}'s answer: facts and edges keyed in chant's id space. */
928
+ export interface EngineGraphRead {
929
+ /** Facts of a block node, by entity key. */
930
+ blocks: Map<string, EngineFacts>;
931
+ /** Facts of an instance node, by its block's entity key, then by the instance address. */
932
+ instances: Map<string, Map<string, EngineFacts>>;
933
+ /** Edges with a grain, endpoints as live graph node ids. */
934
+ edges: IREdge[];
935
+ }
936
+
937
+ const GRAINS: ReadonlySet<string> = new Set<EdgeGrain>(["block", "instance", "property"]);
938
+ const OWNERSHIPS: ReadonlySet<string> = new Set(["bound", "unowned", "pending", "foreign"]);
939
+ const OWNED_BY: ReadonlySet<string> = new Set(["marker", "record", "name"]);
940
+
941
+ /**
942
+ * One node's `facts`, keeping only the fields of chant's `EngineFacts` and
943
+ * only values of the shape it declares. Undefined when nothing is left.
944
+ */
945
+ function engineFactsOf(value: unknown): EngineFacts | undefined {
946
+ const f = asRecord(value);
947
+ const out: EngineFacts = {};
948
+ const ownership = asString(f.ownership);
949
+ if (ownership && OWNERSHIPS.has(ownership)) out.ownership = ownership as EngineFacts["ownership"];
950
+ const ownedBy = asString(f.ownedBy);
951
+ if (ownedBy && OWNED_BY.has(ownedBy)) out.ownedBy = ownedBy as EngineFacts["ownedBy"];
952
+ const markers = asRecord(f.markers);
953
+ const encoding = asString(markers.encoding);
954
+ const source = asString(markers.source);
955
+ if ((encoding === "plain" || encoding === "escaped" || encoding === "digest") && (source === "live" || source === "record")) {
956
+ out.markers = {
957
+ ...(asString(markers.owner) ? { owner: asString(markers.owner) } : {}),
958
+ ...(asString(markers.address) ? { address: asString(markers.address) } : {}),
959
+ encoding,
960
+ source,
961
+ };
962
+ }
963
+ const cloudIds = asArray(f.cloudIds)
964
+ .map((c) => ({ kind: asString(asRecord(c).kind), value: asString(asRecord(c).value) }))
965
+ .filter((c): c is NonNullable<EngineFacts["cloudIds"]>[number] =>
966
+ c.value !== undefined && ["import-id", "arn", "provider-id", "self-link", "k8s-uid"].includes(c.kind ?? ""),
967
+ );
968
+ if (cloudIds.length > 0) out.cloudIds = cloudIds;
969
+ const history = asRecord(f.recordVersions);
970
+ if (typeof history.kept === "boolean") {
971
+ out.recordVersions = {
972
+ kept: history.kept,
973
+ versions: asArray(history.versions).flatMap((v) => {
974
+ const r = asRecord(v);
975
+ const id = asString(r.id);
976
+ const writtenAt = asString(r.writtenAt);
977
+ return id && writtenAt ? [{ id, writtenAt, current: r.current === true, deleted: r.deleted === true }] : [];
978
+ }),
979
+ };
980
+ }
981
+ const observedAt = asString(f.observedAt);
982
+ if (observedAt) out.observedAt = observedAt;
983
+ return Object.keys(out).length > 0 ? out : undefined;
984
+ }
985
+
986
+ /**
987
+ * Read an engine graph document (`choudoufu live-graph -json`, chant#4527's
988
+ * schema) for one root into chant's id space. Node ids are choudoufu's
989
+ * addresses (the #4520 ruling, Q2): a block node is the entity
990
+ * {@link entityKeyFor} names; an instance node (`instanceOf`) belongs to its
991
+ * block's entity, and as an edge endpoint it is the live instance node id
992
+ * core's `observedInstanceNodeId` gives. A node with no address (an estate
993
+ * the configuration reads, `estate:<name>`) has no chant entity, so its
994
+ * edges are left out. Edges keep their grain; one without a grain is left out.
995
+ *
996
+ * Throws when the document is not an engine graph document.
997
+ */
998
+ export function readEngineGraph(root: string, document: unknown): EngineGraphRead {
999
+ const doc = asRecord(document);
1000
+ const engineGraph = asRecord(asRecord(doc.meta).engineGraph);
1001
+ if (doc.version !== 1 || engineGraph.format !== 1 || !Array.isArray(doc.nodes)) {
1002
+ throw new Error("not an engine graph document (want GraphIR version 1 with meta.engineGraph format 1)");
1003
+ }
1004
+ const blocks = new Map<string, EngineFacts>();
1005
+ const instances = new Map<string, Map<string, EngineFacts>>();
1006
+ const nodeIds = new Map<string, string>();
1007
+
1008
+ for (const entry of doc.nodes) {
1009
+ const node = asRecord(entry);
1010
+ const id = asString(node.id);
1011
+ const address = asString(asRecord(node.attrs).address);
1012
+ if (!id || !address) continue;
1013
+ const facts = engineFactsOf(node.facts);
1014
+ const instanceOf = asString(node.instanceOf);
1015
+ if (instanceOf) {
1016
+ const block = entityKeyFor(root, instanceOf);
1017
+ const key = asString(node.instanceKey);
1018
+ const scope = asString(node.instanceScope);
1019
+ const liveId = observedInstanceNodeId(block, {
1020
+ address: id,
1021
+ ...(key !== undefined ? { key } : {}),
1022
+ ...(scope !== undefined ? { scope } : {}),
1023
+ });
1024
+ if (liveId) nodeIds.set(id, liveId);
1025
+ if (facts) {
1026
+ let byAddress = instances.get(block);
1027
+ if (!byAddress) instances.set(block, (byAddress = new Map()));
1028
+ byAddress.set(id, facts);
1029
+ }
1030
+ continue;
1031
+ }
1032
+ const key = entityKeyFor(root, id);
1033
+ nodeIds.set(id, key);
1034
+ if (facts) blocks.set(key, facts);
1035
+ }
1036
+
1037
+ const edges: IREdge[] = [];
1038
+ const seen = new Set<string>();
1039
+ for (const entry of asArray(doc.edges)) {
1040
+ const edge = asRecord(entry);
1041
+ const grain = asString(edge.grain);
1042
+ if (!grain || !GRAINS.has(grain)) continue;
1043
+ const from = nodeIds.get(asString(edge.from) ?? "");
1044
+ const to = nodeIds.get(asString(edge.to) ?? "");
1045
+ if (!from || !to || from === to) continue;
1046
+ const viaAttr = asString(edge.viaAttr);
1047
+ const key = `${from}\0${to}\0${viaAttr ?? ""}\0${grain}`;
1048
+ if (seen.has(key)) continue;
1049
+ seen.add(key);
1050
+ edges.push({ from, to, kind: "ref", ...(viaAttr ? { viaAttr } : {}), grain: grain as EdgeGrain });
1051
+ }
1052
+ return { blocks, instances, edges };
1053
+ }
1054
+
1055
+ /**
1056
+ * `resources` with {@link readEngineGraph}'s facts on them: a block's on
1057
+ * `ResourceMetadata.facts`, an instance's on its `instances` entry of the
1058
+ * same address. Nothing else changes, and nothing is added for an entity the
1059
+ * read did not return.
1060
+ */
1061
+ export function withEngineFacts(
1062
+ resources: Record<string, ResourceMetadata>,
1063
+ read: EngineGraphRead,
1064
+ ): Record<string, ResourceMetadata> {
1065
+ const out: Record<string, ResourceMetadata> = {};
1066
+ for (const [name, meta] of Object.entries(resources)) {
1067
+ const facts = read.blocks.get(name);
1068
+ const byAddress = read.instances.get(name);
1069
+ const instances = byAddress && meta.instances
1070
+ ? meta.instances.map((inst) => {
1071
+ const f = byAddress.get(inst.address);
1072
+ return f ? { ...inst, facts: f } : inst;
1073
+ })
1074
+ : meta.instances;
1075
+ out[name] = {
1076
+ ...meta,
1077
+ ...(facts ? { facts } : {}),
1078
+ ...(instances ? { instances } : {}),
1079
+ };
1080
+ }
1081
+ return out;
1082
+ }
1083
+
1084
+ /**
1085
+ * Run `choudoufu live-graph -json -from-plan=<file>` over the document a
1086
+ * live root's bind handed off, and read it (#4528). Never throws: facts are
1087
+ * extra, so a failure is a note and no facts.
1088
+ */
1089
+ async function engineGraphFor(
1090
+ root: string,
1091
+ cwd: string | undefined,
1092
+ deps: TerraformReadDeps,
1093
+ handoff: LivePlanHandoff,
1094
+ ): Promise<{ read?: EngineGraphRead; note?: string }> {
1095
+ if (!deps.liveGraph || handoff.json === undefined) return {};
1096
+ let scratch: string | undefined;
1097
+ try {
1098
+ let file = handoff.file;
1099
+ if (!file) {
1100
+ scratch = mkdtempSync(join(tmpdir(), "chant-live-graph-"));
1101
+ file = join(scratch, livePlanDocumentFile(root));
1102
+ writeFileSync(file, JSON.stringify(handoff.json));
1103
+ }
1104
+ const graph = await deps.liveGraph({ root, ...(cwd ? { cwd } : {}), fromPlan: file, ...(handoff.estate ? { estate: handoff.estate } : {}) });
1105
+ if (!graph.supported) {
1106
+ return {
1107
+ note:
1108
+ `terraform.roots.${root}: engine facts are absent: this choudoufu has no \`live-graph -from-plan\` ` +
1109
+ `(${graph.detail}), which needs v${MIN_CHOUDOUFU_GRAPH_VERSION} or later; the rest of the read is unchanged`,
1110
+ };
1111
+ }
1112
+ return { read: readEngineGraph(root, graph.json) };
1113
+ } catch (err) {
1114
+ const message = (err instanceof Error ? err.message : String(err)).split("\n")[0];
1115
+ return {
1116
+ note: `terraform.roots.${root}: engine facts are absent: \`choudoufu live-graph -json -from-plan\` failed (${message}); the rest of the read is unchanged`,
1117
+ };
1118
+ } finally {
1119
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
1120
+ }
1121
+ }
1122
+
820
1123
  /* ──────────────────── the estate beyond the declaration ─────────────────── */
821
1124
 
822
1125
  /** One `items[]` entry of a `live-ls -json` listing. */
@@ -944,7 +1247,8 @@ export interface LiveEstateOptions {
944
1247
  }
945
1248
 
946
1249
  /**
947
- * Every live root the project declares, in config order. Reads the project
1250
+ * Every live root the project declares, in config order, leaving out a
1251
+ * `signalOnly` one (#4484). Reads the project
948
1252
  * config the same way the activities do, so `terraform.roots` is read once
949
1253
  * and the same answer reaches both.
950
1254
  */
@@ -956,12 +1260,14 @@ export async function liveRootNames(cwd?: string): Promise<Array<{ root: string;
956
1260
  const start = resolve(cwd ?? process.cwd());
957
1261
  const { config, configPath } = await loadChantConfigUpward(start);
958
1262
  const projectRoot = configPath ? dirname(configPath) : start;
959
- const namespace = (config as { terraform?: { binary?: string; roots?: Record<string, { dir: string }> } })
1263
+ const namespace = (config as { terraform?: { binary?: string; roots?: Record<string, { dir: string; signalOnly?: boolean }> } })
960
1264
  .terraform;
961
1265
  if (namespace?.binary !== "choudoufu") return [];
962
1266
 
963
1267
  const live: Array<{ root: string; estate: string }> = [];
964
1268
  for (const [name, root] of Object.entries(namespace.roots ?? {})) {
1269
+ // A `signalOnly` root (#4484) is another project's: it only brings this one into a change.
1270
+ if (root.signalOnly === true) continue;
965
1271
  const estate = detectLiveEstate(resolve(projectRoot, root.dir));
966
1272
  if (estate !== undefined) live.push({ root: name, estate });
967
1273
  }
@@ -1105,6 +1411,12 @@ export interface TerraformReadDeps {
1105
1411
  /** The document alone (chant #2498): this reader indexes it and never reads the human render. */
1106
1412
  livePlan: typeof choudoufuLivePlanDocument;
1107
1413
  liveLs: typeof choudoufuLiveLs;
1414
+ /**
1415
+ * The engine graph read on a live root (#4528). Optional so a test that
1416
+ * never asks for facts need not stub it; omitted, no facts are read and no
1417
+ * note is added.
1418
+ */
1419
+ liveGraph?: typeof choudoufuLiveGraph;
1108
1420
  /**
1109
1421
  * The plan depth's read (#4358). Optional so a test that never asks for the
1110
1422
  * plan depth need not stub it; omitted, the real activity runs.
@@ -1117,6 +1429,7 @@ const REAL_DEPS: TerraformReadDeps = {
1117
1429
  show: terraformShow,
1118
1430
  livePlan: choudoufuLivePlanDocument,
1119
1431
  liveLs: choudoufuLiveLs,
1432
+ liveGraph: choudoufuLiveGraph,
1120
1433
  planRead: terraformPlanRead,
1121
1434
  };
1122
1435
 
@@ -1222,6 +1535,22 @@ function blockRows(address: string, index: StateIndex): StateResourceRow[] {
1222
1535
  return [...index.rows.values()].filter((row) => blockAddressOf(row.address) === address);
1223
1536
  }
1224
1537
 
1538
+ /**
1539
+ * Each row of an expanded block as an instance (#4454), keyed as Terraform
1540
+ * prints it. A state row is a recorded instance, owned by membership. A row
1541
+ * under an expanded module call carries its module path as `scope`, as on
1542
+ * the live read (#4488).
1543
+ */
1544
+ function stateInstanceObservations(address: string, rows: readonly StateResourceRow[]): InstanceObservation[] {
1545
+ const out: InstanceObservation[] = [];
1546
+ for (const r of rows) {
1547
+ const at = instanceKeyAndScope(address, r.address);
1548
+ if (at === undefined) continue;
1549
+ out.push({ ...at, address: r.address, verdict: "present", ownership: "owned", status: r.mode ?? "managed", ...(r.id ? { physicalId: r.id } : {}) });
1550
+ }
1551
+ return out.sort((a, b) => a.address.localeCompare(b.address));
1552
+ }
1553
+
1225
1554
  /**
1226
1555
  * One declared entity against a state index: the state read's whole verdict,
1227
1556
  * and the plan depth's too, since a plan's `prior_state` is the same shape.
@@ -1237,13 +1566,17 @@ function readStateEntity(
1237
1566
  const { address } = entity as TerraformDeclared;
1238
1567
 
1239
1568
  if (entity.type === RESOURCE_TYPE || entity.type === DATA_TYPE) {
1240
- // The exact row first, which is the state read's rule since #2087; the
1241
- // plan depth also finds a `count` or `for_each` block by its instances.
1242
- const rows = extra ? blockRows(address, index) : index.rows.get(address) ? [index.rows.get(address)!] : [];
1569
+ // The exact row first, which is the state read's rule since #2087, then
1570
+ // a `count` or `for_each` block by its instances: the plan depth did
1571
+ // that from the start, and the state read does since #4454, which is
1572
+ // what gives a drift row for `aws_eip.pool[0]` a block to roll up to.
1573
+ const rows = blockRows(address, index);
1243
1574
  if (rows.length === 0) return { absent: true, queried };
1244
1575
  const row = rows[0]!;
1576
+ const instances = stateInstanceObservations(address, rows);
1245
1577
  return {
1246
1578
  present: {
1579
+ ...(instances.length > 0 ? { instances } : {}),
1247
1580
  type: entity.type,
1248
1581
  physicalId: rows.length === 1 ? (row.id ?? address) : address,
1249
1582
  status: row.mode ?? "managed",
@@ -1423,9 +1756,10 @@ function livePlanDepthAdapter(
1423
1756
  cwd: string | undefined,
1424
1757
  deps: TerraformReadDeps,
1425
1758
  outcome: { answered: LivePlanDepthAnswer; note: string; read?: RootRead },
1759
+ handoff: LivePlanHandoff = {},
1426
1760
  ): ObserverAdapter<LivePlanDepthIndex> {
1427
1761
  const where = cwd ? { cwd } : {};
1428
- const live = liveAdapter(root, cwd, deps);
1762
+ const live = liveAdapter(root, cwd, deps, handoff);
1429
1763
  const planRead = deps.planRead ?? terraformPlanRead;
1430
1764
  const firstLine = (err: unknown): string => (err instanceof Error ? err.message : String(err)).split("\n")[0]!;
1431
1765
  let command = "";
@@ -1571,6 +1905,15 @@ export async function describeResources(
1571
1905
  // an overlay exists to prevent.
1572
1906
  const sources: Record<string, string> = {};
1573
1907
  const rootReads: Record<string, RootRead> = {};
1908
+ const edges: IREdge[] = [];
1909
+ // A live root's observation with the engine graph's facts and edges on it (#4528).
1910
+ const withEngineGraph = async (root: string, handoff: LivePlanHandoff, observed: ReturnType<typeof normalizeObservation>) => {
1911
+ const graph = await engineGraphFor(root, options.cwd, deps, handoff);
1912
+ if (graph.note) notes.push(graph.note);
1913
+ if (!graph.read) return observed;
1914
+ edges.push(...graph.read.edges);
1915
+ return { ...observed, resources: withEngineFacts(observed.resources, graph.read) };
1916
+ };
1574
1917
  const depthOf = byRoot.size > 0 ? await observeDepths(options, notes) : () => "state" as const;
1575
1918
  for (const [root, declared] of byRoot) {
1576
1919
  // One fact decides the whole read, and the parse already recorded it on
@@ -1587,15 +1930,18 @@ export async function describeResources(
1587
1930
  // Both reads (#4371): `live-plan` for ownership, the plan for the
1588
1931
  // attributes on what it bound. `sources` is known only after the read.
1589
1932
  const outcome: { answered: LivePlanDepthAnswer; note: string; read?: RootRead } = { answered: "live", note: "" };
1590
- const read = await observeEntities(declared, livePlanDepthAdapter(root, options.cwd, deps, outcome));
1933
+ const handoff: LivePlanHandoff = {};
1934
+ const read = await observeEntities(declared, livePlanDepthAdapter(root, options.cwd, deps, outcome, handoff));
1591
1935
  for (const entity of declared) sources[entity.name] = outcome.answered;
1592
1936
  if (outcome.read) rootReads[`terraform.roots.${root}`] = outcome.read;
1593
1937
  if (outcome.note) notes.push(outcome.note);
1594
- parts.push(normalizeObservation(read));
1938
+ parts.push(await withEngineGraph(root, handoff, normalizeObservation(read)));
1595
1939
  continue;
1596
1940
  }
1597
1941
  for (const entity of declared) sources[entity.name] = "live";
1598
- parts.push(normalizeObservation(await observeEntities(declared, liveAdapter(root, options.cwd, deps))));
1942
+ const handoff: LivePlanHandoff = {};
1943
+ const read = await observeEntities(declared, liveAdapter(root, options.cwd, deps, handoff));
1944
+ parts.push(await withEngineGraph(root, handoff, normalizeObservation(read)));
1599
1945
  continue;
1600
1946
  }
1601
1947
  if (depth === "plan") {
@@ -1646,5 +1992,5 @@ export async function describeResources(
1646
1992
 
1647
1993
  // A planned create's plan attributes (#4372) ride beside the verdict, never
1648
1994
  // in `resources`: the block stays absent.
1649
- return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources, merged.annotations, rootReads);
1995
+ return observation(resources, unobserved, merged.queried, merged.notes, undefined, sources, merged.annotations, rootReads, edges);
1650
1996
  }