@intentius/chant 0.124.51 → 0.124.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (593) hide show
  1. package/dist/audit/discover.d.ts.map +1 -1
  2. package/dist/audit/discover.js +3 -1
  3. package/dist/behaviour.d.ts +1 -1
  4. package/dist/behaviour.d.ts.map +1 -1
  5. package/dist/capabilities/templates.d.ts +65 -0
  6. package/dist/capabilities/templates.d.ts.map +1 -0
  7. package/dist/capabilities/templates.js +193 -0
  8. package/dist/capabilities/templates.json +145 -0
  9. package/dist/change-set-graph.d.ts +35 -3
  10. package/dist/change-set-graph.d.ts.map +1 -1
  11. package/dist/change-set-graph.js +60 -9
  12. package/dist/change-set.d.ts +65 -7
  13. package/dist/change-set.d.ts.map +1 -1
  14. package/dist/change-set.js +23 -12
  15. package/dist/ci-identity.d.ts +7 -2
  16. package/dist/ci-identity.d.ts.map +1 -1
  17. package/dist/ci-identity.js +4 -1
  18. package/dist/cli/commands/import-index.d.ts +52 -0
  19. package/dist/cli/commands/import-index.d.ts.map +1 -0
  20. package/dist/cli/commands/import-index.js +226 -0
  21. package/dist/cli/commands/lint.d.ts.map +1 -1
  22. package/dist/cli/commands/lint.js +4 -2
  23. package/dist/cli/handlers/components.d.ts.map +1 -1
  24. package/dist/cli/handlers/components.js +11 -2
  25. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  26. package/dist/cli/handlers/fan-out.js +16 -3
  27. package/dist/cli/handlers/graph.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.js +22 -4
  29. package/dist/cli/handlers/index-cloud.d.ts +14 -0
  30. package/dist/cli/handlers/index-cloud.d.ts.map +1 -0
  31. package/dist/cli/handlers/index-cloud.js +22 -0
  32. package/dist/cli/handlers/index-diff.d.ts +18 -0
  33. package/dist/cli/handlers/index-diff.d.ts.map +1 -0
  34. package/dist/cli/handlers/index-diff.js +142 -0
  35. package/dist/cli/handlers/lifecycle-drift.d.ts +25 -0
  36. package/dist/cli/handlers/lifecycle-drift.d.ts.map +1 -0
  37. package/dist/cli/handlers/lifecycle-drift.js +64 -0
  38. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  39. package/dist/cli/handlers/lifecycle.js +188 -28
  40. package/dist/cli/handlers/misc.d.ts +9 -0
  41. package/dist/cli/handlers/misc.d.ts.map +1 -1
  42. package/dist/cli/handlers/misc.js +77 -0
  43. package/dist/cli/handlers/pr.d.ts.map +1 -1
  44. package/dist/cli/handlers/pr.js +17 -3
  45. package/dist/cli/handlers/promote.d.ts.map +1 -1
  46. package/dist/cli/handlers/promote.js +89 -15
  47. package/dist/cli/handlers/query.d.ts.map +1 -1
  48. package/dist/cli/handlers/query.js +28 -4
  49. package/dist/cli/handlers/scenario.d.ts.map +1 -1
  50. package/dist/cli/handlers/scenario.js +8 -7
  51. package/dist/cli/handlers/search.d.ts +34 -4
  52. package/dist/cli/handlers/search.d.ts.map +1 -1
  53. package/dist/cli/handlers/search.js +216 -36
  54. package/dist/cli/main.d.ts.map +1 -1
  55. package/dist/cli/main.js +55 -1
  56. package/dist/cli/mcp/lifecycle-tools.d.ts.map +1 -1
  57. package/dist/cli/mcp/lifecycle-tools.js +3 -2
  58. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  59. package/dist/cli/mcp/resource-handlers.js +4 -3
  60. package/dist/cli/mcp/workspace-tools.js +1 -1
  61. package/dist/cli/parse-args.d.ts.map +1 -1
  62. package/dist/cli/parse-args.js +55 -2
  63. package/dist/cli/registry.d.ts +19 -1
  64. package/dist/cli/registry.d.ts.map +1 -1
  65. package/dist/cloud-lister/aws.d.ts +35 -0
  66. package/dist/cloud-lister/aws.d.ts.map +1 -0
  67. package/dist/cloud-lister/aws.js +203 -0
  68. package/dist/cloud-lister/azure.d.ts +34 -0
  69. package/dist/cloud-lister/azure.d.ts.map +1 -0
  70. package/dist/cloud-lister/azure.js +129 -0
  71. package/dist/cloud-lister/common.d.ts +40 -0
  72. package/dist/cloud-lister/common.d.ts.map +1 -0
  73. package/dist/cloud-lister/common.js +83 -0
  74. package/dist/cloud-lister/credentials.d.ts +50 -0
  75. package/dist/cloud-lister/credentials.d.ts.map +1 -0
  76. package/dist/cloud-lister/credentials.js +86 -0
  77. package/dist/cloud-lister/fakes.d.ts +27 -0
  78. package/dist/cloud-lister/fakes.d.ts.map +1 -0
  79. package/dist/cloud-lister/fakes.js +50 -0
  80. package/dist/cloud-lister/gcp.d.ts +42 -0
  81. package/dist/cloud-lister/gcp.d.ts.map +1 -0
  82. package/dist/cloud-lister/gcp.js +143 -0
  83. package/dist/cloud-lister/guard.d.ts +49 -0
  84. package/dist/cloud-lister/guard.d.ts.map +1 -0
  85. package/dist/cloud-lister/guard.js +103 -0
  86. package/dist/cloud-lister/ids.d.ts +23 -0
  87. package/dist/cloud-lister/ids.d.ts.map +1 -0
  88. package/dist/cloud-lister/ids.js +42 -0
  89. package/dist/cloud-lister/index.d.ts +67 -0
  90. package/dist/cloud-lister/index.d.ts.map +1 -0
  91. package/dist/cloud-lister/index.js +102 -0
  92. package/dist/cloud-lister/kubernetes.d.ts +64 -0
  93. package/dist/cloud-lister/kubernetes.d.ts.map +1 -0
  94. package/dist/cloud-lister/kubernetes.js +276 -0
  95. package/dist/cloud-lister/markers.d.ts +31 -0
  96. package/dist/cloud-lister/markers.d.ts.map +1 -0
  97. package/dist/cloud-lister/markers.js +73 -0
  98. package/dist/cloud-lister/mask.d.ts +31 -0
  99. package/dist/cloud-lister/mask.d.ts.map +1 -0
  100. package/dist/cloud-lister/mask.js +74 -0
  101. package/dist/cloud-lister/sigv4.d.ts +29 -0
  102. package/dist/cloud-lister/sigv4.d.ts.map +1 -0
  103. package/dist/cloud-lister/sigv4.js +54 -0
  104. package/dist/cloud-lister/transport.d.ts +55 -0
  105. package/dist/cloud-lister/transport.d.ts.map +1 -0
  106. package/dist/cloud-lister/transport.js +86 -0
  107. package/dist/cloud-lister/types.d.ts +121 -0
  108. package/dist/cloud-lister/types.d.ts.map +1 -0
  109. package/dist/cloud-lister/types.js +24 -0
  110. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  111. package/dist/components/capability-plugin-loader.js +6 -0
  112. package/dist/components/capability-plugin.d.ts +9 -0
  113. package/dist/components/capability-plugin.d.ts.map +1 -1
  114. package/dist/components/capability.d.ts +224 -0
  115. package/dist/components/capability.d.ts.map +1 -1
  116. package/dist/components/cli-support.d.ts +21 -0
  117. package/dist/components/cli-support.d.ts.map +1 -1
  118. package/dist/components/cli-support.js +58 -14
  119. package/dist/components/covered-round.d.ts +173 -0
  120. package/dist/components/covered-round.d.ts.map +1 -0
  121. package/dist/components/covered-round.js +200 -0
  122. package/dist/components/declared-reads.d.ts +38 -0
  123. package/dist/components/declared-reads.d.ts.map +1 -0
  124. package/dist/components/declared-reads.js +131 -0
  125. package/dist/components/deploy-units.d.ts.map +1 -1
  126. package/dist/components/deploy-units.js +4 -0
  127. package/dist/components/driver.d.ts +42 -1
  128. package/dist/components/driver.d.ts.map +1 -1
  129. package/dist/components/driver.js +74 -3
  130. package/dist/components/fan-out-run.d.ts +23 -1
  131. package/dist/components/fan-out-run.d.ts.map +1 -1
  132. package/dist/components/fan-out-run.js +82 -14
  133. package/dist/components/fan-out-support.d.ts +6 -0
  134. package/dist/components/fan-out-support.d.ts.map +1 -1
  135. package/dist/components/fan-out-support.js +4 -1
  136. package/dist/components/fan-out.d.ts +28 -0
  137. package/dist/components/fan-out.d.ts.map +1 -1
  138. package/dist/components/groups.d.ts +229 -0
  139. package/dist/components/groups.d.ts.map +1 -0
  140. package/dist/components/groups.js +760 -0
  141. package/dist/components/index.d.ts +7 -2
  142. package/dist/components/index.d.ts.map +1 -1
  143. package/dist/components/index.js +5 -0
  144. package/dist/components/plan-shape-conformance.d.ts +48 -0
  145. package/dist/components/plan-shape-conformance.d.ts.map +1 -0
  146. package/dist/components/plan-shape-conformance.js +122 -0
  147. package/dist/components/pr-run.d.ts +62 -7
  148. package/dist/components/pr-run.d.ts.map +1 -1
  149. package/dist/components/pr-run.js +354 -53
  150. package/dist/components/promote-rounds.d.ts +9 -0
  151. package/dist/components/promote-rounds.d.ts.map +1 -1
  152. package/dist/components/promote-rounds.js +23 -2
  153. package/dist/components/promote.d.ts +17 -0
  154. package/dist/components/promote.d.ts.map +1 -1
  155. package/dist/components/promote.js +36 -6
  156. package/dist/components/release-gate.d.ts +23 -1
  157. package/dist/components/release-gate.d.ts.map +1 -1
  158. package/dist/components/release-gate.js +97 -33
  159. package/dist/components/run-groups.d.ts +58 -0
  160. package/dist/components/run-groups.d.ts.map +1 -0
  161. package/dist/components/run-groups.js +78 -0
  162. package/dist/components/starter-plugin.d.ts +1 -0
  163. package/dist/components/starter-plugin.d.ts.map +1 -1
  164. package/dist/components/starter-plugin.js +6 -1
  165. package/dist/components/verbs/index.d.ts +1 -0
  166. package/dist/components/verbs/index.d.ts.map +1 -1
  167. package/dist/components/verbs/index.js +1 -0
  168. package/dist/components/verbs/member-outputs.d.ts +187 -0
  169. package/dist/components/verbs/member-outputs.d.ts.map +1 -0
  170. package/dist/components/verbs/member-outputs.js +218 -0
  171. package/dist/components/wave-plan.d.ts +2 -0
  172. package/dist/components/wave-plan.d.ts.map +1 -1
  173. package/dist/components/wave-plan.js +4 -1
  174. package/dist/config.d.ts +60 -0
  175. package/dist/config.d.ts.map +1 -1
  176. package/dist/config.js +27 -0
  177. package/dist/deep-observation.d.ts +1 -13
  178. package/dist/deep-observation.d.ts.map +1 -1
  179. package/dist/deep-observation.js +5 -22
  180. package/dist/drift-record.d.ts +542 -0
  181. package/dist/drift-record.d.ts.map +1 -0
  182. package/dist/drift-record.js +886 -0
  183. package/dist/engine-facts.d.ts +144 -0
  184. package/dist/engine-facts.d.ts.map +1 -0
  185. package/dist/engine-facts.js +14 -0
  186. package/dist/graph-ir.d.ts +153 -2
  187. package/dist/graph-ir.d.ts.map +1 -1
  188. package/dist/graph-ir.js +224 -6
  189. package/dist/graph-refs.d.ts +145 -0
  190. package/dist/graph-refs.d.ts.map +1 -1
  191. package/dist/graph-refs.js +305 -0
  192. package/dist/graph-schema/fixtures/adopt.json +15 -0
  193. package/dist/graph-schema/fixtures/choudoufu.engine-graph.json +169 -0
  194. package/dist/graph-schema/fixtures/index.json +68 -0
  195. package/dist/graph-schema/fixtures/plunge.engine-graph.json +158 -0
  196. package/dist/graph-schema/fixtures.d.ts +23 -0
  197. package/dist/graph-schema/fixtures.d.ts.map +1 -0
  198. package/dist/graph-schema/fixtures.js +246 -0
  199. package/dist/graph-schema/generate.d.ts +15 -0
  200. package/dist/graph-schema/generate.d.ts.map +1 -0
  201. package/dist/graph-schema/generate.js +267 -0
  202. package/dist/graph-schema/graph.schema.json +1387 -0
  203. package/dist/health.schema.json +2 -1
  204. package/dist/identity.d.ts.map +1 -1
  205. package/dist/identity.js +2 -0
  206. package/dist/index.d.ts +1 -0
  207. package/dist/index.d.ts.map +1 -1
  208. package/dist/index.js +2 -0
  209. package/dist/instance-nodes.d.ts +159 -0
  210. package/dist/instance-nodes.d.ts.map +1 -0
  211. package/dist/instance-nodes.js +231 -0
  212. package/dist/lexicon.d.ts +72 -0
  213. package/dist/lexicon.d.ts.map +1 -1
  214. package/dist/lifecycle/adoption-file.d.ts +56 -0
  215. package/dist/lifecycle/adoption-file.d.ts.map +1 -0
  216. package/dist/lifecycle/adoption-file.js +123 -0
  217. package/dist/lifecycle/cloud-index.d.ts +186 -0
  218. package/dist/lifecycle/cloud-index.d.ts.map +1 -0
  219. package/dist/lifecycle/cloud-index.js +618 -0
  220. package/dist/lifecycle/derive-edges.d.ts +59 -0
  221. package/dist/lifecycle/derive-edges.d.ts.map +1 -0
  222. package/dist/lifecycle/derive-edges.js +126 -0
  223. package/dist/lifecycle/git.d.ts +10 -9
  224. package/dist/lifecycle/git.d.ts.map +1 -1
  225. package/dist/lifecycle/git.js +7 -1
  226. package/dist/lifecycle/identity-match.d.ts +133 -0
  227. package/dist/lifecycle/identity-match.d.ts.map +1 -0
  228. package/dist/lifecycle/identity-match.js +147 -0
  229. package/dist/lifecycle/index-diff.d.ts +224 -0
  230. package/dist/lifecycle/index-diff.d.ts.map +1 -0
  231. package/dist/lifecycle/index-diff.js +327 -0
  232. package/dist/lifecycle/index-document.d.ts +130 -0
  233. package/dist/lifecycle/index-document.d.ts.map +1 -0
  234. package/dist/lifecycle/index-document.js +189 -0
  235. package/dist/lifecycle/index-drift.d.ts +121 -0
  236. package/dist/lifecycle/index-drift.d.ts.map +1 -0
  237. package/dist/lifecycle/index-drift.js +178 -0
  238. package/dist/lifecycle/index-import.d.ts +168 -0
  239. package/dist/lifecycle/index-import.d.ts.map +1 -0
  240. package/dist/lifecycle/index-import.js +515 -0
  241. package/dist/lifecycle/index-join.d.ts +129 -0
  242. package/dist/lifecycle/index-join.d.ts.map +1 -0
  243. package/dist/lifecycle/index-join.js +248 -0
  244. package/dist/lifecycle/index.d.ts +4 -0
  245. package/dist/lifecycle/index.d.ts.map +1 -1
  246. package/dist/lifecycle/index.js +4 -0
  247. package/dist/lifecycle/observation-baseline.d.ts +61 -21
  248. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  249. package/dist/lifecycle/observation-baseline.js +144 -19
  250. package/dist/lifecycle/observe.d.ts.map +1 -1
  251. package/dist/lifecycle/observe.js +6 -1
  252. package/dist/lifecycle/query.d.ts +45 -4
  253. package/dist/lifecycle/query.d.ts.map +1 -1
  254. package/dist/lifecycle/query.js +89 -7
  255. package/dist/lifecycle/replay.d.ts +10 -3
  256. package/dist/lifecycle/replay.d.ts.map +1 -1
  257. package/dist/lifecycle/replay.js +10 -6
  258. package/dist/lifecycle/scenario.d.ts +4 -3
  259. package/dist/lifecycle/scenario.d.ts.map +1 -1
  260. package/dist/lifecycle/snapshot-store-fake.d.ts +9 -0
  261. package/dist/lifecycle/snapshot-store-fake.d.ts.map +1 -0
  262. package/dist/lifecycle/snapshot-store-fake.js +21 -0
  263. package/dist/lifecycle/snapshot-store-s3.d.ts +102 -0
  264. package/dist/lifecycle/snapshot-store-s3.d.ts.map +1 -0
  265. package/dist/lifecycle/snapshot-store-s3.js +254 -0
  266. package/dist/lifecycle/snapshot-store.d.ts +170 -0
  267. package/dist/lifecycle/snapshot-store.d.ts.map +1 -0
  268. package/dist/lifecycle/snapshot-store.js +832 -0
  269. package/dist/lifecycle/snapshot.d.ts +11 -0
  270. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  271. package/dist/lifecycle/snapshot.js +18 -5
  272. package/dist/lifecycle/type-listing.d.ts +148 -0
  273. package/dist/lifecycle/type-listing.d.ts.map +1 -0
  274. package/dist/lifecycle/type-listing.js +166 -0
  275. package/dist/lint/component-checks.d.ts +15 -1
  276. package/dist/lint/component-checks.d.ts.map +1 -1
  277. package/dist/lint/component-checks.js +1 -0
  278. package/dist/lint/presets/relaxed.json +2 -1
  279. package/dist/lint/presets/strict.json +2 -1
  280. package/dist/lint/rules/__fixtures__/comp/comp008/fail/dns.component.d.ts +4 -0
  281. package/dist/lint/rules/__fixtures__/comp/comp008/fail/dns.component.d.ts.map +1 -0
  282. package/dist/lint/rules/__fixtures__/comp/comp008/fail/dns.component.js +13 -0
  283. package/dist/lint/rules/__fixtures__/comp/comp008/pass/dns.component.d.ts +4 -0
  284. package/dist/lint/rules/__fixtures__/comp/comp008/pass/dns.component.d.ts.map +1 -0
  285. package/dist/lint/rules/__fixtures__/comp/comp008/pass/dns.component.js +8 -0
  286. package/dist/lint/rules/comp/comp008-applies-signal-only-root.d.ts +33 -0
  287. package/dist/lint/rules/comp/comp008-applies-signal-only-root.d.ts.map +1 -0
  288. package/dist/lint/rules/comp/comp008-applies-signal-only-root.js +123 -0
  289. package/dist/lint/rules/comp/index.d.ts +2 -1
  290. package/dist/lint/rules/comp/index.d.ts.map +1 -1
  291. package/dist/lint/rules/comp/index.js +4 -1
  292. package/dist/observation.d.ts +19 -2
  293. package/dist/observation.d.ts.map +1 -1
  294. package/dist/observation.js +18 -1
  295. package/dist/op/activities/activity-contracts.d.ts +1 -1
  296. package/dist/op/activities/activity-contracts.js +1 -1
  297. package/dist/op/activities/change-set.d.ts +6 -4
  298. package/dist/op/activities/change-set.d.ts.map +1 -1
  299. package/dist/op/activities/change-set.js +4 -2
  300. package/dist/op/activity-failure.d.ts +27 -0
  301. package/dist/op/activity-failure.d.ts.map +1 -1
  302. package/dist/op/activity-failure.js +31 -0
  303. package/dist/op/index.d.ts +2 -1
  304. package/dist/op/index.d.ts.map +1 -1
  305. package/dist/op/index.js +1 -1
  306. package/dist/op/local-executor.d.ts +5 -1
  307. package/dist/op/local-executor.d.ts.map +1 -1
  308. package/dist/op/local-executor.js +22 -8
  309. package/dist/pr-loop.d.ts +92 -3
  310. package/dist/pr-loop.d.ts.map +1 -1
  311. package/dist/pr-loop.js +62 -3
  312. package/dist/pulumi-urn.d.ts +101 -0
  313. package/dist/pulumi-urn.d.ts.map +1 -0
  314. package/dist/pulumi-urn.js +131 -0
  315. package/dist/release-floors.d.ts +23 -0
  316. package/dist/release-floors.d.ts.map +1 -0
  317. package/dist/release-floors.js +22 -0
  318. package/dist/sensitive-key.d.ts +23 -0
  319. package/dist/sensitive-key.d.ts.map +1 -0
  320. package/dist/sensitive-key.js +31 -0
  321. package/dist/wave-runtime.mjs +234 -234
  322. package/dist/workspace/adoption-report.d.ts +53 -0
  323. package/dist/workspace/adoption-report.d.ts.map +1 -0
  324. package/dist/workspace/adoption-report.js +76 -0
  325. package/dist/workspace/capabilities-contract.d.ts +7 -0
  326. package/dist/workspace/capabilities-contract.d.ts.map +1 -1
  327. package/dist/workspace/capabilities-contract.js +2 -1
  328. package/dist/workspace/capabilities.schema.json +98 -0
  329. package/dist/workspace/change-set.schema.json +42 -3
  330. package/dist/workspace/ci-cloud-identity.d.ts +13 -1
  331. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  332. package/dist/workspace/ci-cloud-identity.js +30 -1
  333. package/dist/workspace/compose-graph.d.ts +10 -1
  334. package/dist/workspace/compose-graph.d.ts.map +1 -1
  335. package/dist/workspace/compose-graph.js +6 -1
  336. package/dist/workspace/conformance/index.d.ts +1 -1
  337. package/dist/workspace/conformance/index.d.ts.map +1 -1
  338. package/dist/workspace/conformance/index.js +6 -1
  339. package/dist/workspace/drift-cli.d.ts +81 -0
  340. package/dist/workspace/drift-cli.d.ts.map +1 -0
  341. package/dist/workspace/drift-cli.js +317 -0
  342. package/dist/workspace/drift.schema.json +672 -0
  343. package/dist/workspace/graph-cli.d.ts +2 -0
  344. package/dist/workspace/graph-cli.d.ts.map +1 -1
  345. package/dist/workspace/graph-cli.js +3 -0
  346. package/dist/workspace/graph.schema.json +72 -0
  347. package/dist/workspace/init.d.ts +5 -1
  348. package/dist/workspace/init.d.ts.map +1 -1
  349. package/dist/workspace/init.js +55 -9
  350. package/dist/workspace/ledger.schema.json +1 -1
  351. package/dist/workspace/nested-graph.d.ts.map +1 -1
  352. package/dist/workspace/nested-graph.js +2 -0
  353. package/dist/workspace/pr-report.schema.json +76 -3
  354. package/dist/workspace/promote-waves.d.ts +112 -0
  355. package/dist/workspace/promote-waves.d.ts.map +1 -0
  356. package/dist/workspace/promote-waves.js +290 -0
  357. package/dist/workspace/promote.d.ts +84 -2
  358. package/dist/workspace/promote.d.ts.map +1 -1
  359. package/dist/workspace/promote.js +347 -61
  360. package/dist/workspace/reason-codes.d.ts +12 -0
  361. package/dist/workspace/reason-codes.d.ts.map +1 -1
  362. package/dist/workspace/reason-codes.js +14 -0
  363. package/dist/workspace/search-contract.d.ts +3 -3
  364. package/dist/workspace/search-contract.d.ts.map +1 -1
  365. package/dist/workspace/search-contract.js +2 -0
  366. package/dist/workspace/search.schema.json +103 -5
  367. package/package.json +14 -1
  368. package/src/__fixtures__/drift-record/README.md +58 -0
  369. package/src/__fixtures__/drift-record/choudoufu-cone.ndjson +4 -0
  370. package/src/__fixtures__/drift-record/choudoufu-resource.ndjson +3 -0
  371. package/src/__fixtures__/drift-record/component-status-ledger.json +65 -0
  372. package/src/__fixtures__/drift-record/component-status.json +91 -0
  373. package/src/__fixtures__/drift-record/deep-diff.json +259 -0
  374. package/src/__fixtures__/drift-record/lifecycle-diff-live.json +825 -0
  375. package/src/__fixtures__/drift-record/live-diff.json +125 -0
  376. package/src/audit/discover.e2e.test.ts +14 -0
  377. package/src/audit/discover.ts +3 -1
  378. package/src/behaviour.ts +1 -1
  379. package/src/capabilities/templates.json +145 -0
  380. package/src/capabilities/templates.test.ts +212 -0
  381. package/src/capabilities/templates.ts +204 -0
  382. package/src/change-set-bundle.test.ts +83 -0
  383. package/src/change-set-graph.ts +80 -9
  384. package/src/change-set.test.ts +24 -0
  385. package/src/change-set.ts +77 -14
  386. package/src/ci-identity-targets.test.ts +23 -0
  387. package/src/ci-identity.ts +10 -4
  388. package/src/cli/commands/import-index.test.ts +117 -0
  389. package/src/cli/commands/import-index.ts +265 -0
  390. package/src/cli/commands/lint.ts +4 -2
  391. package/src/cli/handlers/components.test.ts +9 -0
  392. package/src/cli/handlers/components.ts +12 -2
  393. package/src/cli/handlers/fan-out.ts +17 -3
  394. package/src/cli/handlers/graph.test.ts +42 -0
  395. package/src/cli/handlers/graph.ts +26 -4
  396. package/src/cli/handlers/index-cloud.ts +32 -0
  397. package/src/cli/handlers/index-diff.test.ts +68 -0
  398. package/src/cli/handlers/index-diff.ts +146 -0
  399. package/src/cli/handlers/ledger-root-member.test.ts +161 -0
  400. package/src/cli/handlers/lifecycle-drift.ts +68 -0
  401. package/src/cli/handlers/lifecycle.test.ts +33 -11
  402. package/src/cli/handlers/lifecycle.ts +205 -30
  403. package/src/cli/handlers/misc.ts +73 -0
  404. package/src/cli/handlers/pr-waves-blocked.test.ts +37 -2
  405. package/src/cli/handlers/pr.ts +16 -3
  406. package/src/cli/handlers/promote.test.ts +64 -0
  407. package/src/cli/handlers/promote.ts +81 -6
  408. package/src/cli/handlers/query.ts +27 -4
  409. package/src/cli/handlers/run-declared-reads-cli.test.ts +187 -0
  410. package/src/cli/handlers/run-groups-cli.test.ts +229 -0
  411. package/src/cli/handlers/scenario.test.ts +13 -0
  412. package/src/cli/handlers/scenario.ts +8 -7
  413. package/src/cli/handlers/search-index.test.ts +255 -0
  414. package/src/cli/handlers/search-json.test.ts +1 -1
  415. package/src/cli/handlers/search-ownership.test.ts +194 -0
  416. package/src/cli/handlers/search.ts +271 -38
  417. package/src/cli/main.test.ts +7 -0
  418. package/src/cli/main.ts +56 -1
  419. package/src/cli/mcp/lifecycle-tools.ts +3 -2
  420. package/src/cli/mcp/resource-handlers.ts +4 -3
  421. package/src/cli/mcp/workspace-tools.ts +1 -1
  422. package/src/cli/parse-args.ts +40 -2
  423. package/src/cli/registry.ts +19 -1
  424. package/src/cloud-lister/aws.test.ts +202 -0
  425. package/src/cloud-lister/aws.ts +226 -0
  426. package/src/cloud-lister/azure.test.ts +132 -0
  427. package/src/cloud-lister/azure.ts +142 -0
  428. package/src/cloud-lister/common.ts +115 -0
  429. package/src/cloud-lister/credentials.ts +110 -0
  430. package/src/cloud-lister/fakes.ts +66 -0
  431. package/src/cloud-lister/gcp.test.ts +121 -0
  432. package/src/cloud-lister/gcp.ts +159 -0
  433. package/src/cloud-lister/guard.test.ts +198 -0
  434. package/src/cloud-lister/guard.ts +111 -0
  435. package/src/cloud-lister/ids.ts +54 -0
  436. package/src/cloud-lister/index.ts +134 -0
  437. package/src/cloud-lister/kubernetes.test.ts +160 -0
  438. package/src/cloud-lister/kubernetes.ts +284 -0
  439. package/src/cloud-lister/markers.ts +74 -0
  440. package/src/cloud-lister/mask.ts +77 -0
  441. package/src/cloud-lister/shape.test.ts +81 -0
  442. package/src/cloud-lister/sigv4.ts +70 -0
  443. package/src/cloud-lister/transport.ts +118 -0
  444. package/src/cloud-lister/types.ts +135 -0
  445. package/src/components/capability-plugin-loader.ts +4 -0
  446. package/src/components/capability-plugin.ts +10 -0
  447. package/src/components/capability.ts +238 -0
  448. package/src/components/cli-support.ts +70 -14
  449. package/src/components/covered-round.test.ts +166 -0
  450. package/src/components/covered-round.ts +293 -0
  451. package/src/components/declared-reads.test.ts +103 -0
  452. package/src/components/declared-reads.ts +134 -0
  453. package/src/components/deploy-units.ts +4 -0
  454. package/src/components/driver.ts +101 -4
  455. package/src/components/fan-out-run.ts +95 -15
  456. package/src/components/fan-out-support.ts +10 -1
  457. package/src/components/fan-out.ts +30 -0
  458. package/src/components/groups.test.ts +320 -0
  459. package/src/components/groups.ts +880 -0
  460. package/src/components/index.ts +59 -0
  461. package/src/components/plan-shape-conformance.ts +130 -0
  462. package/src/components/pr-env-run-bundle.test.ts +2 -0
  463. package/src/components/pr-run.test.ts +728 -4
  464. package/src/components/pr-run.ts +395 -55
  465. package/src/components/promote-rounds.ts +25 -2
  466. package/src/components/promote.ts +55 -5
  467. package/src/components/registry.test.ts +1 -1
  468. package/src/components/release-gate.ts +115 -30
  469. package/src/components/release-groups.test.ts +362 -0
  470. package/src/components/run-groups.ts +113 -0
  471. package/src/components/starter-plugin.ts +6 -0
  472. package/src/components/verbs/index.ts +1 -0
  473. package/src/components/verbs/member-outputs.test.ts +205 -0
  474. package/src/components/verbs/member-outputs.ts +308 -0
  475. package/src/components/wave-plan.ts +5 -2
  476. package/src/config.ts +44 -0
  477. package/src/deep-observation.ts +5 -23
  478. package/src/drift-record.test.ts +596 -0
  479. package/src/drift-record.ts +1206 -0
  480. package/src/engine-facts.guard.test.ts +111 -0
  481. package/src/engine-facts.test.ts +178 -0
  482. package/src/engine-facts.ts +154 -0
  483. package/src/gated-waves-bundle.test.ts +2 -0
  484. package/src/graph-ir-live.test.ts +92 -0
  485. package/src/graph-ir.ts +317 -7
  486. package/src/graph-refs.ts +416 -0
  487. package/src/graph-schema/fixtures/adopt.json +15 -0
  488. package/src/graph-schema/fixtures/choudoufu.engine-graph.json +169 -0
  489. package/src/graph-schema/fixtures/index.json +68 -0
  490. package/src/graph-schema/fixtures/plunge.engine-graph.json +158 -0
  491. package/src/graph-schema/fixtures.ts +267 -0
  492. package/src/graph-schema/generate.ts +263 -0
  493. package/src/graph-schema/graph-schema.test.ts +153 -0
  494. package/src/graph-schema/graph.schema.json +1387 -0
  495. package/src/health.schema.json +2 -1
  496. package/src/identity.ts +2 -0
  497. package/src/index.ts +2 -0
  498. package/src/instance-nodes.ts +288 -0
  499. package/src/lexicon.ts +76 -0
  500. package/src/lifecycle/__fixtures__/index-diff/20261026T120000Z-aaaaaaaaaaaa.json +149 -0
  501. package/src/lifecycle/__fixtures__/index-diff/20261026T130000Z-bbbbbbbbbbbb.json +127 -0
  502. package/src/lifecycle/adoption-file.test.ts +100 -0
  503. package/src/lifecycle/adoption-file.ts +154 -0
  504. package/src/lifecycle/cloud-index.test.ts +517 -0
  505. package/src/lifecycle/cloud-index.ts +736 -0
  506. package/src/lifecycle/derive-edges.test.ts +193 -0
  507. package/src/lifecycle/derive-edges.ts +182 -0
  508. package/src/lifecycle/git.ts +10 -4
  509. package/src/lifecycle/identity-match.test.ts +308 -0
  510. package/src/lifecycle/identity-match.ts +257 -0
  511. package/src/lifecycle/index-diff.test.ts +198 -0
  512. package/src/lifecycle/index-diff.ts +476 -0
  513. package/src/lifecycle/index-document.test.ts +64 -0
  514. package/src/lifecycle/index-document.ts +299 -0
  515. package/src/lifecycle/index-drift.test.ts +191 -0
  516. package/src/lifecycle/index-drift.ts +304 -0
  517. package/src/lifecycle/index-import.test.ts +255 -0
  518. package/src/lifecycle/index-import.ts +634 -0
  519. package/src/lifecycle/index-join.test.ts +250 -0
  520. package/src/lifecycle/index-join.ts +304 -0
  521. package/src/lifecycle/index-store-load-bearing.test.ts +187 -0
  522. package/src/lifecycle/index.ts +4 -0
  523. package/src/lifecycle/member-ledger.test.ts +25 -7
  524. package/src/lifecycle/observation-baseline.test.ts +72 -1
  525. package/src/lifecycle/observation-baseline.ts +190 -27
  526. package/src/lifecycle/observe.ts +6 -1
  527. package/src/lifecycle/query.test.ts +69 -1
  528. package/src/lifecycle/query.ts +103 -10
  529. package/src/lifecycle/replay.test.ts +9 -5
  530. package/src/lifecycle/replay.ts +9 -5
  531. package/src/lifecycle/scenario.ts +4 -3
  532. package/src/lifecycle/snapshot-store-fake.ts +29 -0
  533. package/src/lifecycle/snapshot-store-s3.ts +316 -0
  534. package/src/lifecycle/snapshot-store.test.ts +385 -0
  535. package/src/lifecycle/snapshot-store.ts +981 -0
  536. package/src/lifecycle/snapshot.test.ts +13 -0
  537. package/src/lifecycle/snapshot.ts +23 -6
  538. package/src/lifecycle/type-listing.test.ts +166 -0
  539. package/src/lifecycle/type-listing.ts +309 -0
  540. package/src/lint/component-checks.ts +17 -1
  541. package/src/lint/config.test.ts +3 -0
  542. package/src/lint/presets/relaxed.json +2 -1
  543. package/src/lint/presets/strict.json +2 -1
  544. package/src/lint/rules/__fixtures__/comp/comp008/fail/dns.component.ts +15 -0
  545. package/src/lint/rules/__fixtures__/comp/comp008/pass/dns.component.ts +10 -0
  546. package/src/lint/rules/comp/comp.test.ts +2 -1
  547. package/src/lint/rules/comp/comp008-applies-signal-only-root.ts +121 -0
  548. package/src/lint/rules/comp/comp008.test.ts +77 -0
  549. package/src/lint/rules/comp/index.ts +4 -1
  550. package/src/observation-edges.test.ts +33 -0
  551. package/src/observation.ts +36 -1
  552. package/src/op/activities/activity-contracts.ts +1 -1
  553. package/src/op/activities/change-set.ts +8 -5
  554. package/src/op/activity-failure.ts +36 -0
  555. package/src/op/index.ts +2 -1
  556. package/src/op/local-executor.test.ts +80 -0
  557. package/src/op/local-executor.ts +27 -9
  558. package/src/pr-loop-bundle.test.ts +5 -2
  559. package/src/pr-loop.test.ts +57 -1
  560. package/src/pr-loop.ts +154 -6
  561. package/src/pulumi-urn.ts +151 -0
  562. package/src/release-floors.ts +22 -0
  563. package/src/sensitive-key.ts +34 -0
  564. package/src/workspace/adoption-report.test.ts +61 -0
  565. package/src/workspace/adoption-report.ts +103 -0
  566. package/src/workspace/capabilities-contract.test.ts +4 -0
  567. package/src/workspace/capabilities-contract.ts +9 -1
  568. package/src/workspace/capabilities.schema.json +98 -0
  569. package/src/workspace/change-set.schema.json +42 -3
  570. package/src/workspace/ci-cloud-identity.ts +31 -1
  571. package/src/workspace/compose-graph.test.ts +12 -0
  572. package/src/workspace/compose-graph.ts +12 -1
  573. package/src/workspace/conformance/conformance.e2e.test.ts +2 -1
  574. package/src/workspace/conformance/index.ts +6 -1
  575. package/src/workspace/drift-cli.test.ts +65 -0
  576. package/src/workspace/drift-cli.ts +365 -0
  577. package/src/workspace/drift.schema.json +672 -0
  578. package/src/workspace/graph-cli.ts +5 -0
  579. package/src/workspace/graph.schema.json +72 -0
  580. package/src/workspace/init.test.ts +38 -1
  581. package/src/workspace/init.ts +50 -8
  582. package/src/workspace/intent-gaps.test.ts +18 -2
  583. package/src/workspace/ledger.schema.json +1 -1
  584. package/src/workspace/nested-graph.ts +1 -0
  585. package/src/workspace/pr-report.schema.json +76 -3
  586. package/src/workspace/promote-waves.test.ts +296 -0
  587. package/src/workspace/promote-waves.ts +340 -0
  588. package/src/workspace/promote.test.ts +158 -4
  589. package/src/workspace/promote.ts +398 -57
  590. package/src/workspace/reason-codes.test.ts +4 -1
  591. package/src/workspace/reason-codes.ts +14 -0
  592. package/src/workspace/search-contract.ts +2 -0
  593. package/src/workspace/search.schema.json +103 -5
