@intentius/chant-lexicon-terraform 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (381) hide show
  1. package/dist/composites/terraform-adopt-op.d.ts +126 -0
  2. package/dist/composites/terraform-adopt-op.d.ts.map +1 -0
  3. package/dist/composites/terraform-apply-op.d.ts +63 -16
  4. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  5. package/dist/composites/terraform-watch-op.d.ts +80 -36
  6. package/dist/composites/terraform-watch-op.d.ts.map +1 -1
  7. package/dist/config.d.ts +49 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/describe-resources.d.ts +256 -16
  10. package/dist/describe-resources.d.ts.map +1 -1
  11. package/dist/hcl/descend.d.ts +98 -0
  12. package/dist/hcl/descend.d.ts.map +1 -0
  13. package/dist/hcl/marker.d.ts +130 -0
  14. package/dist/hcl/marker.d.ts.map +1 -0
  15. package/dist/hcl/parse.d.ts +142 -9
  16. package/dist/hcl/parse.d.ts.map +1 -1
  17. package/dist/hcl/references.d.ts +76 -0
  18. package/dist/hcl/references.d.ts.map +1 -0
  19. package/dist/hcl/roots.d.ts +22 -3
  20. package/dist/hcl/roots.d.ts.map +1 -1
  21. package/dist/hcl/suppressions.d.ts +54 -0
  22. package/dist/hcl/suppressions.d.ts.map +1 -0
  23. package/dist/hcl/value.d.ts +77 -0
  24. package/dist/hcl/value.d.ts.map +1 -0
  25. package/dist/index.d.ts +5 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/integrity.json +32 -5
  28. package/dist/lint/audit-catalog.d.ts +3 -2
  29. package/dist/lint/audit-catalog.d.ts.map +1 -1
  30. package/dist/lint/audit-lineage.d.ts +68 -0
  31. package/dist/lint/audit-lineage.d.ts.map +1 -1
  32. package/dist/lint/post-synth/blocks.d.ts +53 -0
  33. package/dist/lint/post-synth/blocks.d.ts.map +1 -0
  34. package/dist/lint/post-synth/fixtures/load.d.ts +37 -0
  35. package/dist/lint/post-synth/fixtures/load.d.ts.map +1 -0
  36. package/dist/lint/post-synth/index.d.ts.map +1 -1
  37. package/dist/lint/post-synth/module-source.d.ts +38 -0
  38. package/dist/lint/post-synth/module-source.d.ts.map +1 -0
  39. package/dist/lint/post-synth/scope.d.ts +65 -0
  40. package/dist/lint/post-synth/scope.d.ts.map +1 -0
  41. package/dist/lint/post-synth/tf001.d.ts +18 -0
  42. package/dist/lint/post-synth/tf001.d.ts.map +1 -1
  43. package/dist/lint/post-synth/tf002.d.ts +32 -0
  44. package/dist/lint/post-synth/tf002.d.ts.map +1 -0
  45. package/dist/lint/post-synth/tf003.d.ts +22 -0
  46. package/dist/lint/post-synth/tf003.d.ts.map +1 -0
  47. package/dist/lint/post-synth/tf004.d.ts +22 -0
  48. package/dist/lint/post-synth/tf004.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf005.d.ts +28 -0
  50. package/dist/lint/post-synth/tf005.d.ts.map +1 -0
  51. package/dist/lint/post-synth/tf006.d.ts +21 -0
  52. package/dist/lint/post-synth/tf006.d.ts.map +1 -0
  53. package/dist/lint/post-synth/tf007.d.ts +21 -0
  54. package/dist/lint/post-synth/tf007.d.ts.map +1 -0
  55. package/dist/lint/post-synth/tf008.d.ts +22 -0
  56. package/dist/lint/post-synth/tf008.d.ts.map +1 -0
  57. package/dist/lint/post-synth/tf009.d.ts +24 -0
  58. package/dist/lint/post-synth/tf009.d.ts.map +1 -0
  59. package/dist/lint/post-synth/tf010.d.ts +19 -0
  60. package/dist/lint/post-synth/tf010.d.ts.map +1 -0
  61. package/dist/lint/post-synth/tf011.d.ts +18 -0
  62. package/dist/lint/post-synth/tf011.d.ts.map +1 -0
  63. package/dist/lint/post-synth/tf012.d.ts +17 -0
  64. package/dist/lint/post-synth/tf012.d.ts.map +1 -0
  65. package/dist/lint/post-synth/tf013.d.ts +21 -0
  66. package/dist/lint/post-synth/tf013.d.ts.map +1 -0
  67. package/dist/lint/post-synth/tf014.d.ts +25 -0
  68. package/dist/lint/post-synth/tf014.d.ts.map +1 -0
  69. package/dist/lint/post-synth/tf015.d.ts +21 -0
  70. package/dist/lint/post-synth/tf015.d.ts.map +1 -0
  71. package/dist/lint/post-synth/tf016.d.ts +30 -0
  72. package/dist/lint/post-synth/tf016.d.ts.map +1 -0
  73. package/dist/lint/post-synth/tf017.d.ts +19 -0
  74. package/dist/lint/post-synth/tf017.d.ts.map +1 -0
  75. package/dist/lint/post-synth/tf018.d.ts +20 -0
  76. package/dist/lint/post-synth/tf018.d.ts.map +1 -0
  77. package/dist/lint/post-synth/tf019.d.ts +25 -0
  78. package/dist/lint/post-synth/tf019.d.ts.map +1 -0
  79. package/dist/lint/post-synth/tf020.d.ts +38 -0
  80. package/dist/lint/post-synth/tf020.d.ts.map +1 -0
  81. package/dist/lint/post-synth/tf021.d.ts +41 -0
  82. package/dist/lint/post-synth/tf021.d.ts.map +1 -0
  83. package/dist/lint/post-synth/tf022.d.ts +24 -0
  84. package/dist/lint/post-synth/tf022.d.ts.map +1 -0
  85. package/dist/lint/post-synth/tf024.d.ts +24 -0
  86. package/dist/lint/post-synth/tf024.d.ts.map +1 -0
  87. package/dist/lint/post-synth/tf025.d.ts +29 -0
  88. package/dist/lint/post-synth/tf025.d.ts.map +1 -0
  89. package/dist/lint/post-synth/tf026.d.ts +21 -0
  90. package/dist/lint/post-synth/tf026.d.ts.map +1 -0
  91. package/dist/lint/rules/fixtures/load.d.ts +13 -0
  92. package/dist/lint/rules/fixtures/load.d.ts.map +1 -0
  93. package/dist/lint/rules/plan-before-apply.d.ts +11 -0
  94. package/dist/lint/rules/plan-before-apply.d.ts.map +1 -1
  95. package/dist/lint/secret-shape.d.ts +68 -0
  96. package/dist/lint/secret-shape.d.ts.map +1 -0
  97. package/dist/live-ownership.d.ts +55 -0
  98. package/dist/live-ownership.d.ts.map +1 -0
  99. package/dist/lsp/option-keys.d.ts.map +1 -1
  100. package/dist/manifest.json +1 -1
  101. package/dist/okf/index.md +24 -0
  102. package/dist/okf/rules/TF002.md +11 -0
  103. package/dist/okf/rules/TF003.md +11 -0
  104. package/dist/okf/rules/TF004.md +11 -0
  105. package/dist/okf/rules/TF005.md +11 -0
  106. package/dist/okf/rules/TF006.md +11 -0
  107. package/dist/okf/rules/TF007.md +11 -0
  108. package/dist/okf/rules/TF008.md +11 -0
  109. package/dist/okf/rules/TF009.md +11 -0
  110. package/dist/okf/rules/TF010.md +11 -0
  111. package/dist/okf/rules/TF011.md +11 -0
  112. package/dist/okf/rules/TF012.md +11 -0
  113. package/dist/okf/rules/TF013.md +11 -0
  114. package/dist/okf/rules/TF014.md +11 -0
  115. package/dist/okf/rules/TF015.md +11 -0
  116. package/dist/okf/rules/TF016.md +11 -0
  117. package/dist/okf/rules/TF017.md +11 -0
  118. package/dist/okf/rules/TF018.md +11 -0
  119. package/dist/okf/rules/TF019.md +11 -0
  120. package/dist/okf/rules/TF020.md +11 -0
  121. package/dist/okf/rules/TF021.md +11 -0
  122. package/dist/okf/rules/TF022.md +11 -0
  123. package/dist/okf/rules/TF024.md +11 -0
  124. package/dist/okf/rules/TF025.md +11 -0
  125. package/dist/okf/rules/TF026.md +11 -0
  126. package/dist/op/activities/index.d.ts +13 -6
  127. package/dist/op/activities/index.d.ts.map +1 -1
  128. package/dist/op/activities/live-detect.d.ts +51 -0
  129. package/dist/op/activities/live-detect.d.ts.map +1 -0
  130. package/dist/op/activities/terraform.d.ts +445 -17
  131. package/dist/op/activities/terraform.d.ts.map +1 -1
  132. package/dist/op/activity-contracts.d.ts +148 -0
  133. package/dist/op/activity-contracts.d.ts.map +1 -0
  134. package/dist/op/adoption.d.ts +118 -0
  135. package/dist/op/adoption.d.ts.map +1 -0
  136. package/dist/op/builders.d.ts +46 -6
  137. package/dist/op/builders.d.ts.map +1 -1
  138. package/dist/op/resolve-root-mode.d.ts +45 -0
  139. package/dist/op/resolve-root-mode.d.ts.map +1 -0
  140. package/dist/plugin.d.ts.map +1 -1
  141. package/dist/rules/blocks.ts +109 -0
  142. package/dist/rules/module-source.ts +121 -0
  143. package/dist/rules/plan-before-apply.ts +11 -0
  144. package/dist/rules/scope.ts +107 -0
  145. package/dist/rules/tf001.ts +32 -8
  146. package/dist/rules/tf002.ts +151 -0
  147. package/dist/rules/tf003.ts +71 -0
  148. package/dist/rules/tf004.ts +63 -0
  149. package/dist/rules/tf005.ts +76 -0
  150. package/dist/rules/tf006.ts +53 -0
  151. package/dist/rules/tf007.ts +78 -0
  152. package/dist/rules/tf008.ts +75 -0
  153. package/dist/rules/tf009.ts +59 -0
  154. package/dist/rules/tf010.ts +51 -0
  155. package/dist/rules/tf011.ts +49 -0
  156. package/dist/rules/tf012.ts +47 -0
  157. package/dist/rules/tf013.ts +63 -0
  158. package/dist/rules/tf014.ts +72 -0
  159. package/dist/rules/tf015.ts +63 -0
  160. package/dist/rules/tf016.ts +75 -0
  161. package/dist/rules/tf017.ts +50 -0
  162. package/dist/rules/tf018.ts +72 -0
  163. package/dist/rules/tf019.ts +72 -0
  164. package/dist/rules/tf020.ts +123 -0
  165. package/dist/rules/tf021.ts +126 -0
  166. package/dist/rules/tf022.ts +61 -0
  167. package/dist/rules/tf024.ts +70 -0
  168. package/dist/rules/tf025.ts +101 -0
  169. package/dist/rules/tf026.ts +80 -0
  170. package/dist/skills/chant-terraform.md +1 -1
  171. package/package.json +7 -2
  172. package/src/__fixtures__/live/main.tf +28 -0
  173. package/src/__fixtures__/live-adopt/estate.chdf.hcl +6 -0
  174. package/src/__fixtures__/live-adopt/main.tf +21 -0
  175. package/src/__fixtures__/live-estate/README.md +58 -0
  176. package/src/__fixtures__/live-estate/compute.tf +10 -0
  177. package/src/__fixtures__/live-estate/estate.chdf.hcl +1 -0
  178. package/src/__fixtures__/live-estate/logs.tf +8 -0
  179. package/src/__fixtures__/live-estate/network.tf +24 -0
  180. package/src/__fixtures__/live-estate/rule.tf +11 -0
  181. package/src/__fixtures__/live-estate/unowned.tf +21 -0
  182. package/src/__fixtures__/live-estate/versions.tf +31 -0
  183. package/src/__fixtures__/live-ls.json +98 -0
  184. package/src/__fixtures__/live-plan.json +285 -0
  185. package/src/__fixtures__/live-sidecar/estate.chdf.hcl +3 -0
  186. package/src/__fixtures__/live-sidecar/main.tf +24 -0
  187. package/src/__fixtures__/module-tree/README.md +16 -0
  188. package/src/__fixtures__/module-tree/cycle/main.tf +3 -0
  189. package/src/__fixtures__/module-tree/cycle/modules/a/main.tf +9 -0
  190. package/src/__fixtures__/module-tree/cycle/modules/b/main.tf +9 -0
  191. package/src/__fixtures__/module-tree/missing/main.tf +9 -0
  192. package/src/__fixtures__/module-tree/outside/root/main.tf +9 -0
  193. package/src/__fixtures__/module-tree/root/main.tf +28 -0
  194. package/src/__fixtures__/module-tree/root/modules/cdn/main.tf +15 -0
  195. package/src/__fixtures__/module-tree/root/modules/cdn/modules/bucket/main.tf +11 -0
  196. package/src/__fixtures__/module-tree/shared/main.tf +5 -0
  197. package/src/__fixtures__/references/main.tf +56 -0
  198. package/src/codegen/lint-rules-page.test.ts +28 -0
  199. package/src/composites/terraform-adopt-op.acceptance.test.ts +166 -0
  200. package/src/composites/terraform-adopt-op.test.ts +170 -0
  201. package/src/composites/terraform-adopt-op.ts +197 -0
  202. package/src/composites/terraform-apply-op.acceptance.test.ts +183 -5
  203. package/src/composites/terraform-apply-op.test.ts +167 -15
  204. package/src/composites/terraform-apply-op.ts +105 -28
  205. package/src/composites/terraform-watch-op.test.ts +85 -26
  206. package/src/composites/terraform-watch-op.ts +130 -73
  207. package/src/config.ts +32 -3
  208. package/src/describe-resources.live.test.ts +598 -0
  209. package/src/describe-resources.test.ts +53 -0
  210. package/src/describe-resources.ts +811 -28
  211. package/src/hcl/descend.test.ts +162 -0
  212. package/src/hcl/descend.ts +273 -0
  213. package/src/hcl/marker.test.ts +147 -0
  214. package/src/hcl/marker.ts +235 -0
  215. package/src/hcl/parse.test.ts +69 -0
  216. package/src/hcl/parse.ts +249 -27
  217. package/src/hcl/references.test.ts +116 -0
  218. package/src/hcl/references.ts +172 -0
  219. package/src/hcl/roots.ts +59 -5
  220. package/src/hcl/suppressions.test.ts +147 -0
  221. package/src/hcl/suppressions.ts +204 -0
  222. package/src/hcl/value.test.ts +73 -0
  223. package/src/hcl/value.ts +106 -0
  224. package/src/index.ts +31 -1
  225. package/src/lint/audit-catalog.ts +264 -3
  226. package/src/lint/audit-lineage.ts +319 -1
  227. package/src/lint/audit.test.ts +40 -1
  228. package/src/lint/post-synth/blocks.ts +109 -0
  229. package/src/lint/post-synth/fixtures/TF001/negative.tf +28 -0
  230. package/src/lint/post-synth/fixtures/TF001/positive.tf +24 -0
  231. package/src/lint/post-synth/fixtures/TF002/negative.tf +17 -0
  232. package/src/lint/post-synth/fixtures/TF002/positive-legacy.tf +10 -0
  233. package/src/lint/post-synth/fixtures/TF002/positive-no-version.tf +12 -0
  234. package/src/lint/post-synth/fixtures/TF002/positive.tf +7 -0
  235. package/src/lint/post-synth/fixtures/TF003/negative.tf +10 -0
  236. package/src/lint/post-synth/fixtures/TF003/positive.tf +8 -0
  237. package/src/lint/post-synth/fixtures/TF004/negative-git.tf +3 -0
  238. package/src/lint/post-synth/fixtures/TF004/negative-local.tf +3 -0
  239. package/src/lint/post-synth/fixtures/TF004/negative.tf +4 -0
  240. package/src/lint/post-synth/fixtures/TF004/positive.tf +3 -0
  241. package/src/lint/post-synth/fixtures/TF005/negative-local.tf +3 -0
  242. package/src/lint/post-synth/fixtures/TF005/negative-registry.tf +3 -0
  243. package/src/lint/post-synth/fixtures/TF005/negative-sha.tf +3 -0
  244. package/src/lint/post-synth/fixtures/TF005/negative.tf +3 -0
  245. package/src/lint/post-synth/fixtures/TF005/positive-branch.tf +3 -0
  246. package/src/lint/post-synth/fixtures/TF005/positive-non-semver.tf +3 -0
  247. package/src/lint/post-synth/fixtures/TF005/positive.tf +3 -0
  248. package/src/lint/post-synth/fixtures/TF006/negative.tf +5 -0
  249. package/src/lint/post-synth/fixtures/TF006/positive-empty-default.tf +11 -0
  250. package/src/lint/post-synth/fixtures/TF006/positive.tf +6 -0
  251. package/src/lint/post-synth/fixtures/TF007/negative.tf +10 -0
  252. package/src/lint/post-synth/fixtures/TF007/positive.tf +9 -0
  253. package/src/lint/post-synth/fixtures/TF008/negative-variable.tf +10 -0
  254. package/src/lint/post-synth/fixtures/TF008/negative.tf +8 -0
  255. package/src/lint/post-synth/fixtures/TF008/positive-other-provider.tf +10 -0
  256. package/src/lint/post-synth/fixtures/TF008/positive.tf +5 -0
  257. package/src/lint/post-synth/fixtures/TF009/negative.tf +10 -0
  258. package/src/lint/post-synth/fixtures/TF009/positive.tf +4 -0
  259. package/src/lint/post-synth/fixtures/TF010/negative.tf +5 -0
  260. package/src/lint/post-synth/fixtures/TF010/positive.tf +4 -0
  261. package/src/lint/post-synth/fixtures/TF011/negative.tf +5 -0
  262. package/src/lint/post-synth/fixtures/TF011/positive-blank.tf +5 -0
  263. package/src/lint/post-synth/fixtures/TF011/positive.tf +4 -0
  264. package/src/lint/post-synth/fixtures/TF012/negative.tf +4 -0
  265. package/src/lint/post-synth/fixtures/TF012/positive.tf +3 -0
  266. package/src/lint/post-synth/fixtures/TF013/negative.tf +8 -0
  267. package/src/lint/post-synth/fixtures/TF013/positive-data.tf +8 -0
  268. package/src/lint/post-synth/fixtures/TF013/positive.tf +8 -0
  269. package/src/lint/post-synth/fixtures/TF014/negative/main.tf +26 -0
  270. package/src/lint/post-synth/fixtures/TF014/negative/modules/cdn/main.tf +19 -0
  271. package/src/lint/post-synth/fixtures/TF014/positive/main.tf +16 -0
  272. package/src/lint/post-synth/fixtures/TF014/positive/modules/cdn/main.tf +15 -0
  273. package/src/lint/post-synth/fixtures/TF015/negative/main.tf +13 -0
  274. package/src/lint/post-synth/fixtures/TF015/negative/modules/cdn/main.tf +16 -0
  275. package/src/lint/post-synth/fixtures/TF015/positive/main.tf +13 -0
  276. package/src/lint/post-synth/fixtures/TF015/positive/modules/cdn/main.tf +14 -0
  277. package/src/lint/post-synth/fixtures/TF016/negative-template.tf +7 -0
  278. package/src/lint/post-synth/fixtures/TF016/negative.tf +16 -0
  279. package/src/lint/post-synth/fixtures/TF016/positive-shared-file.tf +12 -0
  280. package/src/lint/post-synth/fixtures/TF016/positive.tf +13 -0
  281. package/src/lint/post-synth/fixtures/TF017/negative.tf +4 -0
  282. package/src/lint/post-synth/fixtures/TF017/positive.tf +5 -0
  283. package/src/lint/post-synth/fixtures/TF018/negative.tf +9 -0
  284. package/src/lint/post-synth/fixtures/TF018/positive-data.tf +9 -0
  285. package/src/lint/post-synth/fixtures/TF018/positive.tf +4 -0
  286. package/src/lint/post-synth/fixtures/TF019/negative.tf +14 -0
  287. package/src/lint/post-synth/fixtures/TF019/positive-output.tf +5 -0
  288. package/src/lint/post-synth/fixtures/TF019/positive.tf +16 -0
  289. package/src/lint/post-synth/fixtures/TF020/negative.tf +43 -0
  290. package/src/lint/post-synth/fixtures/TF020/positive.tf +32 -0
  291. package/src/lint/post-synth/fixtures/TF020/scoped/main.tf +21 -0
  292. package/src/lint/post-synth/fixtures/TF020/scoped/modules/cdn/main.tf +13 -0
  293. package/src/lint/post-synth/fixtures/TF021/negative.tf +31 -0
  294. package/src/lint/post-synth/fixtures/TF021/positive-length.tf +8 -0
  295. package/src/lint/post-synth/fixtures/TF021/positive.tf +8 -0
  296. package/src/lint/post-synth/fixtures/TF022/negative-locator.tf +7 -0
  297. package/src/lint/post-synth/fixtures/TF022/negative.tf +10 -0
  298. package/src/lint/post-synth/fixtures/TF022/positive-nested.tf +10 -0
  299. package/src/lint/post-synth/fixtures/TF022/positive.tf +6 -0
  300. package/src/lint/post-synth/fixtures/TF023/negative/main.tf +15 -0
  301. package/src/lint/post-synth/fixtures/TF023/positive/main.tf +9 -0
  302. package/src/lint/post-synth/fixtures/TF023/positive/state.json +24 -0
  303. package/src/lint/post-synth/fixtures/TF024/live-clean/main.tf +9 -0
  304. package/src/lint/post-synth/fixtures/TF024/live-with-backend/main.tf +14 -0
  305. package/src/lint/post-synth/fixtures/TF024/live-with-cloud/main.tf +16 -0
  306. package/src/lint/post-synth/fixtures/TF025/live-clean/main.tf +13 -0
  307. package/src/lint/post-synth/fixtures/TF025/live-with-workspace-ref/main.tf +13 -0
  308. package/src/lint/post-synth/fixtures/TF026/live-default/main.tf +9 -0
  309. package/src/lint/post-synth/fixtures/TF026/live-explicit-delete/main.tf +13 -0
  310. package/src/lint/post-synth/fixtures/TF026/live-keep/main.tf +13 -0
  311. package/src/lint/post-synth/fixtures/load.ts +108 -0
  312. package/src/lint/post-synth/index.ts +48 -0
  313. package/src/lint/post-synth/module-rules.test.ts +153 -0
  314. package/src/lint/post-synth/module-source.test.ts +75 -0
  315. package/src/lint/post-synth/module-source.ts +121 -0
  316. package/src/lint/post-synth/post-synth.test.ts +14 -15
  317. package/src/lint/post-synth/scope.ts +107 -0
  318. package/src/lint/post-synth/single-block.test.ts +263 -0
  319. package/src/lint/post-synth/suppression.test.ts +110 -0
  320. package/src/lint/post-synth/tf001.ts +32 -8
  321. package/src/lint/post-synth/tf002.test.ts +93 -0
  322. package/src/lint/post-synth/tf002.ts +151 -0
  323. package/src/lint/post-synth/tf003.test.ts +75 -0
  324. package/src/lint/post-synth/tf003.ts +71 -0
  325. package/src/lint/post-synth/tf004.test.ts +47 -0
  326. package/src/lint/post-synth/tf004.ts +63 -0
  327. package/src/lint/post-synth/tf005.test.ts +57 -0
  328. package/src/lint/post-synth/tf005.ts +76 -0
  329. package/src/lint/post-synth/tf006.ts +53 -0
  330. package/src/lint/post-synth/tf007.ts +78 -0
  331. package/src/lint/post-synth/tf008.ts +75 -0
  332. package/src/lint/post-synth/tf009.ts +59 -0
  333. package/src/lint/post-synth/tf010.ts +51 -0
  334. package/src/lint/post-synth/tf011.ts +49 -0
  335. package/src/lint/post-synth/tf012.ts +47 -0
  336. package/src/lint/post-synth/tf013.ts +63 -0
  337. package/src/lint/post-synth/tf014.ts +72 -0
  338. package/src/lint/post-synth/tf015.ts +63 -0
  339. package/src/lint/post-synth/tf016.ts +75 -0
  340. package/src/lint/post-synth/tf017.ts +50 -0
  341. package/src/lint/post-synth/tf018.ts +72 -0
  342. package/src/lint/post-synth/tf019.ts +72 -0
  343. package/src/lint/post-synth/tf020.ts +123 -0
  344. package/src/lint/post-synth/tf021.ts +126 -0
  345. package/src/lint/post-synth/tf022.ts +61 -0
  346. package/src/lint/post-synth/tf024.test.ts +42 -0
  347. package/src/lint/post-synth/tf024.ts +70 -0
  348. package/src/lint/post-synth/tf025.test.ts +46 -0
  349. package/src/lint/post-synth/tf025.ts +101 -0
  350. package/src/lint/post-synth/tf026.test.ts +56 -0
  351. package/src/lint/post-synth/tf026.ts +80 -0
  352. package/src/lint/presets.test.ts +72 -0
  353. package/src/lint/rules/fixtures/TF101/negative-op.ts +2 -0
  354. package/src/lint/rules/fixtures/TF101/positive-op.ts +2 -0
  355. package/src/lint/rules/fixtures/load.ts +53 -0
  356. package/src/lint/rules/plan-before-apply.test.ts +112 -7
  357. package/src/lint/rules/plan-before-apply.ts +11 -0
  358. package/src/lint/secret-shape.test.ts +150 -0
  359. package/src/lint/secret-shape.ts +159 -0
  360. package/src/live-ownership.ts +60 -0
  361. package/src/lsp/completions.test.ts +3 -2
  362. package/src/lsp/option-keys.ts +10 -5
  363. package/src/op/activities/choudoufu.acceptance.test.ts +143 -0
  364. package/src/op/activities/choudoufu.test.ts +693 -0
  365. package/src/op/activities/index.ts +40 -4
  366. package/src/op/activities/live-detect.test.ts +143 -0
  367. package/src/op/activities/live-detect.ts +162 -0
  368. package/src/op/activities/registry.test.ts +11 -3
  369. package/src/op/activities/terraform.test.ts +7 -3
  370. package/src/op/activities/terraform.ts +823 -69
  371. package/src/op/activity-contracts.test.ts +147 -0
  372. package/src/op/activity-contracts.ts +178 -0
  373. package/src/op/adoption.test.ts +170 -0
  374. package/src/op/adoption.ts +241 -0
  375. package/src/op/builders.test.ts +60 -5
  376. package/src/op/builders.ts +93 -5
  377. package/src/op/resolve-root-mode.test.ts +85 -0
  378. package/src/op/resolve-root-mode.ts +72 -0
  379. package/src/plugin.test.ts +68 -0
  380. package/src/plugin.ts +75 -9
  381. package/src/skills/chant-terraform.md +1 -1
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Live observation for declared terraform entities (#2087).
2
+ * Live observation for declared terraform entities (#2087, #2104).
3
3
  *
4
4
  * `buildRoots()` turns each configured root module into one entity per HCL
5
5
  * block, keyed `<root>/<address>` (`./hcl/parse.ts`). This reader answers the
6
- * lifecycle question for those entities by running `terraform show -json` over
7
- * the root's current state and matching addresses.
6
+ * lifecycle question for those entities, and how it answers depends on one
7
+ * fact the parse already recorded on every entity: the root's `mode`.
8
8
  *
9
- * ## The state file is the ownership answer
9
+ * A **stock** root is read with `terraform show -json` over its state, and
10
+ * state membership is the ownership answer. A **live** root, meaning
11
+ * `terraform.binary` is `"choudoufu"` and the root declares an estate
12
+ * (#2103), has no state to show: it is read with `choudoufu live-plan -json`,
13
+ * and the marker on the resource is the ownership answer. The two halves are
14
+ * two adapters over the same `observeEntities` harness, picked per root.
15
+ *
16
+ * ## The state file is the ownership answer, on a stock root
10
17
  *
11
18
  * Every other lexicon in chant stamps a tag or a label at synthesis and reads
12
19
  * it back off the live resource. Terraform stamps nothing, and there is
@@ -24,10 +31,58 @@
24
31
  * escalates to a delete, which is the correct posture for a thing chant did
25
32
  * not read a verdict for.
26
33
  *
27
- * That places terraform on the trusted-state-file row of the third axis in
28
- * docs/src/content/docs/concepts/lifecycle-models.mdx, where chant elsewhere
29
- * sits on the live-marker row. `docs/pages/observation.mdx` is the reader's
30
- * version of this paragraph.
34
+ * That places a stock terraform root on the trusted-state-file row of the
35
+ * third axis in docs/src/content/docs/concepts/lifecycle-models.mdx, where
36
+ * chant elsewhere sits on the live-marker row. `docs/pages/observation.mdx`
37
+ * is the reader's version of this paragraph.
38
+ *
39
+ * ## The marker is the ownership answer, on a live root
40
+ *
41
+ * choudoufu keeps no authoritative state file. Every taggable resource it
42
+ * manages carries `tofu-estate` and `tofu-address` tags written in the create
43
+ * call, prior state is rebuilt from the live system each run, and the state
44
+ * file, where one exists at all, is a disposable cache. So a live root is on
45
+ * the live-marker row, the row every other chant lexicon sits in.
46
+ *
47
+ * chant does not re-derive that verdict from the tags. `live-plan -json`
48
+ * (choudoufu issue #788) already splits every declared instance four ways,
49
+ * and those four are the classification:
50
+ *
51
+ * | live-plan section | chant |
52
+ * |---|---|
53
+ * | `bound[]`, `source: "marker"` | present, `owned`; the marker named this estate and this address, and it is surfaced as `ResourceMetadata.marker` |
54
+ * | `bound[]`, any other source | present, `owned` by derivation, record, or cache; noted as such, and no marker is surfaced because none was read |
55
+ * | `unowned[]` with `adopt_*` | present, `unknown`; an adoptable match, carrying the exact two tag values #2105 would write |
56
+ * | `unowned[]` without | present, `foreign`; a live resource is in the way at a declared identity and the plan will not touch it |
57
+ * | `omissions[]` | not-observed, `ABSENT` excepted (see below) |
58
+ *
59
+ * The ownership channel this declares is `./live-ownership.ts`'s
60
+ * `TERRAFORM_LIVE_MARKER_KEYS`, and its module doc holds the record of why
61
+ * the keys are declared while the verdicts are set here by hand rather than
62
+ * core's `OwnershipChannel` growing a classifier for them.
63
+ *
64
+ * ### `ABSENT` is absence, and the rest of the omissions are not
65
+ *
66
+ * An omission is choudoufu saying an instance is missing from prior state,
67
+ * with a reason. Most of those reasons are claims of ignorance and become
68
+ * NOT-OBSERVED, which is what the tri-state exists for. One is not:
69
+ * `ABSENT` means "the instance has a usable import identity and the provider
70
+ * reported, normally, that no such object exists", which is exactly chant's
71
+ * OBSERVED-ABSENT, spelled "in neither map". Reporting it as not-observed
72
+ * instead would mean a live root could never propose a create for a resource
73
+ * that genuinely is not there yet, which is the opposite of the honesty the
74
+ * contract is for. `UNOWNED` is the other exception, in the other direction:
75
+ * the `unowned[]` section answers for that address with a real verdict, so
76
+ * the paired omission is never read on its own.
77
+ *
78
+ * ### A block's instances, aggregated
79
+ *
80
+ * chant's entity is the HCL block, `aws_eip.pool`; the document's rows are
81
+ * instances, `aws_eip.pool[0]`. So a block's verdict is the aggregate of its
82
+ * instances, in this precedence: any instance not-observed makes the block
83
+ * not-observed, then foreign, then adoptable, then owned, and a block whose
84
+ * instances are all absent is absent. Partial knowledge is not knowledge,
85
+ * which is the same rule choudoufu applies to its own `INCOMPLETE_BLOCK`.
31
86
  *
32
87
  * ## What is readable at all
33
88
  *
@@ -37,13 +92,28 @@
37
92
  * That is honest, not a gap to close by inventing an address for something
38
93
  * that has none.
39
94
  *
95
+ * A live root reads one block narrower. `live-plan`'s document is prior state
96
+ * for managed resources, so a `data` block has no row in it either and reads
97
+ * `unsupported-kind` there, where the stock reader can answer for it.
98
+ *
40
99
  * ## Tri-state (#1089)
41
100
  *
42
- * A root whose `init` or `show` fails reports EVERY entity declared in that
43
- * root as not-observed with reason `read-failed` and the root named, never
44
- * absent. A failed read must never render as a list of creates. Roots are
45
- * read independently and merged (`mergeObservations`), so one broken backend
46
- * does not un-observe a root that answered.
101
+ * A root whose `init` or `show` fails, or whose `live-plan` fails, reports
102
+ * EVERY entity declared in that root as not-observed with the mapped reason
103
+ * and the root named, never absent. A failed read must never render as a list
104
+ * of creates. Roots are read independently and merged (`mergeObservations`),
105
+ * so one broken backend does not un-observe a root that answered.
106
+ *
107
+ * ## Beyond the declared estate, on a live root
108
+ *
109
+ * `live-ls -estate -json` lists the whole estate off the Resource Groups
110
+ * Tagging API with no configuration read at all, which is a question a state
111
+ * file cannot answer: what does this estate hold that nothing declares? That
112
+ * is what makes `ambientKinds()`, `observeAmbient()` and `teardownOwned()`
113
+ * implementable on this lexicon for the first time. `-consistent` is always
114
+ * passed, because that index lags a tag write by about a minute
115
+ * (`live-ls`'s own help text) and a listing taken right after an apply can
116
+ * otherwise show a resource under both its old and new estate, or neither.
47
117
  *
48
118
  * ## Nothing from `values` is surfaced
49
119
  *
@@ -54,7 +124,13 @@
54
124
  * `id`, which is the physical id every observation carries.
55
125
  */
56
126
 
57
- import type { DescribeResourcesResult } from "@intentius/chant/lexicon";
127
+ import type {
128
+ DescribeResourcesResult,
129
+ ResourceMetadata,
130
+ TeardownCandidate,
131
+ TeardownEnumeration,
132
+ TeardownHole,
133
+ } from "@intentius/chant/lexicon";
58
134
  import {
59
135
  mergeObservations,
60
136
  normalizeObservation,
@@ -63,14 +139,22 @@ import {
63
139
  type DeclaredEntity,
64
140
  type EntityObservation,
65
141
  type ObserverAdapter,
142
+ type UnobservedReason,
66
143
  } from "@intentius/chant/observation";
67
- import { terraformInit, terraformShow } from "./op/activities/terraform";
68
- import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE } from "./hcl/parse";
144
+ import type { OwnershipMarker } from "@intentius/chant/ownership";
145
+ import {
146
+ choudoufuLiveLs,
147
+ choudoufuLivePlan,
148
+ terraformInit,
149
+ terraformShow,
150
+ } from "./op/activities/terraform";
151
+ import { DATA_TYPE, MODULE_TYPE, RESOURCE_TYPE, callersOfKey } from "./hcl/parse";
69
152
 
70
153
  // The channel keys live in their own module so `plugin.ts` can declare
71
154
  // `ownershipChannel` without loading this reader. Re-exported here because
72
155
  // this is where they are used.
73
156
  export { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
157
+ export { TERRAFORM_LIVE_MARKER_KEYS } from "./live-ownership";
74
158
 
75
159
  /** One row out of `values.root_module.resources[]`, at any module depth. */
76
160
  export interface StateResourceRow {
@@ -165,40 +249,724 @@ function moduleIsLive(address: string, index: StateIndex): boolean {
165
249
  return false;
166
250
  }
167
251
 
168
- /** The activity pair this reader drives. Injectable so tests never run terraform. */
252
+ /* ─────────────────────────── live roots (#2104) ─────────────────────────── */
253
+
254
+ /** One `bound[]` entry: a declared instance the plan admitted into prior state. */
255
+ export interface LivePlanBoundRow {
256
+ addr: string;
257
+ type?: string;
258
+ /** The import id it bound to, when the resolver produced one. */
259
+ identity?: string;
260
+ /** Which admission path supplied the identity: `marker`, `record`, `derived` or `cache`. */
261
+ source?: string;
262
+ }
263
+
264
+ /** One `omissions[]` entry: a declared instance the plan could not read, and why. */
265
+ export interface LivePlanOmissionRow {
266
+ addr: string;
267
+ /** choudoufu's own machine-readable code (`ABSENT`, `FAILED`, `UNOWNED`, …). */
268
+ reason: string;
269
+ detail: string;
270
+ }
271
+
272
+ /** One `unowned[]` entry: a live resource at a declared identity without this estate's marker. */
273
+ export interface LivePlanUnownedRow {
274
+ addr: string;
275
+ type?: string;
276
+ identity?: string;
277
+ /** `tofu_estate`: the marker it does carry, when it carries one. Empty means no marker at all. */
278
+ heldBy?: string;
279
+ /** `adopt_tofu_estate`: present exactly when adoption is this run's to offer. */
280
+ adoptEstate?: string;
281
+ /** `adopt_tofu_address`: the escaped address the adopting tag write would carry. */
282
+ adoptAddress?: string;
283
+ }
284
+
285
+ /** A `live-plan -json` document, indexed by declared instance address. */
286
+ export interface LivePlanIndex {
287
+ /** `estate`: the estate every section was computed against. */
288
+ estate: string;
289
+ bound: Map<string, LivePlanBoundRow>;
290
+ omissions: Map<string, LivePlanOmissionRow>;
291
+ unowned: Map<string, LivePlanUnownedRow>;
292
+ /** Every address any section named, so a block can find its own instances. */
293
+ addresses: string[];
294
+ /** `diagnostics[]` summaries, for the observation's run-level notes. */
295
+ diagnostics: string[];
296
+ }
297
+
298
+ /**
299
+ * Index choudoufu issue #788's document. Unknown fields are ignored and a
300
+ * missing section reads as empty: a document from a newer choudoufu must
301
+ * degrade to "this section said nothing", never to a throw.
302
+ */
303
+ export function indexLivePlan(document: unknown): LivePlanIndex {
304
+ const doc = asRecord(document);
305
+ const index: LivePlanIndex = {
306
+ estate: asString(doc.estate) ?? "",
307
+ bound: new Map(),
308
+ omissions: new Map(),
309
+ unowned: new Map(),
310
+ addresses: [],
311
+ diagnostics: [],
312
+ };
313
+
314
+ for (const entry of asArray(doc.bound)) {
315
+ const row = asRecord(entry);
316
+ const addr = asString(row.addr);
317
+ if (!addr) continue;
318
+ index.bound.set(addr, {
319
+ addr,
320
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
321
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
322
+ ...(asString(row.source) ? { source: asString(row.source) } : {}),
323
+ });
324
+ }
325
+
326
+ for (const entry of asArray(doc.omissions)) {
327
+ const row = asRecord(entry);
328
+ const addr = asString(row.addr);
329
+ if (!addr) continue;
330
+ index.omissions.set(addr, {
331
+ addr,
332
+ reason: asString(row.reason) ?? "",
333
+ detail: asString(row.detail) ?? "",
334
+ });
335
+ }
336
+
337
+ for (const entry of asArray(doc.unowned)) {
338
+ const row = asRecord(entry);
339
+ const addr = asString(row.addr);
340
+ if (!addr) continue;
341
+ index.unowned.set(addr, {
342
+ addr,
343
+ ...(asString(row.type) ? { type: asString(row.type) } : {}),
344
+ ...(asString(row.identity) ? { identity: asString(row.identity) } : {}),
345
+ ...(asString(row.tofu_estate) ? { heldBy: asString(row.tofu_estate) } : {}),
346
+ ...(asString(row.adopt_tofu_estate) ? { adoptEstate: asString(row.adopt_tofu_estate) } : {}),
347
+ ...(asString(row.adopt_tofu_address) ? { adoptAddress: asString(row.adopt_tofu_address) } : {}),
348
+ });
349
+ }
350
+
351
+ for (const entry of asArray(doc.diagnostics)) {
352
+ const row = asRecord(entry);
353
+ const summary = asString(row.summary);
354
+ if (summary) index.diagnostics.push(`${asString(row.severity) ?? "warning"}: ${summary}`);
355
+ }
356
+
357
+ index.addresses = [
358
+ ...new Set([...index.bound.keys(), ...index.omissions.keys(), ...index.unowned.keys()]),
359
+ ].sort();
360
+ return index;
361
+ }
362
+
363
+ /**
364
+ * choudoufu's omission reason codes, mapped onto chant's total vocabulary
365
+ * (`internal/live/projection/result.go`). `"absent"` is not an
366
+ * {@link UnobservedReason}: it is the tri-state's OBSERVED-ABSENT, the one
367
+ * omission that is a claim of knowledge rather than of ignorance.
368
+ *
369
+ * A code this table does not know maps to `read-failed`, which is the safe
370
+ * direction: no NOT-OBSERVED verdict ever becomes a create or a delete.
371
+ */
372
+ export const LIVE_PLAN_OMISSION_REASONS: Readonly<Record<string, UnobservedReason | "absent">> = {
373
+ // The provider was asked and reported, normally, that nothing is there.
374
+ ABSENT: "absent",
375
+ // The provider errored, or could not be reached in a usable order.
376
+ FAILED: "read-failed",
377
+ PARENT_UNAVAILABLE: "read-failed",
378
+ CYCLE: "read-failed",
379
+ INCOMPLETE_BLOCK: "read-failed",
380
+ LISTED_NOT_IMPORTABLE: "read-failed",
381
+ // Something exists at the identity and does not carry this estate's marker.
382
+ // Normally answered by the `unowned[]` section before this table is read;
383
+ // this entry is what happens if a document ever carries the omission alone.
384
+ UNOWNED: "read-failed",
385
+ // The mode cannot name, list or tag this instance at all: a server-assigned
386
+ // identity with no marker to find it by, a resource with no cloud object to
387
+ // read, or an address another declared instance has taken over.
388
+ NEEDS_DISCOVERY: "unsupported-kind",
389
+ UNREADABLE: "unsupported-kind",
390
+ SUPERSEDED: "unsupported-kind",
391
+ };
392
+
393
+ /** Does this text name a credentials or authorization failure rather than any other error? */
394
+ function readsAsCredentials(text: string): boolean {
395
+ return /\b(no valid credential|credentials?|not authorized|unauthorized|accessdenied|access denied|expiredtoken|invalidclienttokenid|signature)\b/i.test(
396
+ text,
397
+ );
398
+ }
399
+
400
+ /** The chant verdict for one omission: `no-credentials` when its detail says so. */
401
+ function omissionVerdict(row: LivePlanOmissionRow): UnobservedReason | "absent" {
402
+ const mapped = LIVE_PLAN_OMISSION_REASONS[row.reason] ?? "read-failed";
403
+ if (mapped === "read-failed" && readsAsCredentials(row.detail)) return "no-credentials";
404
+ return mapped;
405
+ }
406
+
407
+ /** Every instance address in the document belonging to the declared block `address`. */
408
+ function instancesOf(address: string, index: LivePlanIndex): string[] {
409
+ return index.addresses.filter((a) => a === address || a.startsWith(`${address}[`));
410
+ }
411
+
412
+ /** True when the document names any instance under `module.<name>`, at any depth. */
413
+ function liveModuleMembers(address: string, index: LivePlanIndex): string[] {
414
+ return index.addresses.filter(
415
+ (a) => a === address || a.startsWith(`${address}.`) || a.startsWith(`${address}[`),
416
+ );
417
+ }
418
+
419
+ /** One instance's verdict, before a block aggregates its instances. */
420
+ type InstanceVerdict =
421
+ | { kind: "owned"; row: LivePlanBoundRow }
422
+ | { kind: "adoptable"; row: LivePlanUnownedRow }
423
+ | { kind: "foreign"; row: LivePlanUnownedRow }
424
+ | { kind: "absent" }
425
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
426
+
427
+ /**
428
+ * Classify one instance address against the document, `unowned[]` first: a
429
+ * declared instance that also carries a `UNOWNED` omission is answered by the
430
+ * section that has the verdict, not by the one that has the apology.
431
+ */
432
+ function classifyLiveInstance(address: string, index: LivePlanIndex): InstanceVerdict {
433
+ const unowned = index.unowned.get(address);
434
+ if (unowned) {
435
+ return unowned.adoptEstate || unowned.adoptAddress
436
+ ? { kind: "adoptable", row: unowned }
437
+ : { kind: "foreign", row: unowned };
438
+ }
439
+
440
+ const bound = index.bound.get(address);
441
+ if (bound) return { kind: "owned", row: bound };
442
+
443
+ const omission = index.omissions.get(address);
444
+ if (omission) {
445
+ const verdict = omissionVerdict(omission);
446
+ if (verdict === "absent") return { kind: "absent" };
447
+ return {
448
+ kind: "unobserved",
449
+ reason: verdict,
450
+ detail: `${address}: ${omission.reason}${omission.detail ? ` — ${omission.detail}` : ""}`,
451
+ };
452
+ }
453
+
454
+ return { kind: "absent" };
455
+ }
456
+
457
+ /**
458
+ * The declared block's verdict, aggregated over its instances in the
459
+ * precedence the module doc states: not-observed, then foreign, then
460
+ * adoptable, then owned, and all-absent last. A block whose instances are
461
+ * only partly known is not partly owned; it is unknown, which never becomes
462
+ * a delete.
463
+ */
464
+ function readLiveResource(
465
+ entity: TerraformDeclared,
466
+ index: LivePlanIndex,
467
+ root: string,
468
+ queried: string,
469
+ ): EntityObservation {
470
+ const { address } = entity;
471
+ const instances = instancesOf(address, index);
472
+ if (instances.length === 0) return { absent: true, queried };
473
+
474
+ const verdicts = instances.map((a) => classifyLiveInstance(a, index));
475
+ const attributes: Record<string, unknown> = { address, root, estate: index.estate };
476
+ if (instances.length > 1 || instances[0] !== address) attributes.instances = instances;
477
+
478
+ const unobserved = verdicts.find((v) => v.kind === "unobserved");
479
+ if (unobserved && unobserved.kind === "unobserved") {
480
+ return { unobserved: { reason: unobserved.reason, detail: unobserved.detail }, queried };
481
+ }
482
+
483
+ const foreign = verdicts.find((v) => v.kind === "foreign");
484
+ if (foreign && foreign.kind === "foreign") {
485
+ return {
486
+ present: {
487
+ type: entity.type,
488
+ physicalId: foreign.row.identity ?? address,
489
+ status: "unowned",
490
+ ownership: "foreign",
491
+ attributes: {
492
+ ...attributes,
493
+ ...(foreign.row.type ? { resourceType: foreign.row.type } : {}),
494
+ ...(foreign.row.heldBy ? { heldBy: foreign.row.heldBy } : {}),
495
+ },
496
+ },
497
+ queried,
498
+ };
499
+ }
500
+
501
+ const adoptable = verdicts.find((v) => v.kind === "adoptable");
502
+ if (adoptable && adoptable.kind === "adoptable") {
503
+ // `unknown`, not `foreign`: the resource matches the declaration and one
504
+ // tag write claims it. The two values that write carries are the whole
505
+ // adoption proposal #2105 builds its Op on, so they ride the metadata
506
+ // rather than being re-derived from the address there. No `marker` is
507
+ // surfaced, because nothing was read off this resource: it carries none.
508
+ return {
509
+ present: {
510
+ type: entity.type,
511
+ physicalId: adoptable.row.identity ?? address,
512
+ status: "adoptable",
513
+ ownership: "unknown",
514
+ attributes: {
515
+ ...attributes,
516
+ ...(adoptable.row.type ? { resourceType: adoptable.row.type } : {}),
517
+ ...(adoptable.row.adoptEstate ? { adoptTofuEstate: adoptable.row.adoptEstate } : {}),
518
+ ...(adoptable.row.adoptAddress ? { adoptTofuAddress: adoptable.row.adoptAddress } : {}),
519
+ },
520
+ },
521
+ queried,
522
+ };
523
+ }
524
+
525
+ const owned = verdicts.filter((v) => v.kind === "owned") as Array<{ kind: "owned"; row: LivePlanBoundRow }>;
526
+ if (owned.length === 0) return { absent: true, queried };
527
+
528
+ const sources = [...new Set(owned.map((v) => v.row.source ?? "unknown"))].sort();
529
+ const byMarker = sources.length === 1 && sources[0] === "marker";
530
+ return {
531
+ present: {
532
+ type: entity.type,
533
+ physicalId: owned.length === 1 ? (owned[0]!.row.identity ?? address) : address,
534
+ status: "bound",
535
+ ownership: "owned",
536
+ // The marker is surfaced only where one was actually read. A `derived`
537
+ // bind computed the identity from the configuration and looked at no
538
+ // tag, so claiming a marker for it would be a guess.
539
+ ...(byMarker ? { marker: { stack: index.estate } as OwnershipMarker } : {}),
540
+ attributes: {
541
+ ...attributes,
542
+ ...(owned[0]!.row.type ? { resourceType: owned[0]!.row.type } : {}),
543
+ boundBy: sources.join(","),
544
+ },
545
+ },
546
+ queried,
547
+ };
548
+ }
549
+
550
+ /**
551
+ * One live root's reader: `live-plan -json`, then address matching against
552
+ * the document. `terraform show` is never run here, and there is no state
553
+ * file to run it over.
554
+ */
555
+ function liveAdapter(root: string, cwd: string | undefined, deps: TerraformReadDeps): ObserverAdapter<LivePlanIndex> {
556
+ const where = cwd ? { cwd } : {};
557
+ let dir: string | undefined;
558
+
559
+ return {
560
+ async bind(): Promise<LivePlanIndex> {
561
+ const planned = await deps.livePlan({ root, ...where });
562
+ dir = planned.dir;
563
+ return indexLivePlan(planned.json);
564
+ },
565
+
566
+ classifyBindFailure(err) {
567
+ const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
568
+ const full = err instanceof Error ? err.message : String(err);
569
+ return {
570
+ reason: readsAsCredentials(full) ? "no-credentials" : "read-failed",
571
+ detail: `terraform.roots.${root}${dir ? ` (${dir})` : ""}: ${message}`,
572
+ };
573
+ },
574
+
575
+ async read(index, entity): Promise<EntityObservation> {
576
+ const declared = entity as TerraformDeclared;
577
+ const { address } = declared;
578
+ const queried = `choudoufu live-plan -json (root "${root}", estate "${index.estate}", address "${address}")`;
579
+
580
+ if (entity.type === RESOURCE_TYPE) return readLiveResource(declared, index, root, queried);
581
+
582
+ if (entity.type === DATA_TYPE) {
583
+ return {
584
+ unobserved: {
585
+ reason: "unsupported-kind",
586
+ detail:
587
+ "live-plan's document is prior state for managed resources; a data block has no row in bound, omissions or unowned",
588
+ },
589
+ queried,
590
+ };
591
+ }
592
+
593
+ if (entity.type === MODULE_TYPE) {
594
+ const members = liveModuleMembers(address, index);
595
+ if (members.length === 0) return { absent: true, queried };
596
+ // Same shape as the stock reader's module answer, for the same
597
+ // reason: the document names the module's resources and never the
598
+ // block itself, so the ownership channel has nothing to say about it.
599
+ return {
600
+ present: {
601
+ type: entity.type,
602
+ physicalId: address,
603
+ status: "module",
604
+ ownership: "unknown",
605
+ attributes: { address, root, estate: index.estate, instances: members },
606
+ },
607
+ queried,
608
+ };
609
+ }
610
+
611
+ return {
612
+ unobserved: {
613
+ reason: "unsupported-kind",
614
+ detail: `${entity.type} has no row in a live-plan document: only resource blocks do`,
615
+ },
616
+ queried,
617
+ };
618
+ },
619
+ };
620
+ }
621
+
622
+ /* ──────────────────── the estate beyond the declaration ─────────────────── */
623
+
624
+ /** One `items[]` entry of a `live-ls -json` listing. */
625
+ export interface LiveLsItem {
626
+ /** ARN or other stable identity. */
627
+ id: string;
628
+ /** Resource type, e.g. `aws_vpc`. */
629
+ type: string;
630
+ /** The configuration address decoded from the marker, when the listing could decode one. */
631
+ address?: string;
632
+ /** `tofu-slot`, on a `count` instance. */
633
+ slot?: string;
634
+ /** Whether the cross-referenced configuration directory still declares it. */
635
+ declared: boolean;
636
+ /** Which read found it: `tagging`, or a per-service pass. */
637
+ source?: string;
638
+ /** Every marker tag it carries. */
639
+ tags: Record<string, string>;
640
+ }
641
+
642
+ /** One `gaps[]` entry: a declared instance this listing's mechanism cannot reach. */
643
+ export interface LiveLsGap {
644
+ address: string;
645
+ type: string;
646
+ /** `record` or `declaration-carried`, choudoufu's own rung vocabulary. */
647
+ rung: string;
648
+ detail: string;
649
+ }
650
+
651
+ /** A `live-ls -json` listing, parsed. */
652
+ export interface LiveLsListing {
653
+ estate: string;
654
+ items: LiveLsItem[];
655
+ gaps: LiveLsGap[];
656
+ }
657
+
658
+ /** Parse a `live-ls -json` document. A missing section reads as empty, never as a throw. */
659
+ export function readLiveLs(document: unknown): LiveLsListing {
660
+ const doc = asRecord(document);
661
+ const items: LiveLsItem[] = [];
662
+ for (const entry of asArray(doc.items)) {
663
+ const row = asRecord(entry);
664
+ const id = asString(row.id);
665
+ const type = asString(row.type);
666
+ if (!id || !type) continue;
667
+ const tags: Record<string, string> = {};
668
+ for (const [key, value] of Object.entries(asRecord(row.tags))) {
669
+ if (typeof value === "string") tags[key] = value;
670
+ }
671
+ items.push({
672
+ id,
673
+ type,
674
+ ...(asString(row.address) ? { address: asString(row.address) } : {}),
675
+ ...(asString(row.slot) ? { slot: asString(row.slot) } : {}),
676
+ declared: row.declared === true,
677
+ ...(asString(row.source) ? { source: asString(row.source) } : {}),
678
+ tags,
679
+ });
680
+ }
681
+
682
+ const gaps: LiveLsGap[] = [];
683
+ for (const entry of asArray(doc.gaps)) {
684
+ const row = asRecord(entry);
685
+ const address = asString(row.address);
686
+ if (!address) continue;
687
+ gaps.push({
688
+ address,
689
+ type: asString(row.type) ?? "",
690
+ rung: asString(row.rung) ?? "",
691
+ detail: asString(row.detail) ?? "",
692
+ });
693
+ }
694
+
695
+ return { estate: asString(doc.estate) ?? "", items, gaps };
696
+ }
697
+
698
+ /**
699
+ * A live-ls gap's rung, mapped onto chant's vocabulary. Both rungs mean the
700
+ * same thing to a reader of the listing: this instance can never appear in
701
+ * it, because the mechanism the listing uses cannot reach it. That is
702
+ * `unsupported-kind`, not a failure and not an absence.
703
+ */
704
+ function gapReason(): UnobservedReason {
705
+ return "unsupported-kind";
706
+ }
707
+
708
+ /**
709
+ * The entity key `buildRoots()` would have produced for a live resource's
710
+ * address.
711
+ *
712
+ * A live address is fully qualified with dots, `module.cdn.null_resource.edge`;
713
+ * the build keys a descended child module's block with a slash per call,
714
+ * `app/module.cdn/null_resource.edge` (#2112). Splitting the `module.<name>.`
715
+ * prefixes back out is what keeps a live row and the block that declared it
716
+ * on the same key.
717
+ */
718
+ export function entityKeyFor(root: string, address: string): string {
719
+ const parts = address.split(".");
720
+ const segments: string[] = [];
721
+ let i = 0;
722
+ while (i + 1 < parts.length && parts[i] === "module") {
723
+ segments.push(`module.${parts[i + 1]}`);
724
+ i += 2;
725
+ }
726
+ segments.push(parts.slice(i).join("."));
727
+ return [root, ...segments].join("/");
728
+ }
729
+
730
+ /**
731
+ * The entity types this lexicon can enumerate beyond the declared estate
732
+ * (#1278). One: a `resource` block, whose live counterparts `live-ls` lists
733
+ * off the tag index. A `data`, `module` or `provider` block has no live
734
+ * counterpart to enumerate at all.
735
+ */
736
+ export function ambientKinds(): string[] {
737
+ return [RESOURCE_TYPE];
738
+ }
739
+
740
+ /** Shared options for the two estate-wide reads. */
741
+ export interface LiveEstateOptions {
742
+ /** Directory the activities start the `chant.config.*` search from. Default: the process cwd. */
743
+ cwd?: string;
744
+ /** Restrict to these root names. Default: every live root the project declares. */
745
+ roots?: string[];
746
+ }
747
+
748
+ /**
749
+ * Every live root the project declares, in config order. Reads the project
750
+ * config the same way the activities do, so `terraform.roots` is read once
751
+ * and the same answer reaches both.
752
+ */
753
+ export async function liveRootNames(cwd?: string): Promise<Array<{ root: string; estate: string }>> {
754
+ const { loadChantConfigUpward } = await import("@intentius/chant/config");
755
+ const { detectLiveEstate } = await import("./op/activities/live-detect");
756
+ const { dirname, resolve } = await import("node:path");
757
+
758
+ const start = resolve(cwd ?? process.cwd());
759
+ const { config, configPath } = await loadChantConfigUpward(start);
760
+ const projectRoot = configPath ? dirname(configPath) : start;
761
+ const namespace = (config as { terraform?: { binary?: string; roots?: Record<string, { dir: string }> } })
762
+ .terraform;
763
+ if (namespace?.binary !== "choudoufu") return [];
764
+
765
+ const live: Array<{ root: string; estate: string }> = [];
766
+ for (const [name, root] of Object.entries(namespace.roots ?? {})) {
767
+ const estate = detectLiveEstate(resolve(projectRoot, root.dir));
768
+ if (estate !== undefined) live.push({ root: name, estate });
769
+ }
770
+ return live;
771
+ }
772
+
773
+ /**
774
+ * Report the resources this estate owns that nothing declares (#1278) — the
775
+ * owned-orphan set, which is `live-ls`'s `declared: false` rows.
776
+ *
777
+ * This is the question a state file cannot answer at all, because a state
778
+ * file knows only what it created. The tag index knows what the estate holds,
779
+ * whether or not any configuration still mentions it.
780
+ */
781
+ export async function observeAmbient(
782
+ options: {
783
+ environment: string;
784
+ kinds: string[];
785
+ observed: Record<string, ResourceMetadata>;
786
+ } & LiveEstateOptions,
787
+ deps: TerraformReadDeps = REAL_DEPS,
788
+ ): Promise<Record<string, ResourceMetadata>> {
789
+ // The bound #1278 asks for: a project that declares no terraform resources
790
+ // is never made to enumerate an estate.
791
+ if (!options.kinds.includes(RESOURCE_TYPE)) return {};
792
+
793
+ const out: Record<string, ResourceMetadata> = {};
794
+ for (const { root, estate } of await liveRoots(options)) {
795
+ const listed = await deps.liveLs({
796
+ root,
797
+ consistent: true,
798
+ ...(options.cwd ? { cwd: options.cwd } : {}),
799
+ });
800
+ const listing = readLiveLs(listed.json);
801
+ for (const item of listing.items) {
802
+ if (item.declared) continue; // declared is `describeResources`'s business, not this one's
803
+ const address = item.address ?? item.id;
804
+ const name = entityKeyFor(root, address);
805
+ if (options.observed[name]) continue;
806
+ out[name] = {
807
+ type: RESOURCE_TYPE,
808
+ physicalId: item.id,
809
+ status: "orphan",
810
+ ownership: "owned",
811
+ marker: { stack: listing.estate || estate },
812
+ ambient: true,
813
+ attributes: {
814
+ address,
815
+ root,
816
+ estate: listing.estate || estate,
817
+ resourceType: item.type,
818
+ ...(item.slot ? { slot: item.slot } : {}),
819
+ ...(item.source ? { listedBy: item.source } : {}),
820
+ tags: item.tags,
821
+ },
822
+ };
823
+ }
824
+ }
825
+ return out;
826
+ }
827
+
828
+ /**
829
+ * Name what a teardown of this environment would remove from every live root
830
+ * (#1222): the estate's owned orphans, which are exactly the set choudoufu's
831
+ * own default `policy` verb (`undeclared_tagged = "delete"`) removes on the
832
+ * next apply.
833
+ *
834
+ * Candidates carry the requested identity verbatim, because that identity is
835
+ * what selected them: the roots enumerated here are this project's own, and
836
+ * their estates are what `terraform.roots` declares for this environment.
837
+ * The estate that actually answered rides `name`, so a reviewer of the plan
838
+ * can see which one it was.
839
+ *
840
+ * `live-ls`'s own `gaps[]` become holes rather than silence, per #1089: a
841
+ * declared instance the tag index can never serve is unknown, not clean.
842
+ */
843
+ export async function teardownOwned(
844
+ options: { environment: string; marker: OwnershipMarker } & LiveEstateOptions,
845
+ deps: TerraformReadDeps = REAL_DEPS,
846
+ ): Promise<TeardownEnumeration> {
847
+ const candidates: TeardownCandidate[] = [];
848
+ const holes: TeardownHole[] = [];
849
+
850
+ for (const { root, estate } of await liveRoots(options)) {
851
+ let listing: LiveLsListing;
852
+ try {
853
+ const listed = await deps.liveLs({
854
+ root,
855
+ consistent: true,
856
+ ...(options.cwd ? { cwd: options.cwd } : {}),
857
+ });
858
+ listing = readLiveLs(listed.json);
859
+ } catch (err) {
860
+ // Nothing was read, so nothing is known about this root's estate. A
861
+ // hole, never an empty candidate list, which would read as "clean".
862
+ holes.push({
863
+ name: `terraform.roots.${root}`,
864
+ reason: "read-failed",
865
+ detail: `choudoufu live-ls (estate "${estate}"): ${err instanceof Error ? err.message : String(err)}`,
866
+ });
867
+ continue;
868
+ }
869
+
870
+ for (const item of listing.items) {
871
+ if (item.declared) continue;
872
+ candidates.push({
873
+ name: entityKeyFor(root, item.address ?? item.id),
874
+ type: RESOURCE_TYPE,
875
+ physicalId: item.id,
876
+ marker: options.marker,
877
+ });
878
+ }
879
+
880
+ for (const gap of listing.gaps) {
881
+ holes.push({
882
+ name: entityKeyFor(root, gap.address),
883
+ type: RESOURCE_TYPE,
884
+ reason: gapReason(),
885
+ detail: `${gap.rung}: ${gap.detail}`,
886
+ });
887
+ }
888
+ }
889
+
890
+ return { candidates, ...(holes.length > 0 ? { holes } : {}) };
891
+ }
892
+
893
+ /** The live roots an estate-wide read runs over: the caller's list, or every one declared. */
894
+ async function liveRoots(options: LiveEstateOptions): Promise<Array<{ root: string; estate: string }>> {
895
+ const declared = await liveRootNames(options.cwd);
896
+ if (!options.roots) return declared;
897
+ const wanted = new Set(options.roots);
898
+ return declared.filter((entry) => wanted.has(entry.root));
899
+ }
900
+
901
+ /* ─────────────────────────────── dispatch ───────────────────────────────── */
902
+
903
+ /** The activities this reader drives. Injectable so tests never run a binary. */
169
904
  export interface TerraformReadDeps {
170
905
  init: typeof terraformInit;
171
906
  show: typeof terraformShow;
907
+ livePlan: typeof choudoufuLivePlan;
908
+ liveLs: typeof choudoufuLiveLs;
172
909
  }
173
910
 
174
- const REAL_DEPS: TerraformReadDeps = { init: terraformInit, show: terraformShow };
911
+ const REAL_DEPS: TerraformReadDeps = {
912
+ init: terraformInit,
913
+ show: terraformShow,
914
+ livePlan: choudoufuLivePlan,
915
+ liveLs: choudoufuLiveLs,
916
+ };
175
917
 
176
- /** A declared entity, plus the root and address `buildRoots()` recorded on it. */
918
+ /** A declared entity, plus the root, address and mode `buildRoots()` recorded on it. */
177
919
  interface TerraformDeclared extends DeclaredEntity {
178
920
  root: string;
179
921
  address: string;
922
+ /** `"live"` when the root runs under choudoufu with a declared estate (#2103). */
923
+ mode?: string;
180
924
  }
181
925
 
182
926
  /**
183
- * Split a `<root>/<address>` entity key. Only a fallback: `buildRoots()`
184
- * records both on `props`, and a duplicated address is keyed `…~2` there, so
185
- * the props are the reliable source.
927
+ * Split a `<root>/<address>` entity key, or a descended child module's
928
+ * `<root>/module.<name>/<address>` (#2112). Only a fallback: `buildRoots()`
929
+ * records the root, the address and the call chain on `props`, and a
930
+ * duplicated address is keyed `…~2` there, so the props are the reliable
931
+ * source.
186
932
  */
187
- function fromEntityName(name: string): { root: string; address: string } {
933
+ function fromEntityName(name: string): { root: string; address: string; callers: string[] } {
188
934
  const slash = name.indexOf("/");
189
- if (slash === -1) return { root: "", address: name };
190
- return { root: name.slice(0, slash), address: name.slice(slash + 1).replace(/~\d+$/, "") };
935
+ if (slash === -1) return { root: "", address: name, callers: [] };
936
+ const last = name.lastIndexOf("/");
937
+ return {
938
+ root: name.slice(0, slash),
939
+ address: name.slice(last + 1).replace(/~\d+$/, ""),
940
+ callers: callersOfKey(name),
941
+ };
942
+ }
943
+
944
+ /**
945
+ * The address the live system knows a block by. For a root-module block that
946
+ * is the block's own address; for one inside a child module it is what
947
+ * `terraform show -json` writes in `child_modules[]` and what choudoufu's
948
+ * markers carry, the call chain and the address joined with dots:
949
+ * `module.cdn.null_resource.edge` (#2112). The entity KEY keeps its slashes
950
+ * (`app/module.cdn/null_resource.edge`), so what a build declares and what a
951
+ * read observes still line up one to one.
952
+ */
953
+ export function qualifiedAddress(address: string, callers: readonly string[]): string {
954
+ return callers.length === 0 ? address : `${callers.join(".")}.${address}`;
191
955
  }
192
956
 
193
957
  function declaredOf(name: string, entity: { entityType: string; props: Record<string, unknown> } | undefined): TerraformDeclared {
194
958
  const props = entity?.props ?? {};
195
959
  const fallback = fromEntityName(name);
960
+ const callers = Array.isArray(props.callers)
961
+ ? (props.callers as unknown[]).filter((c): c is string => typeof c === "string")
962
+ : fallback.callers;
196
963
  return {
197
964
  name,
198
965
  type: entity?.entityType ?? "",
199
966
  props,
200
967
  root: asString(props.root) ?? fallback.root,
201
- address: asString(props.address) ?? fallback.address,
968
+ address: qualifiedAddress(asString(props.address) ?? fallback.address, callers),
969
+ ...(asString(props.mode) ? { mode: asString(props.mode) } : {}),
202
970
  };
203
971
  }
204
972
 
@@ -322,11 +1090,26 @@ export async function describeResources(
322
1090
  }
323
1091
 
324
1092
  const parts = [];
1093
+ const notes: string[] = [];
325
1094
  for (const [root, declared] of byRoot) {
326
- parts.push(normalizeObservation(await observeEntities(declared, adapter(root, options.cwd, deps))));
1095
+ // One fact decides the whole read, and the parse already recorded it on
1096
+ // every entity of the root (#2103). A root is live when its binary is
1097
+ // choudoufu AND it declares an estate, so a single entity carrying
1098
+ // `mode: "live"` settles it for the root.
1099
+ const live = declared.some((entity) => entity.mode === "live");
1100
+ if (live) {
1101
+ notes.push(
1102
+ `terraform.roots.${root} is a live root: ownership came from choudoufu's tofu-estate/tofu-address markers via \`live-plan -json\`, not from a state file`,
1103
+ );
1104
+ }
1105
+ const read = live
1106
+ ? await observeEntities(declared, liveAdapter(root, options.cwd, deps))
1107
+ : await observeEntities(declared, adapter(root, options.cwd, deps));
1108
+ parts.push(normalizeObservation(read));
327
1109
  }
328
1110
 
329
1111
  const merged = mergeObservations(parts);
1112
+ merged.notes.push(...notes);
330
1113
  const resources = { ...merged.resources };
331
1114
  const unobserved = { ...merged.unobserved };
332
1115
 
@@ -347,7 +1130,7 @@ export async function describeResources(
347
1130
  unobserved[name] = {
348
1131
  type: meta.type,
349
1132
  reason: "filtered",
350
- detail: "the root's state carries no row for this address and --owned was requested",
1133
+ detail: `this address read \`${meta.ownership ?? "unknown"}\` on the root's ownership channel (its state file, or its live markers) and --owned was requested`,
351
1134
  ...(merged.queried[name] ? { queried: merged.queried[name] } : {}),
352
1135
  };
353
1136
  }