@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
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Everything else in this lexicon's Op suite proves the shape of what chant
5
5
  * emits. This proves the shape runs: build a `TerraformApplyOp` with
6
- * `gate: "never"` (the local executor refuses any Op containing a gate), hand
6
+ * `gate: "never"` (a gate ends a local run as `gated` since #2119, so an
7
+ * acceptance run that must reach Apply declares none), hand
7
8
  * it to `runOpLocally` with the activities the registry resolves by
8
9
  * convention, and let a real `terraform` — or `tofu` — init, plan and apply
9
10
  * the #2083 `with-backend` fixture root into a temp directory. Two
@@ -23,13 +24,21 @@
23
24
 
24
25
  import { execSync } from "node:child_process";
25
26
  import { lookup } from "node:dns/promises";
26
- import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
27
+ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
28
  import { tmpdir } from "node:os";
28
29
  import { join } from "node:path";
29
30
  import { afterAll, describe, expect, it } from "vitest";
30
31
  import { loadActivities, loadProfiles, runOpLocally, type OpConfig } from "@intentius/chant/op";
31
32
  import { TerraformApplyOp } from "./terraform-apply-op";
32
- import { terraformShow } from "../op/activities/terraform";
33
+ import {
34
+ MIN_CHOUDOUFU_VERSION,
35
+ isOlderVersion,
36
+ parseChoudoufuVersion,
37
+ terraformApply,
38
+ terraformInit,
39
+ terraformPlan,
40
+ terraformShow,
41
+ } from "../op/activities/terraform";
33
42
 