@@ -10,7 +10,18 @@
10
10
  */
11
11
 
12
12
  import { describe, expect, test } from "vitest";
13
- import { CapabilityRegistry, type DeployContext, type PlannedOutputs } from "./capability";
13
+ import {
14
+ CapabilityRegistry,
15
+ type CapabilityGroupOutcome,
16
+ type DeployContext,
17
+ type GroupContext,
18
+ type GroupFallback,
19
+ type GroupMember,
20
+ type GroupRun,
21
+ type PlannedOutputs,
22
+ } from "./capability";
23
+ import { findComponentGroups, groupFanOut } from "./groups";
24
+ import { failedRunExitCode } from "./driver";
14
25
  import { memoryGateLedgerPort } from "../op/gate";
15
26
  import { planFanOut } from "./fan-out";
16
27
  import {
@@ -28,9 +39,9 @@ import {
28
39
  waveNotCovered,
29
40
  type PrPlanSet,
30
41
  } from "./pr-run";
31
- import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
42
+ import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, isEmptyChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
32
43
  import type { PrApplyRecord } from "./fan-out-record";
33
- import { renderPrNote } from "../pr-loop";
44
+ import { renderPrNote, type PrApproval } from "../pr-loop";
34
45
  import type { FanOutPlan } from "./fan-out";
35
46
  import { computePlanDigest } from "../lifecycle/plan-digest";
36
47
  import type { DriverComponent } from "./driver";
@@ -78,6 +89,8 @@ function world() {
78
89
  predict: {} as Record<string, (() => PlannedOutputs | undefined) | undefined>,
79
90
  /** What `a` writes when it applies, when not what its version gives. */
80
91
  appliesAs: undefined as string | undefined,
92
+ /** `a` applying makes `app` plan a change its first plan did not show. */
93
+ bumpAppOnApply: false,
81
94
  };
82
95
  w.predict.net = () => ({ known: { ...(w.state.net ?? {}) }, unknown: [] });
83
96
  w.predict.a = () => ({ known: { id: `a-${w.versions.a ?? "v1"}` }, unknown: [] });
@@ -122,6 +135,7 @@ function world() {
122
135
  w.ranWith[input.root] = input.vars;
123
136
  if (w.failing.has(input.root)) throw new Error(`${input.root} failed`);
124
137
  w.applied.push(input.root);
138
+ if (input.root === "a" && w.bumpAppOnApply) w.versions.app = "v2";
125
139
  if (input.root === "a") w.state.a = { id: w.appliesAs ?? `a-${w.versions.a ?? "v1"}` };
126
140
  return { outputs: { ...(w.state[input.root] ?? {}) } };
127
141
  },
@@ -311,7 +325,7 @@ describe("a dependency outside the selection (#4104)", () => {
311
325
  if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
312
326
  expect(w.outputsRead).toEqual([]);
313
327
  expect(resumed.set.doc.digest).toBe(set.doc.digest);
314
- expect(resumed.set.outputs).toBe(record.planOutputs);
328
+ expect(resumed.set.outputs).toEqual(record.planOutputs);
315
329
  });
316
330
  });
317
331
 
@@ -404,6 +418,19 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
404
418
  expect(w.applied).toEqual(["a", "app"]);
405
419
  });
406
420
 
421
+ test("each member names every upstream output it reads, the known ones too, and the apply report keeps them", async () => {
422
+ const w = world();
423
+ w.versions.a = "v2";
424
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
425
+ // a reads net, which is not in the change: a read of a deployed output is a read too.
426
+ expect(set.members.map((m) => [m.member, m.reads])).toEqual([
427
+ ["a", [{ upstream: "net", outputs: ["cidr"] }]],
428
+ ["app", [{ upstream: "a", outputs: ["id"] }]],
429
+ ]);
430
+ const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
431
+ expect(applied.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"] }]);
432
+ });
433
+
407
434
  test("an output the change moves: the dependent plans on the moved value, not the one standing now", async () => {
408
435
  const w = world();
409
436
  w.versions.a = "v2";
@@ -429,6 +456,7 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
429
456
  ["a", undefined],
430
457
  ["app", ["a.id"]],
431
458
  ]);
459
+ expect(set.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"], unknown: ["id"] }]);
432
460
 
433
461
  // No approval of the plan made after a applied: a applies, app waits.
434
462
  const waiting = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set, decideRound: decideWith(new Set()) });
@@ -457,6 +485,121 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
457
485
  expect(applied.members.find((m) => m.member === "app")).toMatchObject({ status: "applied", round: 2 });