34
43
  function onPath(cmd: string): boolean {
35
44
  try {
@@ -113,7 +122,7 @@ describe.skipIf(skipReason !== "")(
113
122
  await loadProfiles(),
114
123
  );
115
124
 
116
- expect(result.ok).toBe(true);
125
+ expect(result.status).toBe("ok");
117
126
  expect(result.records.map((r) => `${r.phase}:${r.status}`)).toEqual([
118
127
  "Init:ok",
119
128
  "Plan:ok",
@@ -121,7 +130,7 @@ describe.skipIf(skipReason !== "")(
121
130
  ]);
122
131
 
123
132
  // The plan proposed two creates, and said so through the outcome the
124
- // Plan step surfaces as the `Changed` search attribute.
133
+ // Plan step surfaces as the `Changed` run outcome.
125
134
  const plan = result.records.find((r) => r.phase === "Plan")!;
126
135
  expect(plan.outcome).toEqual({ name: "Changed", value: true });
127
136
 
@@ -136,3 +145,172 @@ describe.skipIf(skipReason !== "")(
136
145
  );
137
146
  },
138
147
  );
148
+
149
+ /**
150
+ * `TerraformApplyOp` on a live root, against choudoufu's own pinned emulator
151
+ * (#2106 follow-up). Two suites, gated the same way
152
+ * `../op/activities/choudoufu.acceptance.test.ts` is, plus a version floor:
153
+ *
154
+ * - no `choudoufu` on PATH,
155
+ * - a `choudoufu` older than {@link MIN_CHOUDOUFU_VERSION}, which is the
156
+ * release that shipped the approval artifact
157
+ * ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
158
+ * PR 889): before it, `plan -out` was refused under a live block and this
159
+ * Op could not be built the way it is built now,
160
+ * - `CHOUDOUFU_EMULATOR_ENDPOINT` unset (bring up choudoufu's `just smoke`
161
+ * docker compose stack and export `http://localhost:<mapped port>`).
162
+ *
163
+ * The #894 clause the first version of this suite carried is gone. That issue
164
+ * is still open, but it is about `live-plan -json`, which the apply Op no
165
+ * longer runs: the plan half is the stock `plan -out` path. `TerraformWatchOp`
166
+ * and `TerraformAdoptOp` still need the document and still wait on it.
167
+ *
168
+ * The first test is the happy path end to end through `runOpLocally`. The
169
+ * second is the refusal, and it runs the activities directly rather than
170
+ * through the Op, because the world has to move between the Plan step and the
171
+ * Apply step and there is no seam inside a running Op to do that from.
172
+ */
173
+
174
+ function versionOf(binary: string): string | undefined {
175
+ try {
176
+ return parseChoudoufuVersion(execSync(`${binary} version`, { encoding: "utf8" }));
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ const LIVE_FIXTURE = join(import.meta.dirname, "..", "__fixtures__", "live");
183
+
184
+ /** A throwaway project holding a copy of the live fixture as its one root. */
185
+ function liveProject(): string {
186
+ const dir = mkdtempSync(join(tmpdir(), "chant-choudoufu-apply-accept-"));
187
+ workspaces.push(dir);
188
+ cpSync(LIVE_FIXTURE, join(dir, "root"), { recursive: true });
189
+ writeFileSync(
190
+ join(dir, "chant.config.json"),
191
+ JSON.stringify(
192
+ { lexicons: ["terraform"], terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } },
193
+ null,
194
+ 2,
195
+ ),
196
+ );
197
+ return dir;
198
+ }
199
+
200
+ /** The estate-wide marker sweep configures the AWS provider whatever the root declares. */
201
+ function pointAtTheEmulator(endpoint: string): void {
202
+ process.env.AWS_ENDPOINT_URL ??= endpoint;
203
+ process.env.AWS_ACCESS_KEY_ID ??= "choudoufu-emulator";
204
+ process.env.AWS_SECRET_ACCESS_KEY ??= "choudoufu-emulator";
205
+ process.env.AWS_DEFAULT_REGION ??= "us-east-1";
206
+ }
207
+
208
+ const choudoufuVersion = onPath("choudoufu") ? versionOf("choudoufu") : undefined;
209
+ const emulatorEndpoint = process.env.CHOUDOUFU_EMULATOR_ENDPOINT;
210
+
211
+ const liveSkipReason: string = !onPath("choudoufu")
212
+ ? "no choudoufu binary on PATH"
213
+ : choudoufuVersion === undefined
214
+ ? "the choudoufu on PATH reports no release version (a dev build), so the approval artifact cannot be assumed"
215
+ : isOlderVersion(choudoufuVersion, MIN_CHOUDOUFU_VERSION)
216
+ ? `choudoufu ${choudoufuVersion} is older than v${MIN_CHOUDOUFU_VERSION}, which shipped the approval artifact (choudoufu #878)`
217
+ : !emulatorEndpoint
218
+ ? "CHOUDOUFU_EMULATOR_ENDPOINT is not set (bring up choudoufu's `just smoke` emulator stack and export it)"
219
+ : "";
220
+
221
+ describe.skipIf(liveSkipReason !== "")(
222
+ `TerraformApplyOp applies a live root against choudoufu's emulator${liveSkipReason ? ` (skipped: ${liveSkipReason})` : ""}`,
223
+ () => {
224
+ it(
225
+ 'inits, plans to a file, applies that file, gate: "never" — two null_resources applied',
226
+ { timeout: 600_000 },
227
+ async () => {
228
+ pointAtTheEmulator(emulatorEndpoint!);
229
+ const project = liveProject();
230
+
231
+ const { op } = TerraformApplyOp({
232
+ name: "choudoufu-acceptance",
233
+ root: "estate",
234
+ gate: "never",
235
+ cwd: project,
236
+ });
237
+
238
+ // The plan file is what crosses the (skipped) gate, so the built Op
239
+ // has to name one even here, where nothing waits on it.
240
+ const applyStep = (op as unknown as { props: OpConfig }).props.phases
241
+ .find((p) => p.name === "Apply")!
242
+ .steps[0] as { args?: Record<string, unknown> };
243
+ expect(applyStep.args?.planFile).toBeDefined();
244
+
245
+ const activities = await loadActivities(["terraform"]);
246
+ const result = await runOpLocally(
247
+ (op as unknown as { props: OpConfig }).props,
248
+ activities,
249
+ await loadProfiles(),
250
+ );
251
+
252
+ expect(result.status).toBe("ok");
253
+ expect(result.records.map((r) => `${r.phase}:${r.status}`)).toEqual(["Init:ok", "Plan:ok", "Apply:ok"]);
254
+
255
+ // A live root has no state file to read back: the state cache is not
256
+ // stock's `terraform.tfstate` and `show` over it renders nothing. The
257
+ // estate's own answer is the next plan, which is built from the live
258
+ // system, so what was applied is read back the way choudoufu means it
259
+ // to be. Empty, and its prior state holds exactly the two resources.
260
+ const after = await terraformPlan({ root: "estate", cwd: project });
261
+ expect(after.changed).toBe(false);
262
+ expect({ adds: after.adds, changes: after.changes, destroys: after.destroys }).toEqual({
263
+ adds: 0,
264
+ changes: 0,
265
+ destroys: 0,
266
+ });
267
+ const applied = (
268
+ (after.json as { prior_state?: { values?: { root_module?: { resources?: Array<{ address?: string }> } } } })
269
+ .prior_state?.values?.root_module?.resources ?? []
270
+ ).map((r) => r.address);
271
+ expect(applied.sort()).toEqual(["null_resource.first", "null_resource.second"]);
272
+ },
273
+ );
274
+
275
+ it(
276
+ "a plan file the live system has moved past comes back as a named refusal, not a throw",
277
+ { timeout: 600_000 },
278
+ async () => {
279
+ pointAtTheEmulator(emulatorEndpoint!);
280
+ const project = liveProject();
281
+ const mainTf = join(project, "root", "main.tf");
282
+ const original = readFileSync(mainTf, "utf8");
283
+
284
+ await terraformInit({ root: "estate", cwd: project });
285
+ const plan = await terraformPlan({ root: "estate", cwd: project });
286
+ expect(plan.changed).toBe(true);
287
+ expect(plan.adds).toBe(2);
288
+
289
+ // The configuration moves after the approval: one of the two resources
290
+ // the approver read is gone, so the fresh plan the apply builds has a
291
+ // change the file does not, and the file has one the fresh plan does not.
292
+ writeFileSync(mainTf, original.replace(/resource "null_resource" "second" \{[\s\S]*?\n\}\n/, ""));
293
+
294
+ const refusedRun = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
295
+ expect(refusedRun.applied).toBe(false);
296
+ expect(refusedRun.refused).toBe("approval-mismatch");
297
+ expect(refusedRun.refusal).toContain("The approved plan no longer matches the live system");
298
+ expect(refusedRun.refusal).toContain("null_resource.second");
299
+
300
+ // The sibling refusal, from the same exit status: the file was produced
301
+ // against another estate than the one this directory now declares.
302
+ writeFileSync(mainTf, original.replace('estate = "fixture-estate"', 'estate = "renamed-estate"'));
303
+ const wrongEstate = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
304
+ expect(wrongEstate.applied).toBe(false);
305
+ expect(wrongEstate.refused).toBe("wrong-estate");
306
+ expect(wrongEstate.refusal).toContain("The approved plan belongs to a different estate");
307
+
308
+ // And with the world back where the approval found it, the same file applies.
309
+ writeFileSync(mainTf, original);
310
+ const applied = await terraformApply({ root: "estate", cwd: project, planFile: plan.planFile });
311
+ expect(applied.applied).toBe(true);
312
+ expect(applied.refused).toBeUndefined();
313
+ },
314
+ );
315
+ },
316
+ );
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * TerraformApplyOp composite tests (#2086) — the phase names, the gate, the
3
- * plan-to-apply reference, and the compensation refusal.
3
+ * plan-to-apply reference, and the compensation refusal. A live root builds
4
+ * the same four phases (#2106 follow-up), and what is particular to it is
5
+ * below, in its own describe block.
4
6
  */
5
7
 
6
- import { describe, test, expect } from "vitest";
8
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, describe, test, expect } from "vitest";
7
12
  import {
8
- findGate,
13
+ isGated,
9
14
  isStepOutputRef,
10
15
  runOpLocally,
11
- LocalGateUnsupportedError,
16
+ memoryGateLedgerPort,
12
17
  type ActivityStep,
13
18
  type GateStep,
14
19
  type OpConfig,
@@ -95,7 +100,7 @@ describe("TerraformApplyOp gate (#2086)", () => {
95
100
  test("the Gate phase reports the plan's destroy count before it waits", () => {
96
101
  // GateStep has no condition field, so "on-destroy" cannot branch at build
97
102
  // time. The approver is told instead: a `show` of the saved plan runs
98
- // first and surfaces `destroys` as a search attribute.
103
+ // first and surfaces `destroys` as a run outcome.
99
104
  const op = props({ name: "prod-apply", root: "app" });
100
105
  const steps = op.phases.find((p) => p.name === "Gate")!.steps;
101
106
  const show = steps[0] as ActivityStep;
@@ -168,26 +173,173 @@ describe("TerraformApplyOp compensation — total or refused (#2086)", () => {
168
173
  });
169
174
  });
170
175
 
171
- describe("TerraformApplyOp on the local executor (#2086)", () => {
172
- test("a gated Op is refused up front, naming the signal the local executor cannot wait for", async () => {
176
+ describe("TerraformApplyOp on the local executor (#2086, gate-as-fact #2119)", () => {
177
+ test("a gated Op stops at the gate and records the pending fact, naming the signal", async () => {
173
178
  const op = props({ name: "prod-apply", root: "app" });
174
- await expect(runOpLocally(op, new Map(), {})).rejects.toBeInstanceOf(LocalGateUnsupportedError);
179
+ const ran: string[] = [];
180
+ const stub = (fn: string) => [fn, async () => { ran.push(fn); return {}; }] as const;
181
+ const activities = new Map(
182
+ ["terraformInit", "terraformPlan", "terraformShow", "terraformApply"].map(stub),
183
+ );
184
+ const gates = memoryGateLedgerPort();
185
+ const result = await runOpLocally(op, activities, {}, undefined, { gates, now: "2026-09-05T12:00:00.000Z" });
186
+
187
+ expect(result.status).toBe("gated");
188
+ expect(result.gate?.op).toBe("prod-apply");
189
+ expect(result.gate?.gate).toBe(op.phases.flatMap((p) => p.steps).find(isGate)?.signalName);
190
+ expect(gates.appended).toHaveLength(1);
191
+ // Init, Plan and the pre-gate `show` ran; Apply is behind the gate and did not.
192
+ expect(ran).toEqual(["terraformInit", "terraformPlan", "terraformShow"]);
175
193
  });
176
194
 
177
- test('gate: "never" clears that refusal, which is what makes `chant run` viable', () => {
178
- expect(findGate(props({ name: "prod-apply", root: "app", gate: "never" }))).toBeUndefined();
195
+ test('gate: "never" means there is no gate to stop at', () => {
196
+ expect(isGated(props({ name: "prod-apply", root: "app", gate: "never" }))).toBe(false);
197
+ expect(isGated(props({ name: "prod-apply", root: "app" }))).toBe(true);
179
198
  });
180
199
  });
181
200
 
182
201
  describe("TerraformApplyOp Op metadata (#2086)", () => {
183
- test("task queue defaults to the name and search attributes name the root", () => {
202
+ test("labels name the root, and no task queue rides on the Op (#2118)", () => {
184
203
  const op = props({ name: "app-apply", root: "app" });
185
204
  expect(op.name).toBe("app-apply");
186
- expect(op.taskQueue).toBe("app-apply");
187
- expect(op.searchAttributes).toEqual({ Apply: "true", TerraformRoot: "app" });
205
+ expect(op.labels).toEqual({ Apply: "true", TerraformRoot: "app" });
206
+ expect(op).not.toHaveProperty("taskQueue");
207
+ });
208
+ });
209
+
210
+ // ── Live root (#2106) ────────────────────────────────────────────────────────
211
+
212
+ const dirs: string[] = [];
213
+
214
+ afterEach(() => {
215
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
216
+ });
217
+
218
+ /** A throwaway project whose one root is live, stock, or live with a policy block, per `policy`. */
219
+ function project(policy?: string): string {
220
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
221
+ dirs.push(dir);
222
+ mkdirSync(join(dir, "root"), { recursive: true });
223
+ writeFileSync(
224
+ join(dir, "root", "main.tf"),
225
+ [
226
+ "terraform {",
227
+ " live {",
228
+ ' estate = "fixture-estate"',
229
+ ...(policy ? [" policy {", ` ${policy}`, " }"] : []),
230
+ " }",
231
+ "}",
232
+ "",
233
+ 'resource "null_resource" "x" {}',
234
+ "",
235
+ ].join("\n"),
236
+ );
237
+ writeFileSync(
238
+ join(dir, "chant.config.json"),
239
+ JSON.stringify({ terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } }),
240
+ );
241
+ return dir;
242
+ }
243
+
244
+ describe("TerraformApplyOp on a live root (#2106 follow-up)", () => {
245
+ test("the same Init, Plan, Gate, Apply as a stock root, with the plan file paired through", () => {
246
+ const dir = project();
247
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir });
248
+ expect(phaseNames(op)).toEqual(["Init", "Plan", "Gate", "Apply"]);
249
+
250
+ const activitySteps = op.phases.flatMap((p) => p.steps.filter(isActivity));
251
+ expect(activitySteps.map((s) => s.fn)).toEqual([
252
+ "terraformInit",
253
+ "terraformPlan",
254
+ "terraformShow",
255
+ "terraformApply",
256
+ ]);
257
+
258
+ const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
259
+ expect(plan.args?.planFile).toBe("chant.tfplan");
260
+
261
+ // The Apply step names the Plan step's own output, never a literal path:
262
+ // on a live root that reference is the approval artifact itself.
263
+ const apply = op.phases.find((p) => p.name === "Apply")!.steps[0] as ActivityStep;
264
+ expect(isStepOutputRef(apply.args?.planFile)).toBe(true);
265
+ expect(apply.args?.planFile).toMatchObject({ step: "plan", path: "planFile" });
266
+ });
267
+
268
+ test("no choudoufuLivePlan step anywhere: the watch and adopt Ops still use it, this one does not", () => {
269
+ const dir = project();
270
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir });
271
+ const fns = op.phases.flatMap((p) => p.steps.filter(isActivity)).map((s) => s.fn);
272
+ expect(fns).not.toContain("choudoufuLivePlan");
273
+ });
274
+
275
+ test('gate: "never" drops the Gate phase, leaving Init, Plan, Apply', () => {
276
+ const dir = project();
277
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir, gate: "never" });
278
+ expect(phaseNames(op)).toEqual(["Init", "Plan", "Apply"]);
279
+ expect(op.phases.flatMap((p) => p.steps).some(isGate)).toBe(false);
280
+ });
281
+
282
+ test("Plan reports Changed; the Gate's show step reports Destroys off the saved plan", () => {
283
+ const dir = project();
284
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir });
285
+ const plan = op.phases.find((p) => p.name === "Plan")!.steps[0] as ActivityStep;
286
+ expect(plan.outcomeAttribute).toEqual({ name: "Changed", from: "changed" });
287
+
288
+ const gateSteps = op.phases.find((p) => p.name === "Gate")!.steps;
289
+ const show = gateSteps[0] as ActivityStep;
290
+ expect(show.fn).toBe("terraformShow");
291
+ expect(show.outcomeAttribute).toEqual({ name: "Destroys", from: "destroys" });
292
+ expect(gateSteps[1].kind).toBe("gate");
188
293
  });
189
294
 
190
- test("an explicit taskQueue wins", () => {
191
- expect(props({ name: "app-apply", root: "app", taskQueue: "infra" }).taskQueue).toBe("infra");
295
+ test("the gate description tells the approver what the exit-3 refusal does", () => {
296
+ const dir = project();
297
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir });
298
+ const gateStep = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
299
+ expect(gateStep.description).toContain("live root");
300
+ expect(gateStep.description).toContain("exit status 3");
301
+ expect(gateStep.description).toContain("choudoufu #878");
302
+ });
303
+
304
+ test("a stock root's gate description says nothing about a live re-plan", () => {
305
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
306
+ dirs.push(dir);
307
+ mkdirSync(join(dir, "root"), { recursive: true });
308
+ writeFileSync(join(dir, "root", "main.tf"), 'resource "null_resource" "x" {}\n');
309
+ writeFileSync(
310
+ join(dir, "chant.config.json"),
311
+ JSON.stringify({ terraform: { binary: "terraform", roots: { estate: { dir: "./root" } } } }),
312
+ );
313
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir });
314
+ const gateStep = op.phases.find((p) => p.name === "Gate")!.steps.find(isGate)!;
315
+ expect(gateStep.description).not.toContain("exit status 3");
316
+ expect(gateStep.description).not.toContain("live root");
317
+ });
318
+
319
+ test("refuses to build when the root's policy sets undeclared_untagged = \"delete\"", () => {
320
+ const dir = project('undeclared_untagged = "delete"');
321
+ expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: dir })).toThrow(
322
+ /TerraformApplyOp "estate-apply".*undeclared_untagged = "delete"/s,
323
+ );
324
+ });
325
+
326
+ test("builds fine when the root's policy sets undeclared_untagged to something else", () => {
327
+ const dir = project('undeclared_untagged = "report"');
328
+ expect(() => TerraformApplyOp({ name: "estate-apply", root: "estate", cwd: dir })).not.toThrow();
329
+ });
330
+
331
+ test("a choudoufu root with no declared estate builds the same phases and skips the policy read", () => {
332
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-apply-op-live-"));
333
+ dirs.push(dir);
334
+ mkdirSync(join(dir, "root"), { recursive: true });
335
+ writeFileSync(join(dir, "root", "main.tf"), 'resource "null_resource" "x" {}\n');
336
+ writeFileSync(
337
+ join(dir, "chant.config.json"),
338
+ JSON.stringify({ terraform: { binary: "choudoufu", roots: { estate: { dir: "./root" } } } }),
339
+ );
340
+
341
+ const op = props({ name: "estate-apply", root: "estate", cwd: dir, gate: "never" });
342
+ const fns = op.phases.flatMap((p) => p.steps.filter(isActivity)).map((s) => s.fn);
343
+ expect(fns).toEqual(["terraformInit", "terraformPlan", "terraformApply"]);
192
344
  });
193
345
  });