458
486
  });
459
487
 
488
+ const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
489
+
490
+ test("the first approval covers the round that only fills in what was unknown: no gate of its own, recorded as covered (#4419)", async () => {
491
+ const w = world();
492
+ w.versions.a = "v2";
493
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
494
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
495
+ let asked = 0;
496
+ const applied = await applyPrSet({
497
+ components: ESTATE,
498
+ plan: plan(),
499
+ registry: w.registry,
500
+ env: "prod",
501
+ set,
502
+ approval: approvedFirst,
503
+ decideRound: async () => {
504
+ asked++;
505
+ return { satisfied: false, approval: { status: "pending" } };
506
+ },
507
+ });
508
+ expect(asked).toBe(0);
509
+ expect(applied.status).toBe("applied");
510
+ expect(w.applied).toEqual(["a", "app"]);
511
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
512
+ expect(applied.rounds).toHaveLength(1);
513
+ expect(applied.rounds![0]).toMatchObject({
514
+ round: 2,
515
+ status: "applied",
516
+ approval: approvedFirst,
517
+ covered: { digest: set.doc.digest, filledInputs: ["a.id"] },
518
+ });
519
+ });
520
+
521
+ test("a round that plans a change the first plan did not show asks for its own approval, with the reason (#4419)", async () => {
522
+ const w = world();
523
+ w.versions.a = "v2";
524
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
525
+ w.bumpAppOnApply = true;
526
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
527
+ const seen: Array<string | undefined> = [];
528
+ const applied = await applyPrSet({
529
+ components: ESTATE,
530
+ plan: plan(),
531
+ registry: w.registry,
532
+ env: "prod",
533
+ set,
534
+ approval: approvedFirst,
535
+ decideRound: async ({ notCovered }) => {
536
+ seen.push(notCovered);
537
+ return { satisfied: false, approval: { status: "pending" } };
538
+ },
539
+ });
540
+ expect(applied.status).toBe("waiting");
541
+ expect(w.applied).toEqual(["a"]);
542
+ expect(seen).toEqual(["app now plans to update terraform_data.x, which the first plan did not"]);
543
+ expect(applied.rounds![0]).toMatchObject({ status: "waiting", notCovered: seen[0] });
544
+ expect(applied.rounds![0]!.covered).toBeUndefined();
545
+ });
546
+
547
+ test("a third member that reads a previewed member's known output is covered when the round plans that output as the same value (#4419)", async () => {
548
+ const chain: DriverComponent[] = [
549
+ root("x"),
550
+ root("y", ["x"], { id: { stackOutput: { stack: "x", name: "id" } } }),
551
+ root("z", ["y"], { port: { stackOutput: { stack: "y", name: "port" } } }),
552
+ ];
553
+ const w = world();
554
+ w.state = { x: { id: "x-v1" }, y: { port: "8080" }, z: {} };
555
+ w.versions.x = "v2";
556
+ w.versions.y = "v2";
557
+ w.versions.z = "v2";
558
+ w.predict.x = () => ({ known: {}, unknown: ["id"] });
559
+ w.predict.y = () => ({ known: { port: "8080" }, unknown: [] });
560
+ w.predict.z = () => undefined;
561
+ const chainPlan = planFanOut({ components: chain, changed: ["x"] });
562
+ const set = await planPrSet({ components: chain, plan: chainPlan, registry: w.registry, env: "prod" });
563
+ const seen: Array<string | undefined> = [];
564
+ const applied = await applyPrSet({
565
+ components: chain,
566
+ plan: chainPlan,
567
+ registry: w.registry,
568
+ env: "prod",
569
+ set,
570
+ approval: approvedFirst,
571
+ decideRound: async ({ notCovered }) => {
572
+ seen.push(notCovered);
573
+ return { satisfied: false, approval: { status: "pending" } };
574
+ },
575
+ });
576
+ expect(seen).toEqual([]);
577
+ expect(applied.status).toBe("applied");
578
+ expect(w.applied).toEqual(["x", "y", "z"]);
579
+ expect(applied.rounds![0]).toMatchObject({ round: 2, components: ["y", "z"], covered: { digest: set.doc.digest, filledInputs: ["x.id"] } });
580
+ });
581
+
582
+ test("an input the first plan read with a value that moved is not covered (#4419)", async () => {
583
+ const w = world();
584
+ w.versions.a = "v2";
585
+ w.appliesAs = "a-v2-real";
586
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
587
+ const seen: Array<string | undefined> = [];
588
+ await applyPrSet({
589
+ components: ESTATE,
590
+ plan: plan(),
591
+ registry: w.registry,
592
+ env: "prod",
593
+ set,
594
+ approval: approvedFirst,
595
+ decideRound: async ({ notCovered }) => {
596
+ seen.push(notCovered);
597
+ return { satisfied: false, approval: { status: "pending" } };
598
+ },
599
+ });
600
+ expect(seen).toEqual(["app reads a.id, which the first plan read as another value"]);
601
+ });
602
+
460
603
  test("an upstream new in the same pull request plans, and so does its dependent", async () => {
461
604
  const pair: DriverComponent[] = [root("vpc"), root("svc", ["vpc"], { vpc: { stackOutput: { stack: "vpc", name: "id" } } })];
462
605
  const w = world();
@@ -889,6 +1032,85 @@ describe("planCoveredBy", () => {
889
1032
  });
890
1033
  });