@@ -1,17 +1,66 @@
1
1
  /**
2
- * TerraformApplyOp composite (#2086) — init, plan, approve, apply for one root
2
+ * TerraformApplyOp composite (#2086), init, plan, approve, apply for one root
3
3
  * module named in `terraform.roots`.
4
4
  *
5
5
  * Authority stays with terraform: chant runs the binary, reads its plan, and
6
- * hands the same saved plan to apply. The Apply step never re-plans, because
7
- * it takes the Plan step's own `planFile` as a `StepOutputRef` — whatever was
8
- * approved is what runs.
6
+ * hands the same saved plan to apply. The Apply step never applies something
7
+ * no gate saw, because it takes the Plan step's own `planFile` as a
8
+ * `StepOutputRef`.
9
9
  *
10
- * Phases: Init, Plan, Gate, Apply.
10
+ * Phases: Init, Plan, Gate, Apply. One shape, both kinds of root. A live root
11
+ * (#2103, `terraform.binary: "choudoufu"` plus a declared estate) ran a
12
+ * different shape for one release, because choudoufu refused `-out` and
13
+ * `apply <planfile>`; choudoufu v0.13.0 admits both under a `live` block
14
+ * ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878),
15
+ * PR 889), so the plan-file discipline is back and the two shapes are one
16
+ * again.
11
17
  *
12
- * The gate is durable-runtime work, so an Op that emits one needs Temporal;
13
- * `packages/core/src/op/local-executor.ts` refuses any Op containing a gate.
14
- * `gate: "never"` is what makes the Op runnable with `chant run` locally.
18
+ * **What the approval covers on a live root.** `plan -out=<planFile>` writes
19
+ * stock's own plan file, and that file is what the approver reads and what
20
+ * the Apply step names. `apply <planFile>` does not replay it: prior state on
21
+ * a live root is a projection rebuilt from the live system every run, so the
22
+ * apply re-plans against the live system the way every live-markers run does,
23
+ * then compares its fresh plan with the file's. Same addresses, same actions,
24
+ * same live objects, and the same planned values, or it refuses before
25
+ * anything changes:
26
+ *
27
+ * - `The approved plan no longer matches the live system`, and
28
+ * - `The approved plan belongs to a different estate`,
29
+ *
30
+ * both with exit status **3**, which is neither 1 (any ordinary failure) nor
31
+ * `-detailed-exitcode`'s 2. `terraformApply` maps that status to a named
32
+ * result rather than a thrown error (`refused: "approval-mismatch" |
33
+ * "wrong-estate"`, with choudoufu's message in `refusal`), so a workflow
34
+ * reading the Apply step's output can route the run back to review instead of
35
+ * treating it as a broken step. The seam #2106 left named and unused on the
36
+ * apply args is now the plan file itself.
37
+ *
38
+ * TF101 therefore fires on a live root's `terraformApply` call again: there
39
+ * is a plan file to pair once more, and pairing it is what the refusal above
40
+ * has to compare against.
41
+ *
42
+ * **Delete modes.** chant's `delete: "never" | "owned-only" | "gated"`
43
+ * (`terraform.roots.<name>.delete`, `../config.ts`) maps onto choudoufu's
44
+ * `policy` block. `"owned-only"` is choudoufu's own default verb for the
45
+ * `undeclared_tagged` quadrant (an orphaned marked resource) and needs
46
+ * nothing; `"gated"` needs nothing beyond this composite's own approval gate;
47
+ * `"never"` requires the root's `policy` block to set `undeclared_tagged` to
48
+ * `"keep"`, `"untag"` or `"report"`, and TF026 (`../lint/post-synth/tf026.ts`)
49
+ * enforces that at build time, naming the setting to add. Separately, and
50
+ * regardless of `delete`, this composite refuses outright to build against a
51
+ * root whose `policy` sets `undeclared_untagged = "delete"` (account-scoped
52
+ * reconciliation, which needs a `scope` block): that quadrant deletes
53
+ * resources the estate never claimed, anywhere the scope reaches, and chant
54
+ * never proposes deleting a resource it does not own, not for a root, and not
55
+ * for an Op it generated.
56
+ *
57
+ * A gate is a fact on the gate ledger, not a wait (#2119). Reaching one,
58
+ * `chant run` consults the ledger (`packages/core/src/op/gate.ts`): a
59
+ * resolution newer than the gate's newest pending fact lets the run walk
60
+ * through, and anything else records the pending fact and ends the run with
61
+ * status `gated` and exit 3. `chant approve <op> <gate>` writes the
62
+ * resolution, and the next run reads it. `gate: "never"` drops the Gate phase
63
+ * entirely, so a run never stops.
15
64
  *
16
65
  * @example
17
66
  * ```typescript
@@ -23,6 +72,8 @@
23
72
 
24
73
  import { Op, phase, gate, activity, OpResource } from "@intentius/chant/op";
25
74
  import { DEFAULT_PLAN_FILE } from "../op/activities/terraform";
75
+ import { detectLivePolicyVerbs } from "../op/activities/live-detect";
76
+ import { resolveRootModeSync } from "../op/resolve-root-mode";
26
77
  import {
27
78
  terraformInit as initStep,
28
79
  terraformPlan as planStep,
@@ -38,14 +89,14 @@ import {
38
89
  * cannot branch at build time on a count the plan only produces at run time.
39
90
  * The workable v1 is that `"on-destroy"` and `"always"` emit the same Gate
40
91
  * phase, and the approver is told what is at stake instead: the phase reports
41
- * the plan's `destroys` count as the `Destroys` search attribute before it
42
- * waits, and the gate description says so. A gate that skips itself when the
43
- * plan turns out additive is a new step kind, out of scope here.
92
+ * the plan's `destroys` count as the `Destroys` run outcome before the
93
+ * gate is reached, and the gate description says so. A gate that skips itself
94
+ * when the plan turns out additive is a new step kind, out of scope here.
44
95
  */