891
1034
 
1035
+ /**
1036
+ * One decision for a round, a resume and a wave (#4422). The same estate and
1037
+ * the same two outcomes go through `applyPrSet`'s round, `resumePrSet` and
1038
+ * `waveNotCovered` (what `--wave-gate` uses), and each path must give the same
1039
+ * verdict with the same reason.
1040
+ */
1041
+ describe("one coverage decision on a round, a resume and a wave (#4422)", () => {
1042
+ const plan = () => planFanOut({ components: ESTATE, changed: ["a"] });
1043
+ const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
1044
+ /** `a` v2, whose id is known only after apply; `app` reads it. */
1045
+ async function first(w: ReturnType<typeof world>) {
1046
+ w.versions.a = "v2";
1047
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
1048
+ return planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
1049
+ }
1050
+
1051
+ const paths: Record<string, (extra: boolean) => Promise<string | undefined>> = {
1052
+ round: async (extra) => {
1053
+ const w = world();
1054
+ const set = await first(w);
1055
+ w.bumpAppOnApply = extra;
1056
+ let reason: string | undefined;
1057
+ const applied = await applyPrSet({
1058
+ components: ESTATE,
1059
+ plan: plan(),
1060
+ registry: w.registry,
1061
+ env: "prod",
1062
+ set,
1063
+ approval: approvedFirst,
1064
+ decideRound: async ({ notCovered }) => {
1065
+ reason = notCovered;
1066
+ return { satisfied: false, approval: { status: "pending" } };
1067
+ },
1068
+ });
1069
+ expect(applied.status).toBe(extra ? "waiting" : "applied");
1070
+ return reason;
1071
+ },
1072
+ resume: async (extra) => {
1073
+ const w = world();
1074
+ const set = await first(w);
1075
+ w.state.a = { id: "a-v2" };
1076
+ if (extra) w.versions.app = "v2";
1077
+ const resumed = await resumePrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", record: recordOf(set), completed: ["a"] });
1078
+ return resumed.ok ? undefined : resumed.reasons[0];
1079
+ },
1080
+ wave: async (extra) => {
1081
+ const w = world();
1082
+ const set = await first(w);
1083
+ if (extra) w.versions.app = "v2";
1084
+ const wavePlan = prWavePlan(plan(), ESTATE, 2);
1085
+ const fresh = await planPrSet({ components: ESTATE, plan: wavePlan, registry: w.registry, env: "prod", seededOutputs: { ...set.outputs, a: { id: "a-v2" } } });
1086
+ return waveNotCovered(set.doc, fresh.doc, set.members)[0];
1087
+ },
1088
+ };
1089
+
1090
+ test.each([
1091
+ ["only the previewed unknown filled in", false, undefined],
1092
+ ["an entry the first plan did not show", true, "app now plans to update terraform_data.x, which the first plan did not"],
1093
+ ] as const)("%s", async (_name, extra, expected) => {
1094
+ for (const [path, decide] of Object.entries(paths)) {
1095
+ expect({ path, reason: await decide(extra) }).toEqual({ path, reason: expected });
1096
+ }
1097
+ });
1098
+
1099
+ test("a member that was a hole with no preview is not covered by an approval that never saw its shape", () => {
1100
+ const part = (digest: string, entries: ChangeSetEntry[], holes: ChangeSetPart["member"]["holes"]): ChangeSetPart => ({
1101
+ member: { member: "svc", lexicon: "aws", planner: "chant", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes },
1102
+ entries,
1103
+ });
1104
+ const approved = composeChangeSet([part("1", [], [{ address: "svc", reason: "inputs from net; re-checked on apply" }])]);
1105
+ const entry: ChangeSetEntry = { member: "svc", lexicon: "aws", planner: "chant", address: "Queue", type: "AWS::SQS::Queue", action: "create", attributes: [] };
1106
+ const fresh = composeChangeSet([part("2", [entry], [])]);
1107
+ const members = [{ member: "svc", component: "svc", planDigest: null, status: "planned" as const, counts: { create: 0, update: 0, replace: 0, delete: 0 }, unknownInputs: ["net.id"] }];
1108
+ // Even with the input named unknown, nothing was previewed to cover a create.
1109
+ expect(waveNotCovered(approved, fresh, members)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
1110
+ expect(waveNotCovered(approved, fresh)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
1111
+ });
1112
+ });
1113
+
892
1114
  describe("a gate per wave on merge (#4003)", () => {
893
1115
  const estate = [root("net"), root("a", ["net"]), root("b", ["net"]), root("app", ["a"])];
894
1116
  const plan = planFanOut({ components: estate, changed: ["net"] });
@@ -942,3 +1164,505 @@ describe("a gate per wave on merge (#4003)", () => {
942
1164
  expect(moved[0]).toMatch(/^job now plans to jcs1-sha256:2+, not the approved jcs1-sha256:1+, and its planner describes no changes to compare$/);
943
1165
  });
944
1166
  });
1167
+
1168
+ describe("a group planned and applied as one (ws-107, #4440)", () => {
1169
+ interface StackInput {
1170
+ root: string;
1171
+ vars?: Record<string, unknown>;
1172
+ }
1173
+ const so = (stack: string, name: string) => ({ stackOutput: { stack, name } });
1174
+ const stack = (name: string, dependsOn: string[] = [], vars?: Record<string, unknown>): DriverComponent => ({
1175
+ name,
1176
+ dependsOn,
1177
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-stack", root: name, ...(vars ? { vars } : {}) }] }],
1178
+ });
1179
+ const plainStack = (name: string, dependsOn: string[] = [], vars?: Record<string, unknown>): DriverComponent => ({
1180
+ name,
1181
+ dependsOn,
1182
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-plain", root: name, ...(vars ? { vars } : {}) }] }],
1183
+ });
1184
+ /** a; b reads a's new id; c reads b's. */
1185
+ const ABC = [stack("a"), stack("b", ["a"], { upstream: so("a", "id") }), stack("c", ["b"], { upstream: so("b", "id") })];
1186
+
1187
+ /**
1188
+ * `fake-stack` creates one resource and exposes an `id` known only after
1189
+ * it applies. With `group`, it declares `planGroup` and `runGroup`;
1190
+ * `fallback` adds a `groupFallback`. `fake-plain` never groups, and its
1191
+ * plan knows the `id` it will expose.
1192
+ */
1193
+ function groupWorld(opts: { group?: boolean; fallback?: GroupFallback } = {}) {
1194
+ const w = {
1195
+ registry: new CapabilityRegistry(),
1196
+ planned: [] as string[],
1197
+ applied: [] as string[],
1198
+ planGroupCalls: [] as string[][],
1199
+ runGroupCalls: [] as Array<{ components: string[]; run: GroupRun; members: GroupMember[] }>,
1200
+ planThrows: undefined as unknown,
1201
+ runThrows: undefined as unknown,
1202
+ outcome: undefined as ((members: readonly GroupMember[]) => CapabilityGroupOutcome) | undefined,
1203
+ /** Roots whose plan also creates `res.extra`, a change no earlier plan showed. */
1204
+ extra: new Set<string>(),
1205
+ };
1206
+ const planOf = (input: StackInput, known: boolean) => {
1207
+ const planDigest = computePlanDigest("fake-stack", { root: input.root, vars: input.vars ?? null });
1208
+ const outputs: PlannedOutputs = known ? { known: { id: `${input.root}-id` }, unknown: [] } : { known: {}, unknown: ["id"] };
1209
+ return {
1210
+ member: input.root,
1211
+ planDigest,
1212
+ artifact: { planDigest },
1213
+ outputs,
1214
+ changeSet: {
1215
+ member: { member: input.root, lexicon: "test", planner: "tofu" as const, status: "planned" as const, planDigest, holes: [] },
1216
+ entries: [
1217
+ { member: input.root, lexicon: "test", planner: "tofu" as const, address: "res.x", type: "res", action: "create" as const, attributes: [] },
1218
+ ...(w.extra.has(input.root)
1219
+ ? [{ member: input.root, lexicon: "test", planner: "tofu" as const, address: "res.extra", type: "res", action: "create" as const, attributes: [] }]
1220
+ : []),
1221
+ ],
1222
+ },
1223
+ };
1224
+ };
1225
+ w.registry.register({
1226
+ kind: "fake-stack",
1227
+ async plan(_ctx: DeployContext, input: StackInput) {
1228
+ w.planned.push(input.root);
1229
+ return planOf(input, false);
1230
+ },
1231
+ async run(_ctx: DeployContext, input: StackInput) {
1232
+ w.applied.push(input.root);
1233
+ return { outputs: { id: `${input.root}-id` } };
1234
+ },
1235
+ ...(opts.group
1236
+ ? {
1237
+ async planGroup(_ctx: GroupContext, members: readonly GroupMember<StackInput>[]) {
1238
+ w.planGroupCalls.push(members.map((m) => m.component));
1239
+ if (w.planThrows) throw w.planThrows;
1240
+ return {
1241
+ members: Object.fromEntries(members.map((m) => [m.component, planOf(m.input!, false)])),
1242
+ digest: computePlanDigest("fake-composition", members.map((m) => m.component)),
1243
+ artifact: { composed: members.map((m) => m.component) },
1244
+ };
1245
+ },
1246
+ async runGroup(_ctx: GroupContext, run: GroupRun, members: readonly GroupMember<StackInput>[]) {
1247
+ w.runGroupCalls.push({ components: members.map((m) => m.component), run, members: [...members] });
1248
+ if (w.runThrows) throw w.runThrows;
1249
+ if (w.outcome) return w.outcome(members);
1250
+ return { members: Object.fromEntries(members.map((m) => [m.component, { status: "applied" as const, outputs: { id: `${m.component}-id` } }])) };
1251
+ },
1252
+ }
1253
+ : {}),
1254
+ ...(opts.fallback ? { groupFallback: async () => opts.fallback } : {}),
1255
+ } as never);
1256
+ w.registry.register({
1257
+ kind: "fake-plain",
1258
+ async plan(_ctx: DeployContext, input: StackInput) {
1259
+ w.planned.push(input.root);
1260
+ const p = planOf(input, true);
1261
+ return { ...p, changeSet: { member: p.changeSet.member, entries: [] } };
1262
+ },
1263
+ async run(_ctx: DeployContext, input: StackInput) {
1264
+ w.applied.push(input.root);
1265
+ return { outputs: { id: `${input.root}-id` } };
1266
+ },
1267
+ } as never);
1268
+ return w;
1269
+ }
1270
+
1271
+ /** The fan-out `pr.ts`'s prepare step derives: planFanOut, then the groups placed. */
1272
+ async function prepared(components: DriverComponent[], changed: string[], registry: CapabilityRegistry): Promise<FanOutPlan> {
1273
+ const plan = planFanOut({ components, changed });
1274
+ return groupFanOut(plan, components, await findComponentGroups({ components, plan, registry, env: "prod" }));
1275
+ }
1276
+
1277
+ const approvedBy = async (digest: string) =>
1278
+ approvalOf(await decidePrGate(memoryGateLedgerPort({ resolutions: [approval(digest)] }), { op: "pr-12", gate: "pr-apply", digest, now: NOW }));
1279
+
1280
+ test("A, B reading A's new output and C reading B's: one planGroup call, one digest, no unknownInputs, one runGroup call under one approval", async () => {
1281
+ const w = groupWorld({ group: true });
1282
+ const plan = await prepared(ABC, ["a"], w.registry);
1283
+ expect(plan.groups).toEqual([{ capability: "fake-stack", components: ["a", "b", "c"] }]);
1284
+ expect(plan.waves).toEqual([["a", "b", "c"], [], []]);
1285
+
1286
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1287
+ expect(w.planGroupCalls).toEqual([["a", "b", "c"]]);
1288
+ expect(w.planned).toEqual([]);
1289
+ expect(set.failed).toBe(false);
1290
+ expect(set.members.map((m) => [m.member, m.status, m.unknownInputs])).toEqual([
1291
+ ["a", "planned", undefined],
1292
+ ["b", "planned", undefined],
1293
+ ["c", "planned", undefined],
1294
+ ]);
1295
+ // The report still draws the line from b to a, unknown at plan.
1296
+ expect(set.members.find((m) => m.member === "b")?.reads).toEqual([{ upstream: "a", outputs: ["id"], unknown: ["id"] }]);
1297
+ const groupDigest = set.groups?.[0]?.digest;
1298
+ expect(groupDigest).toEqual(expect.stringMatching(/sha256:/));
1299
+ expect(set.doc.members.map((m) => [m.member, m.group])).toEqual([
1300
+ ["a", { digest: groupDigest, reads: [] }],
1301
+ ["b", { digest: groupDigest, reads: ["a.id"] }],
1302
+ ["c", { digest: groupDigest, reads: ["b.id"] }],
1303
+ ]);
1304
+ // The member's digest binds the group's; the document's binds every member's.
1305
+ expect(set.doc.members.find((m) => m.member === "a")?.planDigest).toBe(
1306
+ computePlanDigest("capability-group-member", { plan: computePlanDigest("fake-stack", { root: "a", vars: null }), group: groupDigest }),
1307
+ );
1308
+ expect(set.doc.digest).toBe(changeSetDocumentDigest(set.doc));
1309
+ // Ruling 4: unknown reads inside the group keep the change set from passing as empty.
1310
+ expect(isEmptyChangeSet(set.doc)).toBe(false);
1311
+
1312
+ const firstApproval = await approvedBy(set.doc.digest);
1313
+ expect(firstApproval.status).toBe("approved");
1314
+ let asked = 0;
1315
+ const applied = await applyPrSet({
1316
+ components: ABC,
1317
+ plan,
1318
+ registry: w.registry,
1319
+ env: "prod",
1320
+ set,
1321
+ approval: firstApproval,
1322
+ decideRound: async () => {
1323
+ asked++;
1324
+ return { satisfied: false, approval: { status: "pending" } };
1325
+ },
1326
+ });
1327
+ expect(w.runGroupCalls.map((c) => c.components)).toEqual([["a", "b", "c"]]);
1328
+ expect(w.applied).toEqual([]);
1329
+ expect(asked).toBe(0);
1330
+ expect(applied.rounds).toBeUndefined();
1331
+ expect(applied.status).toBe("applied");
1332
+ expect(applied.members.map((m) => [m.member, m.status])).toEqual([
1333
+ ["a", "applied"],
1334
+ ["b", "applied"],
1335
+ ["c", "applied"],
1336
+ ]);
1337
+ // runGroup got the bound group digest, the artifact planGroup gave, and b's read of a left out of its input.
1338
+ const call = w.runGroupCalls[0]!;
1339
+ expect(call.run.digest).toBe(groupDigest);
1340
+ expect(call.run.artifact).toEqual({ composed: ["a", "b", "c"] });
1341
+ expect(call.members[1]).toMatchObject({ component: "b", input: { root: "b", vars: {} }, reads: [{ at: "/vars/upstream", ref: "a.id", component: "a", output: "id" }] });
1342
+ expect(applied.run.componentOutputs.c).toEqual({ id: "c-id" });
1343
+ });
1344
+
1345
+ test("the same three without planGroup plan and apply as on main: one by one, B and C wait on unknown inputs for a round", async () => {
1346
+ const w = groupWorld();
1347
+ const derived = planFanOut({ components: ABC, changed: ["a"] });
1348
+ const plan = await prepared(ABC, ["a"], w.registry);
1349
+ expect(plan).toEqual(derived);
1350
+
1351
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1352
+ expect(w.planGroupCalls).toEqual([]);
1353
+ expect(w.planned).toEqual(["a", "b", "c"]);
1354
+ expect(set.groups).toBeUndefined();
1355
+ expect(set.doc.members.every((m) => m.group === undefined)).toBe(true);
1356
+ expect(set.members.map((m) => [m.member, m.unknownInputs])).toEqual([
1357
+ ["a", undefined],
1358
+ ["b", ["a.id"]],
1359
+ ["c", ["b.id"]],
1360
+ ]);
1361
+ const applied = await applyPrSet({
1362
+ components: ABC,
1363
+ plan,
1364
+ registry: w.registry,
1365
+ env: "prod",
1366
+ set,
1367
+ approval: await approvedBy(set.doc.digest),
1368
+ decideRound: async () => ({ satisfied: false, approval: { status: "pending" } }),
1369
+ });
1370
+ expect(w.runGroupCalls).toEqual([]);
1371
+ expect(w.applied[0]).toBe("a");
1372
+ const round = applied.rounds?.[0];
1373
+ expect(round?.round).toBe(2);
1374
+ expect(round?.covered !== undefined || round?.status === "waiting").toBe(true);
1375
+ });
1376
+
1377
+ test("a capability that refuses the set says why; the members plan one by one and the reason is in the plan set", async () => {
1378
+ const w = groupWorld({ group: true, fallback: { kind: "too-many", reason: "fake-stack composes two at most" } });
1379
+ const plan = await prepared(ABC, ["a"], w.registry);
1380
+ expect(plan.groups).toBeUndefined();
1381
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1382
+ expect(w.planGroupCalls).toEqual([]);
1383
+ expect(w.planned).toEqual(["a", "b", "c"]);
1384
+ expect(set.fallbacks).toEqual(
1385
+ ["a", "b", "c"].map((component) => ({ component, capability: "fake-stack", kind: "too-many", reason: "fake-stack composes two at most" })),
1386
+ );
1387
+ expect(set.members.find((m) => m.member === "b")?.unknownInputs).toEqual(["a.id"]);
1388
+ });
1389
+
1390
+ test("two plans of one group on one estate give one digest; a group with one more member gives another", async () => {
1391
+ const w = groupWorld({ group: true });
1392
+ const plan = await prepared(ABC, ["a"], w.registry);
1393
+ const first = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1394
+ const again = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1395
+ expect(again.doc.digest).toBe(first.doc.digest);
1396
+ expect(again.groups?.[0]?.digest).toBe(first.groups?.[0]?.digest);
1397
+
1398
+ const ABCE = [...ABC, stack("e", ["c"], { upstream: so("c", "id") })];
1399
+ const wider = await prepared(ABCE, ["a"], w.registry);
1400
+ expect(wider.groups).toEqual([{ capability: "fake-stack", components: ["a", "b", "c", "e"] }]);
1401
+ const more = await planPrSet({ components: ABCE, plan: wider, registry: w.registry, env: "prod" });
1402
+ expect(more.groups?.[0]?.digest).not.toBe(first.groups?.[0]?.digest);
1403
+ // a plans the same on its own, and still its digest moves with the group's.
1404
+ const a = (s: PrPlanSet) => s.doc.members.find((m) => m.member === "a")?.planDigest;
1405
+ expect(a(more)).not.toBe(a(first));
1406
+ expect(more.doc.digest).not.toBe(first.doc.digest);
1407
+ });
1408
+
1409
+ test("a runGroup that applies A, fails on B and never starts C: A applied, B failed, C not-started, and what depends on C outside the group is blocked by it", async () => {
1410
+ const w = groupWorld({ group: true });
1411
+ w.outcome = () => ({
1412
+ members: {
1413
+ a: { status: "applied", outputs: { id: "a-id" } },
1414
+ b: { status: "failed", error: "b: quota exceeded" },
1415
+ c: { status: "not-started" },
1416
+ },
1417
+ });
1418
+ const components = [...ABC, plainStack("d", ["c"], { upstream: so("c", "id") })];
1419
+ const plan = await prepared(components, ["a"], w.registry);
1420
+ expect(plan.waves).toEqual([["a", "b", "c"], [], [], ["d"]]);
1421
+ const set = await planPrSet({ components, plan, registry: w.registry, env: "prod" });
1422
+ const applied = await applyPrSet({ components, plan, registry: w.registry, env: "prod", set, approval: await approvedBy(set.doc.digest) });
1423
+ expect(applied.status).toBe("failed");
1424
+ expect(Object.fromEntries(applied.members.map((m) => [m.member, m.status]))).toEqual({ a: "applied", b: "failed", c: "not-started", d: "blocked" });
1425
+ expect(applied.members.find((m) => m.member === "b")?.error).toBe("b: quota exceeded");
1426
+ expect(applied.members.find((m) => m.member === "d")?.blockedBy).toBe("c");
1427
+ expect(applied.run.notStarted).toEqual(["c"]);
1428
+ expect(applied.run.componentOutputs.a).toEqual({ id: "a-id" });
1429
+ expect(w.applied).toEqual([]);
1430
+ // pr-apply exits with the failed run's code.
1431
+ expect(failedRunExitCode(applied.run.results)).toBe(1);
1432
+ });
1433
+
1434
+ test("a runGroup that throws a moved plan leaves every member not-started and exits 3; any other throw fails every member", async () => {
1435
+ const moved = groupWorld({ group: true });
1436
+ moved.runThrows = Object.assign(new Error("the target moved since it was planned"), { planMoved: true });
1437
+ const plan = await prepared(ABC, ["a"], moved.registry);
1438
+ const set = await planPrSet({ components: ABC, plan, registry: moved.registry, env: "prod" });
1439
+ const stopped = await applyPrSet({ components: ABC, plan, registry: moved.registry, env: "prod", set });
1440
+ expect(stopped.members.map((m) => m.status)).toEqual(["not-started", "not-started", "not-started"]);
1441
+ expect(failedRunExitCode(stopped.run.results)).toBe(3);
1442
+
1443
+ const broken = groupWorld({ group: true });
1444
+ broken.runThrows = new Error("connection reset");
1445
+ const set2 = await planPrSet({ components: ABC, plan, registry: broken.registry, env: "prod" });
1446
+ const failed = await applyPrSet({ components: ABC, plan, registry: broken.registry, env: "prod", set: set2 });
1447
+ expect(failed.members.map((m) => [m.status, m.error])).toEqual([
1448
+ ["failed", "connection reset"],
1449
+ ["failed", "connection reset"],
1450
+ ["failed", "connection reset"],
1451
+ ]);
1452
+ expect(failedRunExitCode(failed.run.results)).toBe(1);
1453
+ });
1454
+
1455
+ test("a planGroup that throws makes every member plan-failed, and nothing falls back", async () => {
1456
+ const w = groupWorld({ group: true });
1457
+ w.planThrows = new Error("cannot compose: backends differ");
1458
+ const plan = await prepared(ABC, ["a"], w.registry);
1459
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1460
+ expect(set.failed).toBe(true);
1461
+ expect(w.planned).toEqual([]);
1462
+ expect(set.members.map((m) => [m.member, m.status, m.error])).toEqual([
1463
+ ["a", "plan-failed", "cannot compose: backends differ"],
1464
+ ["b", "plan-failed", "cannot compose: backends differ"],
1465
+ ["c", "plan-failed", "cannot compose: backends differ"],
1466
+ ]);
1467
+ expect(set.groups).toEqual([{ capability: "fake-stack", components: ["a", "b", "c"], digest: null, error: "cannot compose: backends differ" }]);
1468
+ });
1469
+
1470
+ test("under --wave-gate the group is part of the wave it was placed in, and the wave's digest covers it", async () => {
1471
+ const w = groupWorld({ group: true });
1472
+ const components = [plainStack("x"), stack("a", ["x"], { upstream: so("x", "id") }), stack("b", ["a"], { upstream: so("a", "id") }), stack("c", ["b"], { upstream: so("b", "id") })];
1473
+ const plan = await prepared(components, ["x"], w.registry);
1474
+ expect(plan.waves).toEqual([["x"], ["a", "b", "c"], [], []]);
1475
+ const approved = await planPrSet({ components, plan, registry: w.registry, env: "prod" });
1476
+
1477
+ const wave = prWavePlan(plan, components, 2, ["x"]);
1478
+ expect(wave.groups).toEqual(plan.groups);
1479
+ const fresh = await planPrSet({ components, plan: wave, registry: w.registry, env: "prod", seededOutputs: approved.outputs });
1480
+ expect(w.planGroupCalls).toEqual([
1481
+ ["a", "b", "c"],
1482
+ ["a", "b", "c"],
1483
+ ]);
1484
+ expect(fresh.doc.members.map((m) => m.member)).toEqual(["a", "b", "c"]);
1485
+ expect(fresh.doc.members.map((m) => m.group?.digest)).toEqual(approved.doc.members.filter((m) => m.member !== "x").map((m) => m.group?.digest));
1486
+ expect(waveNotCovered(approved.doc, fresh.doc, approved.members, { approved: approved.shapes, fresh: fresh.shapes })).toEqual([]);
1487
+ // A wave whose group composed differently is not covered by the digest.
1488
+ const other = groupWorld({ group: true });
1489
+ const wider = [...components, stack("e", ["c"], { upstream: so("c", "id") })];
1490
+ const widerPlan = await prepared(wider, ["x"], other.registry);
1491
+ const widerWave = await planPrSet({ components: wider, plan: prWavePlan(widerPlan, wider, 2, ["x"]), registry: other.registry, env: "prod", seededOutputs: approved.outputs });
1492
+ const a = (s: PrPlanSet) => s.doc.members.find((m) => m.member === "a")?.planDigest;
1493
+ expect(a(widerWave)).not.toBe(a(approved));
1494
+ });
1495
+
1496
+ describe("the report names each group and member outcome, and a resume plans what is left (#4446)", () => {
1497
+ const at = { pr: 12, env: "prod", base: "1".repeat(40), head: "2".repeat(40), op: "pr-12", gate: "pr-apply" };
1498
+ const signal = { changed: ["a"], indeterminate: [], unclaimed: [] };
1499
+ /** A, B and C, where B fails and C never starts. */
1500
+ const failOnB = (w: ReturnType<typeof groupWorld>) => {
1501
+ w.outcome = () => ({
1502
+ members: {
1503
+ a: { status: "applied", outputs: { id: "a-id" } },
1504
+ b: { status: "failed", error: "b: quota exceeded" },
1505
+ c: { status: "not-started" },
1506
+ },
1507
+ });
1508
+ };
1509
+ /** The first attempt: plan, approve, apply with B failing. What a `--resume` record keeps. */
1510
+ async function failedAttempt(w: ReturnType<typeof groupWorld>) {
1511
+ failOnB(w);
1512
+ const plan = await prepared(ABC, ["a"], w.registry);
1513
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1514
+ const firstApproval = await approvedBy(set.doc.digest);
1515
+ const first = await applyPrSet({ components: ABC, plan, registry: w.registry, env: "prod", set, approval: firstApproval });
1516
+ w.outcome = undefined;
1517
+ return { plan, set, first, firstApproval, record: recordOf(set) };
1518
+ }
1519
+
1520
+ test("a plan report for the three-member group lists one group with three members, its wave and the group digest", async () => {
1521
+ const w = groupWorld({ group: true });
1522
+ const plan = await prepared(ABC, ["a"], w.registry);
1523
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1524
+ const report = prReport({ stage: "plan", ...at, plan, signal, set, approval: { status: "pending" }, status: "planned" });
1525
+ expect(report.groups).toEqual([
1526
+ {
1527
+ capability: "fake-stack",
1528
+ components: ["a", "b", "c"],
1529
+ wave: 1,
1530
+ digest: set.groups![0]!.digest,
1531
+ reads: [
1532
+ { component: "b", ref: "a.id" },
1533
+ { component: "c", ref: "b.id" },
1534
+ ],
1535
+ },
1536
+ ]);
1537
+ expect(report.groups![0]!.digest).toEqual(expect.stringMatching(/sha256:/));
1538
+ expect(report.fallbacks).toBeUndefined();
1539
+ expect(report.members.map((m) => [m.member, m.group, m.groupOutcome])).toEqual([
1540
+ ["a", 0, undefined],
1541
+ ["b", 0, undefined],
1542
+ ["c", 0, undefined],
1543
+ ]);
1544
+ // The run's artifact stays in the process that planned.
1545
+ expect(JSON.stringify(report.groups)).not.toContain("composed");
1546
+ const note = renderPrNote(report);
1547
+ expect(note).toContain("`a`, `b` and `c` plan and apply as one (`fake-stack`, wave 1); `b` reads `a.id` and `c` reads `b.id`, known when the group applies.");
1548
+ });
1549
+
1550
+ test("an apply report after a failure on B lists A applied, B failed, C not-started, and the note says plainly that A stays applied", async () => {
1551
+ const w = groupWorld({ group: true });
1552
+ const { plan, set, first, firstApproval } = await failedAttempt(w);
1553
+ const report = prReport({ stage: "apply", ...at, plan, signal, set, approval: firstApproval, status: first.status, members: first.members });
1554
+ expect(report.status).toBe("failed");
1555
+ expect(report.members.map((m) => [m.member, m.status, m.groupOutcome, m.group])).toEqual([
1556
+ ["a", "applied", "applied", 0],
1557
+ ["b", "failed", "failed", 0],
1558
+ ["c", "not-started", "not-started", 0],
1559
+ ]);
1560
+ const note = renderPrNote(report);
1561
+ expect(note).toContain("The apply failed: 1 failed, 1 not started, 1 applied.");
1562
+ expect(note).toContain("The group's apply stopped part way: `a` applied and stays applied; `b` failed; `c` did not start. Nothing that applied is undone.");
1563
+ expect(note).toContain("`pr-apply --resume` plans `b` and `c` again");
1564
+ });
1565
+
1566
+ test("pr-apply --resume on that record plans B and C only, as a smaller group, and applies them under the first approval", async () => {
1567
+ const w = groupWorld({ group: true });
1568
+ const { plan, set, first, firstApproval, record } = await failedAttempt(w);
1569
+ expect(first.run.completed).toEqual(["a"]);
1570
+ w.planGroupCalls.length = 0;
1571
+ w.runGroupCalls.length = 0;
1572
+
1573
+ const resumed = await resumePrSet({ components: ABC, plan, registry: w.registry, env: "prod", record, completed: first.run.completed, applied: { a: first.run.componentOutputs.a! } });
1574
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
1575
+ expect(resumed.plan.order).toEqual(["b", "c"]);
1576
+ expect(resumed.plan.groups).toEqual([{ capability: "fake-stack", components: ["b", "c"] }]);
1577
+ expect(w.planGroupCalls).toEqual([["b", "c"]]);
1578
+ expect(w.planned).toEqual([]);
1579
+ // The smaller group's digest is not the approved one, so coverage rested on entries.
1580
+ expect(resumed.fresh.groups?.[0]?.digest).not.toBe(set.groups?.[0]?.digest);
1581
+ expect(resumed.set.doc.digest).toBe(set.doc.digest);
1582
+ expect(resumed.set.groups?.map((g) => g.components)).toEqual([["b", "c"]]);
1583
+
1584
+ let asked = 0;
1585
+ const applied = await applyPrSet({
1586
+ components: ABC,
1587
+ plan: resumed.plan,
1588
+ registry: w.registry,
1589
+ env: "prod",
1590
+ set: resumed.set,
1591
+ approval: firstApproval,
1592
+ resumed: { completed: first.run.completed, outputs: { a: first.run.componentOutputs.a! } },
1593
+ decideRound: async () => {
1594
+ asked++;
1595
+ return { satisfied: false, approval: { status: "pending" } };
1596
+ },
1597
+ });
1598
+ expect(asked).toBe(0);
1599
+ expect(applied.rounds).toBeUndefined();
1600
+ expect(applied.status).toBe("applied");
1601
+ // B planned and applied against the id A applied with.
1602
+ expect(w.runGroupCalls.map((c) => c.components)).toEqual([["b", "c"]]);
1603
+ expect(w.runGroupCalls[0]!.members[0]).toMatchObject({ component: "b", input: { root: "b", vars: { upstream: "a-id" } } });
1604
+ expect(applied.members.map((m) => [m.member, m.status, m.groupOutcome])).toEqual([
1605
+ ["a", "applied", undefined],
1606
+ ["b", "applied", "applied"],
1607
+ ["c", "applied", "applied"],
1608
+ ]);
1609
+ const report = prReport({ stage: "apply", ...at, plan, signal, set: resumed.set, approval: firstApproval, status: applied.status, members: applied.members, resumed: first.run.completed });
1610
+ expect(report.groups?.map((g) => g.components)).toEqual([["b", "c"]]);
1611
+ expect(report.members.map((m) => [m.member, m.group])).toEqual([
1612
+ ["a", undefined],
1613
+ ["b", 0],
1614
+ ["c", 0],
1615
+ ]);
1616
+ });
1617
+
1618
+ test("a resume whose new plans the approved set does not cover carries nothing, and the new plan waits at its gate", async () => {
1619
+ const w = groupWorld({ group: true });
1620
+ const { plan, set, first, record } = await failedAttempt(w);
1621
+ w.extra.add("c");
1622
+ const resumed = await resumePrSet({ components: ABC, plan, registry: w.registry, env: "prod", record, completed: first.run.completed, applied: { a: first.run.componentOutputs.a! } });
1623
+ expect(resumed.ok).toBe(false);
1624
+ if (resumed.ok) return;
1625
+ expect(resumed.reasons.join("; ")).toMatch(/res\.extra/);
1626
+ // pr-apply then plans every member again, and that plan's digest has no approval of its own.
1627
+ const again = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1628
+ expect(again.doc.digest).not.toBe(set.doc.digest);
1629
+ const ledger = memoryGateLedgerPort({ resolutions: [approval(set.doc.digest)] });
1630
+ const check = await decidePrGate(ledger, { op: "pr-12", gate: "pr-apply", digest: again.doc.digest, now: NOW });
1631
+ expect(check.satisfied).toBe(false);
1632
+ expect(approvalOf(check).status).toBe("changed");
1633
+ });
1634
+
1635
+ test("a resume whose capability refuses the smaller group plans what is left one by one, and says why", async () => {
1636
+ const w = groupWorld({ group: true });
1637
+ const { plan, first, record } = await failedAttempt(w);
1638
+ // From here on the capability takes no group.
1639
+ const refusing = groupWorld({ group: true, fallback: { kind: "too-many", reason: "fake-stack composes three or none" } });
1640
+ const resumed = await resumePrSet({ components: ABC, plan, registry: refusing.registry, env: "prod", record, completed: first.run.completed, applied: { a: first.run.componentOutputs.a! } });
1641
+ expect(refusing.planGroupCalls).toEqual([]);
1642
+ expect(refusing.planned).toEqual(["b", "c"]);
1643
+ const fresh = resumed.fresh!;
1644
+ expect(fresh.groups).toBeUndefined();
1645
+ expect(fresh.fallbacks).toEqual(["b", "c"].map((component) => ({ component, capability: "fake-stack", kind: "too-many", reason: "fake-stack composes three or none" })));
1646
+ });
1647
+
1648
+ test("a set the capability refused is under fallbacks with its reason, and the note prints it; kind off is reported and not printed", async () => {
1649
+ const w = groupWorld({ group: true, fallback: { kind: "too-many", reason: "fake-stack composes two at most" } });
1650
+ const plan = await prepared(ABC, ["a"], w.registry);
1651
+ const set = await planPrSet({ components: ABC, plan, registry: w.registry, env: "prod" });
1652
+ const report = prReport({ stage: "plan", ...at, plan, signal, set, approval: { status: "pending" }, status: "planned" });
1653
+ expect(report.groups).toBeUndefined();
1654
+ expect(report.fallbacks).toEqual(
1655
+ ["a", "b", "c"].map((component) => ({ component, capability: "fake-stack", kind: "too-many", reason: "fake-stack composes two at most" })),
1656
+ );
1657
+ expect(report.members.every((m) => m.group === undefined)).toBe(true);
1658
+ expect(renderPrNote(report)).toContain("Planned one by one, not as a group: `a`, `b`, `c` (`too-many`): fake-stack composes two at most.");
1659
+
1660
+ const off = groupWorld({ group: true, fallback: { kind: "off", reason: "terraform.compose is not set" } });
1661
+ const offPlan = await prepared(ABC, ["a"], off.registry);
1662
+ const offSet = await planPrSet({ components: ABC, plan: offPlan, registry: off.registry, env: "prod" });
1663
+ const offReport = prReport({ stage: "plan", ...at, plan: offPlan, signal, set: offSet, approval: { status: "pending" }, status: "planned" });
1664
+ expect(offReport.fallbacks?.map((f) => f.kind)).toEqual(["off", "off", "off"]);
1665
+ expect(renderPrNote(offReport)).not.toContain("not as a group");
1666
+ });
1667
+ });
1668
+ });