45
96
  export type TerraformGateMode = "on-destroy" | "always" | "never";
46
97
 
47
98
  export interface TerraformApplyOpConfig {
48
- /** Op name (kebab-case). Also the default task queue and gate signal suffix. */
99
+ /** Op name (kebab-case). `signalName` defaults to `approve-<name>`. */
49
100
  name: string;
50
101
  /** Key into the project's `terraform.roots`. The root carries dir, workspace, var files and backend config. */
51
102
  root: string;
@@ -59,7 +110,7 @@ export interface TerraformApplyOpConfig {
59
110
  gate?: TerraformGateMode;
60
111
  /** Gate signal name. Default: `approve-<name>`, as `ApplyOp` does. */
61
112
  signalName?: string;
62
- /** Temporal duration the gate waits before timing out. Default: core's own (48h). */
113
+ /** How long a recorded pending gate stays valid, as a duration string. Default: core's own (48h). */
63
114
  gateTimeout?: string;
64
115
  /** Override the gate description shown to the approver. */
65
116
  gateDescription?: string;
@@ -78,15 +129,13 @@ export interface TerraformApplyOpConfig {
78
129
  * Terraform has no automatic rollback: a half-applied plan is undone by
79
130
  * planning and applying the inverse, which is a decision about the estate
80
131
  * rather than something chant can synthesize. So compensation here is
81
- * total or refused, the same stance `ApplyOp` takes (`lexicons/temporal/src/
132
+ * total or refused, the same stance `ApplyOp` takes (`packages/core/src/op/
82
133
  * composites/apply-op.ts`) — `true`, or an object with no `command`, throws
83
134
  * when the Op is built, naming the Op, rather than warning at the moment a
84
135
  * rollback is already needed. Supply `compensate: { command: "..." }` with a
85
136
  * rollback of your own, or leave it unset.
86
137
  */
87
138
  compensate?: boolean | { command?: string };
88
- /** Override the task queue. Defaults to `name`. */
89
- taskQueue?: string;
90
139
  }
91
140
 
92
141
  export interface TerraformApplyOpResources {
@@ -95,7 +144,6 @@ export interface TerraformApplyOpResources {
95
144
  }
96
145
 
97
146
  export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApplyOpResources {
98
- const taskQueue = config.taskQueue ?? config.name;
99
147
  const planFile = config.planFile ?? DEFAULT_PLAN_FILE;
100
148
  const gateMode: TerraformGateMode = config.gate ?? "on-destroy";
101
149
 
@@ -116,20 +164,45 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
116
164
  // travels with each of them rather than being read off the process once.
117
165
  const where = config.cwd ? { cwd: config.cwd } : {};
118
166
 
119
- // `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
120
- // Apply step below names this step's saved plan.
121
- const plan = planStep(config.root, { planFile, ...where, id: "plan" });
122
- plan.outcomeAttribute = { name: "Changed", from: "changed" };
167
+ // Best-effort, synchronous (chant.config.json only — see
168
+ // resolveRootModeSync's own doc comment): "unknown" reads as stock, the
169
+ // conservative direction. The mode no longer changes which steps are
170
+ // emitted; it decides the policy refusal below and the wording of the gate.
171
+ const resolved = resolveRootModeSync(config.root, config.cwd);
172
+ const live = resolved?.mode === "live";
173
+
174
+ if (live) {
175
+ // Regardless of `delete`: an account-scoped purge is never something
176
+ // chant proposes on an Op's own initiative. TF026 handles the narrower,
177
+ // config-driven `delete: "never"` requirement; this is unconditional.
178
+ const verbs = detectLivePolicyVerbs(resolved!.dir);
179
+ if (verbs?.undeclaredUntagged === "delete") {
180
+ throw new Error(
181
+ `TerraformApplyOp "${config.name}": root "${config.root}"'s policy block sets ` +
182
+ `undeclared_untagged = "delete" (account-scoped reconciliation, scoped by a \`scope\` block). ` +
183
+ "chant never proposes deleting a resource it does not own, and neither does an Op it " +
184
+ "generates. Remove that setting from the policy block, or narrow the estate's ownership " +
185
+ "answer instead of the account's.",
186
+ );
187
+ }
188
+ }
123
189
 
124
190
  const phases = [
125
191
  phase("Init", [initStep(config.root, { ...where, ...(config.upgrade ? { upgrade: true } : {}) })]),
126
- phase("Plan", [plan]),
127
192
  ];
128
193
 
194
+ // `id` is what makes `plan.out` legal, and `plan.out.planFile` is how the
195
+ // Apply step below names this step's saved plan. On a live root that file
196
+ // is also the approval artifact the apply re-plans against, so the same
197
+ // reference carries the approval as well as the path.
198
+ const plan = planStep(config.root, { planFile, ...where, id: "plan" });
199
+ plan.outcomeAttribute = { name: "Changed", from: "changed" };
200
+ phases.push(phase("Plan", [plan]));
201
+
129
202
  if (gateMode !== "never") {
130
- // Re-render the saved plan just before the wait, so the approver's
203
+ // Re-render the saved plan just before the gate, so the approver's
131
204
  // `Destroys` attribute comes off the plan that will actually apply.
132
- // `show` against a plan file calls no provider.
205
+ // `show` against a plan file calls no provider, on either kind of root.
133
206
  const show = showStep(config.root, { ...where, planFile: plan.out.planFile });
134
207
  show.outcomeAttribute = { name: "Destroys", from: "destroys" };
135
208
  phases.push(
@@ -139,8 +212,13 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
139
212
  ...(config.gateTimeout ? { timeout: config.gateTimeout } : {}),
140
213
  description:
141
214
  config.gateDescription ??
142
- `Approve terraform apply of root "${config.root}" (gate: ${gateMode}). ` +
143
- `The Destroys search attribute on this phase is the plan's destroy count.`,
215
+ `Approve terraform apply of ${live ? "live " : ""}root "${config.root}" (gate: ${gateMode}). ` +
216
+ `The Destroys run outcome from this phase is the plan's destroy count.` +
217
+ (live
218
+ ? " The apply that follows re-plans against the live system and applies this plan file only" +
219
+ " if its own fresh plan agrees; otherwise it refuses with exit status 3 and the Apply" +
220
+ " step reports a named refusal for review (choudoufu #878)."
221
+ : ""),
144
222
  }),
145
223
  ]),
146
224
  );
@@ -151,8 +229,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply
151
229
  const op = Op({
152
230
  name: config.name,
153
231
  overview: `Init, plan and apply the "${config.root}" terraform root`,
154
- taskQueue,
155
- searchAttributes: {
232
+ labels: {
156
233
  Apply: "true",
157
234
  TerraformRoot: config.root,
158
235
  },