@intentius/chant 0.124.40 → 0.124.42

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 (346) hide show
  1. package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
  2. package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
  3. package/dist/__fixtures__/oidc-emulator.js +89 -0
  4. package/dist/ci-handover.d.ts +117 -0
  5. package/dist/ci-handover.d.ts.map +1 -0
  6. package/dist/ci-handover.js +217 -0
  7. package/dist/ci-identity.d.ts +190 -0
  8. package/dist/ci-identity.d.ts.map +1 -0
  9. package/dist/ci-identity.js +219 -0
  10. package/dist/cli/handlers/approve.d.ts +2 -1
  11. package/dist/cli/handlers/approve.d.ts.map +1 -1
  12. package/dist/cli/handlers/approve.js +20 -6
  13. package/dist/cli/handlers/build.d.ts.map +1 -1
  14. package/dist/cli/handlers/build.js +18 -1
  15. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  16. package/dist/cli/handlers/fan-out.js +42 -5
  17. package/dist/cli/handlers/gate-resume.d.ts +3 -0
  18. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  19. package/dist/cli/handlers/gate-resume.js +15 -2
  20. package/dist/cli/handlers/lock.d.ts +8 -0
  21. package/dist/cli/handlers/lock.d.ts.map +1 -0
  22. package/dist/cli/handlers/lock.js +197 -0
  23. package/dist/cli/handlers/pr-env.d.ts +34 -0
  24. package/dist/cli/handlers/pr-env.d.ts.map +1 -0
  25. package/dist/cli/handlers/pr-env.js +436 -0
  26. package/dist/cli/handlers/pr.d.ts +76 -0
  27. package/dist/cli/handlers/pr.d.ts.map +1 -1
  28. package/dist/cli/handlers/pr.js +615 -55
  29. package/dist/cli/handlers/promote.d.ts.map +1 -1
  30. package/dist/cli/handlers/promote.js +9 -0
  31. package/dist/cli/handlers/query.d.ts +6 -0
  32. package/dist/cli/handlers/query.d.ts.map +1 -0
  33. package/dist/cli/handlers/query.js +80 -0
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/run.js +11 -0
  36. package/dist/cli/main.d.ts.map +1 -1
  37. package/dist/cli/main.js +49 -4
  38. package/dist/cli/parse-args.d.ts.map +1 -1
  39. package/dist/cli/parse-args.js +46 -2
  40. package/dist/cli/registry.d.ts +31 -2
  41. package/dist/cli/registry.d.ts.map +1 -1
  42. package/dist/components/capability.d.ts +47 -0
  43. package/dist/components/capability.d.ts.map +1 -1
  44. package/dist/components/capability.js +44 -0
  45. package/dist/components/cli-support.d.ts +6 -0
  46. package/dist/components/cli-support.d.ts.map +1 -1
  47. package/dist/components/cli-support.js +8 -1
  48. package/dist/components/driver.d.ts +10 -0
  49. package/dist/components/driver.d.ts.map +1 -1
  50. package/dist/components/driver.js +52 -2
  51. package/dist/components/fan-out-run.d.ts +37 -0
  52. package/dist/components/fan-out-run.d.ts.map +1 -1
  53. package/dist/components/fan-out-run.js +44 -4
  54. package/dist/components/gate-plan.d.ts +2 -0
  55. package/dist/components/gate-plan.d.ts.map +1 -1
  56. package/dist/components/gate-plan.js +1 -0
  57. package/dist/components/pr-environment.d.ts +298 -0
  58. package/dist/components/pr-environment.d.ts.map +1 -0
  59. package/dist/components/pr-environment.js +406 -0
  60. package/dist/components/pr-hooks.d.ts +66 -0
  61. package/dist/components/pr-hooks.d.ts.map +1 -0
  62. package/dist/components/pr-hooks.js +119 -0
  63. package/dist/components/pr-pipeline.d.ts +92 -2
  64. package/dist/components/pr-pipeline.d.ts.map +1 -1
  65. package/dist/components/pr-pipeline.js +150 -21
  66. package/dist/components/pr-policy.d.ts +109 -0
  67. package/dist/components/pr-policy.d.ts.map +1 -0
  68. package/dist/components/pr-policy.js +150 -0
  69. package/dist/components/pr-run.d.ts +6 -0
  70. package/dist/components/pr-run.d.ts.map +1 -1
  71. package/dist/components/pr-run.js +8 -3
  72. package/dist/components/wave-plan.d.ts +14 -1
  73. package/dist/components/wave-plan.d.ts.map +1 -1
  74. package/dist/components/wave-plan.js +34 -2
  75. package/dist/gate-decision.d.ts +15 -2
  76. package/dist/gate-decision.d.ts.map +1 -1
  77. package/dist/gate-decision.js +17 -2
  78. package/dist/gate-policy.d.ts +140 -0
  79. package/dist/gate-policy.d.ts.map +1 -0
  80. package/dist/gate-policy.js +242 -0
  81. package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
  82. package/dist/lifecycle/gate-checkpoint.js +1 -1
  83. package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
  84. package/dist/lifecycle/gate-seal.d.ts.map +1 -0
  85. package/dist/lifecycle/query.d.ts +68 -0
  86. package/dist/lifecycle/query.d.ts.map +1 -0
  87. package/dist/lifecycle/query.js +246 -0
  88. package/dist/lifecycle/run-ledger.d.ts +13 -0
  89. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  90. package/dist/lifecycle/run-ledger.js +16 -0
  91. package/dist/lifecycle/target-lock.d.ts +97 -0
  92. package/dist/lifecycle/target-lock.d.ts.map +1 -0
  93. package/dist/lifecycle/target-lock.js +286 -0
  94. package/dist/op/activities/activity-contracts.d.ts +30 -0
  95. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  96. package/dist/op/activities/activity-contracts.js +26 -0
  97. package/dist/op/activities/index.d.ts +2 -0
  98. package/dist/op/activities/index.d.ts.map +1 -1
  99. package/dist/op/activities/index.js +4 -0
  100. package/dist/op/activities/pr-refs.d.ts +10 -0
  101. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  102. package/dist/op/activities/pr-refs.js +26 -0
  103. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  104. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  105. package/dist/op/activities/predict-behaviour.js +2 -21
  106. package/dist/op/activities/review-agent.d.ts +137 -0
  107. package/dist/op/activities/review-agent.d.ts.map +1 -0
  108. package/dist/op/activities/review-agent.js +266 -0
  109. package/dist/op/builders.d.ts +9 -0
  110. package/dist/op/builders.d.ts.map +1 -1
  111. package/dist/op/builders.js +11 -0
  112. package/dist/op/cron.d.ts +8 -0
  113. package/dist/op/cron.d.ts.map +1 -1
  114. package/dist/op/cron.js +51 -0
  115. package/dist/op/gate-approval.d.ts +20 -13
  116. package/dist/op/gate-approval.d.ts.map +1 -1
  117. package/dist/op/gate-approval.js +13 -18
  118. package/dist/op/gate-quorum.d.ts +131 -0
  119. package/dist/op/gate-quorum.d.ts.map +1 -0
  120. package/dist/op/gate-quorum.js +183 -0
  121. package/dist/op/gate-resume.d.ts +4 -2
  122. package/dist/op/gate-resume.d.ts.map +1 -1
  123. package/dist/op/gate-resume.js +5 -4
  124. package/dist/op/gate-review.js +1 -1
  125. package/dist/op/gate.d.ts +32 -4
  126. package/dist/op/gate.d.ts.map +1 -1
  127. package/dist/op/gate.js +40 -24
  128. package/dist/op/index.d.ts +6 -4
  129. package/dist/op/index.d.ts.map +1 -1
  130. package/dist/op/index.js +4 -3
  131. package/dist/op/local-executor.d.ts.map +1 -1
  132. package/dist/op/local-executor.js +9 -3
  133. package/dist/op/op-waves-run.d.ts +28 -43
  134. package/dist/op/op-waves-run.d.ts.map +1 -1
  135. package/dist/op/op-waves-run.js +170 -120
  136. package/dist/op/op-waves.d.ts +55 -3
  137. package/dist/op/op-waves.d.ts.map +1 -1
  138. package/dist/op/op-waves.js +63 -8
  139. package/dist/op/schedule-overdue.d.ts +57 -0
  140. package/dist/op/schedule-overdue.d.ts.map +1 -0
  141. package/dist/op/schedule-overdue.js +138 -0
  142. package/dist/op/stage-hooks.d.ts +132 -0
  143. package/dist/op/stage-hooks.d.ts.map +1 -0
  144. package/dist/op/stage-hooks.js +142 -0
  145. package/dist/override-gate.d.ts +2 -2
  146. package/dist/override-gate.d.ts.map +1 -1
  147. package/dist/override-gate.js +1 -1
  148. package/dist/pr-forge.d.ts +64 -0
  149. package/dist/pr-forge.d.ts.map +1 -1
  150. package/dist/pr-forge.js +331 -44
  151. package/dist/pr-loop.d.ts +107 -1
  152. package/dist/pr-loop.d.ts.map +1 -1
  153. package/dist/pr-loop.js +84 -3
  154. package/dist/pr-post.d.ts +72 -0
  155. package/dist/pr-post.d.ts.map +1 -0
  156. package/dist/pr-post.js +134 -0
  157. package/dist/pr-requires.d.ts +38 -0
  158. package/dist/pr-requires.d.ts.map +1 -0
  159. package/dist/pr-requires.js +48 -0
  160. package/dist/wave-blocked.d.ts +79 -0
  161. package/dist/wave-blocked.d.ts.map +1 -0
  162. package/dist/wave-blocked.js +245 -0
  163. package/dist/wave-engine.d.ts +6 -0
  164. package/dist/wave-engine.d.ts.map +1 -1
  165. package/dist/wave-engine.js +2 -0
  166. package/dist/wave-runtime.mjs +243 -243
  167. package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
  168. package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
  169. package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
  170. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  171. package/dist/workspace/checks/ci-workflow.js +19 -4
  172. package/dist/workspace/ci-branches.d.ts +51 -0
  173. package/dist/workspace/ci-branches.d.ts.map +1 -0
  174. package/dist/workspace/ci-branches.js +69 -0
  175. package/dist/workspace/ci-cloud-identity.d.ts +11 -0
  176. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
  177. package/dist/workspace/ci-cloud-identity.js +24 -0
  178. package/dist/workspace/ci-init.d.ts.map +1 -1
  179. package/dist/workspace/ci-init.js +5 -4
  180. package/dist/workspace/ci-locks.d.ts +12 -0
  181. package/dist/workspace/ci-locks.d.ts.map +1 -0
  182. package/dist/workspace/ci-locks.js +6 -0
  183. package/dist/workspace/ci-run.d.ts +17 -1
  184. package/dist/workspace/ci-run.d.ts.map +1 -1
  185. package/dist/workspace/ci-run.js +66 -4
  186. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  187. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  188. package/dist/workspace/ci-workflow-at.js +17 -0
  189. package/dist/workspace/ci-workflow.d.ts +11 -3
  190. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  191. package/dist/workspace/ci-workflow.js +10 -2
  192. package/dist/workspace/declaration.d.ts +69 -3
  193. package/dist/workspace/declaration.d.ts.map +1 -1
  194. package/dist/workspace/declaration.js +72 -1
  195. package/dist/workspace/declaration.schema.json +149 -2
  196. package/dist/workspace/identity-gate-rule.d.ts +1 -1
  197. package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
  198. package/dist/workspace/identity-gate-rule.js +1 -1
  199. package/dist/workspace/identity.d.ts +1 -1
  200. package/dist/workspace/identity.d.ts.map +1 -1
  201. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  202. package/dist/workspace/lineage-upgrade.js +8 -5
  203. package/dist/workspace/member-pipeline.d.ts +6 -0
  204. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  205. package/dist/workspace/member-pipeline.js +23 -0
  206. package/dist/workspace/pr-report.schema.json +94 -3
  207. package/dist/workspace/reason-codes.d.ts +1 -1
  208. package/dist/workspace/reason-codes.d.ts.map +1 -1
  209. package/dist/workspace/reason-codes.js +1 -1
  210. package/dist/workspace/security.d.ts.map +1 -1
  211. package/dist/workspace/security.js +4 -0
  212. package/dist/workspace/status-gates.d.ts +3 -2
  213. package/dist/workspace/status-gates.d.ts.map +1 -1
  214. package/dist/workspace/status-gates.js +8 -7
  215. package/dist/workspace/status.d.ts.map +1 -1
  216. package/dist/workspace/status.js +4 -1
  217. package/dist/workspace/trust/seal.d.ts +2 -2
  218. package/dist/workspace/trust/seal.d.ts.map +1 -1
  219. package/dist/workspace/trust/seal.js +2 -2
  220. package/package.json +1 -1
  221. package/src/__fixtures__/oidc-emulator.ts +118 -0
  222. package/src/ci-handover.test.ts +209 -0
  223. package/src/ci-handover.ts +237 -0
  224. package/src/ci-identity.test.ts +201 -0
  225. package/src/ci-identity.ts +310 -0
  226. package/src/cli/handlers/approve.ts +18 -5
  227. package/src/cli/handlers/build.ts +18 -1
  228. package/src/cli/handlers/fan-out.ts +43 -6
  229. package/src/cli/handlers/gate-resume.ts +16 -2
  230. package/src/cli/handlers/lock.test.ts +98 -0
  231. package/src/cli/handlers/lock.ts +202 -0
  232. package/src/cli/handlers/operator.test.ts +3 -1
  233. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  234. package/src/cli/handlers/pr-env.ts +462 -0
  235. package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
  236. package/src/cli/handlers/pr.test.ts +184 -0
  237. package/src/cli/handlers/pr.ts +605 -61
  238. package/src/cli/handlers/promote.ts +9 -0
  239. package/src/cli/handlers/query.ts +78 -0
  240. package/src/cli/handlers/run.ts +10 -0
  241. package/src/cli/main.ts +49 -4
  242. package/src/cli/parse-args.ts +34 -2
  243. package/src/cli/registry.ts +31 -2
  244. package/src/components/capability-targets.test.ts +162 -0
  245. package/src/components/capability.ts +71 -0
  246. package/src/components/cli-support.ts +14 -1
  247. package/src/components/driver.ts +54 -3
  248. package/src/components/fan-out-run.ts +58 -3
  249. package/src/components/fan-out-waves.test.ts +45 -1
  250. package/src/components/gate-plan.ts +3 -0
  251. package/src/components/pr-environment.test.ts +249 -0
  252. package/src/components/pr-environment.ts +538 -0
  253. package/src/components/pr-hooks.test.ts +118 -0
  254. package/src/components/pr-hooks.ts +157 -0
  255. package/src/components/pr-pipeline.ts +194 -21
  256. package/src/components/pr-policy.test.ts +145 -0
  257. package/src/components/pr-policy.ts +211 -0
  258. package/src/components/pr-run.ts +14 -2
  259. package/src/components/wave-plan.ts +38 -3
  260. package/src/gate-decision-bundle.test.ts +4 -3
  261. package/src/gate-decision.ts +39 -2
  262. package/src/gate-policy-bundle.test.ts +28 -0
  263. package/src/gate-policy.test.ts +162 -0
  264. package/src/gate-policy.ts +298 -0
  265. package/src/lifecycle/gate-checkpoint.ts +1 -1
  266. package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
  267. package/src/lifecycle/query.test.ts +91 -0
  268. package/src/lifecycle/query.ts +315 -0
  269. package/src/lifecycle/run-ledger.ts +18 -0
  270. package/src/lifecycle/target-lock.test.ts +195 -0
  271. package/src/lifecycle/target-lock.ts +347 -0
  272. package/src/op/activities/activity-contracts.ts +31 -0
  273. package/src/op/activities/index.ts +6 -0
  274. package/src/op/activities/pr-refs.ts +28 -0
  275. package/src/op/activities/predict-behaviour.ts +3 -23
  276. package/src/op/activities/review-agent.test.ts +240 -0
  277. package/src/op/activities/review-agent.ts +355 -0
  278. package/src/op/builders.ts +13 -0
  279. package/src/op/cron.ts +44 -0
  280. package/src/op/gate-alias.test.ts +2 -2
  281. package/src/op/gate-approval.test.ts +30 -9
  282. package/src/op/gate-approval.ts +23 -26
  283. package/src/op/gate-quorum.test.ts +139 -0
  284. package/src/op/gate-quorum.ts +248 -0
  285. package/src/op/gate-resume.ts +6 -3
  286. package/src/op/gate-review.ts +1 -1
  287. package/src/op/gate.ts +64 -23
  288. package/src/op/index.ts +13 -1
  289. package/src/op/local-executor.ts +9 -2
  290. package/src/op/op-waves-hook-targets.test.ts +126 -0
  291. package/src/op/op-waves-policy-hold.test.ts +66 -0
  292. package/src/op/op-waves-quorum.test.ts +101 -0
  293. package/src/op/op-waves-run.ts +197 -183
  294. package/src/op/op-waves.ts +83 -10
  295. package/src/op/schedule-overdue.test.ts +220 -0
  296. package/src/op/schedule-overdue.ts +161 -0
  297. package/src/op/stage-hooks.ts +251 -0
  298. package/src/override-gate-bundle.test.ts +1 -1
  299. package/src/override-gate.ts +2 -2
  300. package/src/pr-forge-bundle.test.ts +2 -2
  301. package/src/pr-forge.test.ts +33 -0
  302. package/src/pr-forge.ts +347 -35
  303. package/src/pr-loop-bundle.test.ts +2 -2
  304. package/src/pr-loop.test.ts +58 -0
  305. package/src/pr-loop.ts +170 -4
  306. package/src/pr-post.test.ts +141 -0
  307. package/src/pr-post.ts +159 -0
  308. package/src/pr-requires.test.ts +184 -0
  309. package/src/pr-requires.ts +79 -0
  310. package/src/review-agent-bundle.test.ts +35 -0
  311. package/src/stage-hooks-bundle.test.ts +38 -0
  312. package/src/wave-blocked-bundle.test.ts +25 -0
  313. package/src/wave-blocked.test.ts +146 -0
  314. package/src/wave-blocked.ts +271 -0
  315. package/src/wave-engine.ts +8 -0
  316. package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
  317. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  318. package/src/workspace/checks/ci-workflow.ts +18 -4
  319. package/src/workspace/ci-branches.test.ts +62 -0
  320. package/src/workspace/ci-branches.ts +79 -0
  321. package/src/workspace/ci-cloud-identity.test.ts +53 -0
  322. package/src/workspace/ci-cloud-identity.ts +23 -0
  323. package/src/workspace/ci-init.ts +5 -4
  324. package/src/workspace/ci-locks.ts +16 -0
  325. package/src/workspace/ci-run-index.test.ts +2 -1
  326. package/src/workspace/ci-run.test.ts +107 -8
  327. package/src/workspace/ci-run.ts +74 -5
  328. package/src/workspace/ci-workflow-at.ts +23 -0
  329. package/src/workspace/ci-workflow.ts +13 -2
  330. package/src/workspace/declaration.schema.json +149 -2
  331. package/src/workspace/declaration.test.ts +76 -0
  332. package/src/workspace/declaration.ts +140 -5
  333. package/src/workspace/identity-gate-rule.ts +1 -1
  334. package/src/workspace/identity.ts +1 -1
  335. package/src/workspace/lineage-upgrade.ts +11 -8
  336. package/src/workspace/member-pipeline.test.ts +29 -0
  337. package/src/workspace/member-pipeline.ts +30 -0
  338. package/src/workspace/pr-report.schema.json +94 -3
  339. package/src/workspace/reason-codes.ts +1 -1
  340. package/src/workspace/security.ts +4 -0
  341. package/src/workspace/status-gates.ts +9 -6
  342. package/src/workspace/status.ts +4 -1
  343. package/src/workspace/trust/gate-seal.test.ts +1 -1
  344. package/src/workspace/trust/seal.ts +2 -2
  345. package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
  346. /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The requires checks of an apply before merge (#4301, ws-106): the four
3
+ * PrForge methods on GitHub, Forgejo and GitLab, driven through a recording
4
+ * fake forge, and checkPrRequires over them.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "./pr-forge";
9
+ import { checkPrRequires, PR_APPLY_REQUIRES, unmetRequires } from "./pr-requires";
10
+
11
+ /** A fake forge: `routes` maps `METHOD path-without-query` to a body, a status, or a function of the query. */
12
+ function fakeFetch(routes: Record<string, unknown | ((q: URLSearchParams) => unknown)>): ForgeFetch {
13
+ return async (url, init) => {
14
+ const u = new URL(url);
15
+ let found = routes[`${init.method} ${u.pathname}`];
16
+ if (typeof found === "function") found = (found as (q: URLSearchParams) => unknown)(u.searchParams);
17
+ if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
18
+ if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
19
+ return { ok: true, status: 200, text: async () => JSON.stringify(found) };
20
+ };
21
+ }
22
+
23
+ const HEAD = "a".repeat(40);
24
+ const TIP = "b".repeat(40);
25
+ const OTHER = "c".repeat(40);
26
+
27
+ describe("github", () => {
28
+ const repo = "/repos/acme/infra";
29
+ const pull = (over: Record<string, unknown> = {}) => ({ number: 7, state: "open", user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, mergeable_state: "clean", ...over });
30
+ const forge = (routes: Record<string, unknown>) =>
31
+ githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/7`]: pull(), [`GET ${repo}/pulls/7/commits`]: [{ author: { login: "alice" }, committer: { login: "alice" } }], ...routes }) });
32
+
33
+ test("approved: a reviewer other than the authors approved this head", async () => {
34
+ const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
35
+ expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
36
+ });
37
+
38
+ test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
39
+ const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
40
+ expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
41
+ const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
42
+ expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
43
+ });
44
+
45
+ test("every check fails when the forge's head is another commit", async () => {
46
+ const f = forge({ [`GET ${repo}/pulls/7`]: pull({ head: { ref: "f", sha: OTHER } }) });
47
+ const r = await f.prMergeable!(7, HEAD);
48
+ expect(r.passed).toBe(false);
49
+ expect(r.reason).toContain("head is cccccccc, not aaaaaaaa");
50
+ });
51
+
52
+ test("mergeable: conflicts and branch protection refuse, an unknown answer is pending", async () => {
53
+ expect((await forge({}).prMergeable!(7, HEAD)).passed).toBe(true);
54
+ expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: false, mergeable_state: "dirty" }) }).prMergeable!(7, HEAD)).reason).toContain("conflicts with main");
55
+ expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(7, HEAD)).reason).toContain("branch protection");
56
+ expect(await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: null }) }).prMergeable!(7, HEAD)).toMatchObject({ passed: false, pending: true });
57
+ });
58
+
59
+ test("undiverged: reads behind_by from the compare of the base branch with the head", async () => {
60
+ expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 0 } }).prUndiverged!(7, HEAD)).passed).toBe(true);
61
+ expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 2 } }).prUndiverged!(7, HEAD)).reason).toBe("aaaaaaaa is 2 commits behind main");
62
+ });
63
+
64
+ test("checks: statuses and check runs, the apply's own left out", async () => {
65
+ const statuses = (state: string) => ({ statuses: [{ context: "ci/test", state }, { context: "chant/apply", state: "failure" }] });
66
+ const runs = (conclusion: string | null, status = "completed") => ({ check_runs: [{ name: "lint", status, conclusion }] });
67
+ const f = (state: string, run: unknown) => forge({ [`GET ${repo}/commits/${HEAD}/status`]: statuses(state), [`GET ${repo}/commits/${HEAD}/check-runs`]: run });
68
+ expect(await f("success", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
69
+ expect((await f("success", runs("success")).prChecksPassed!(7, HEAD)).reason).toBe("chant/apply failed on aaaaaaaa");
70
+ expect(await f("pending", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toMatchObject({ passed: false, pending: true });
71
+ expect((await f("success", runs(null, "in_progress")).prChecksPassed!(7, HEAD, ["chant/apply"])).pending).toBe(true);
72
+ expect((await f("success", runs("failure")).prChecksPassed!(7, HEAD, ["chant/apply"])).reason).toBe("lint failed on aaaaaaaa");
73
+ });
74
+
75
+ test("checks: nothing reported is pending", async () => {
76
+ const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [] }, [`GET ${repo}/commits/${HEAD}/check-runs`]: { check_runs: [] } });
77
+ expect(await f.prChecksPassed!(7, HEAD)).toMatchObject({ passed: false, pending: true });
78
+ });
79
+ });
80
+
81
+ describe("forgejo", () => {
82
+ const repo = "/api/v1/repos/acme/infra";
83
+ const pull = (over: Record<string, unknown> = {}) => ({ number: 4, user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, merge_base: TIP, ...over });
84
+ const forge = (routes: Record<string, unknown>) =>
85
+ forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/4`]: pull(), [`GET ${repo}/pulls/4/commits`]: [], [`GET ${repo}/branches/main`]: { commit: { id: TIP } }, ...routes }) });
86
+
87
+ test("approved: Forgejo's REQUEST_CHANGES state refuses", async () => {
88
+ const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "dave" }, state: "REQUEST_CHANGES" }] });
89
+ expect((await f.prApproved!(4, HEAD)).reason).toBe("dave asked for changes");
90
+ });
91
+
92
+ test("undiverged: the pull request's merge base is the base branch's tip", async () => {
93
+ expect((await forge({}).prUndiverged!(4, HEAD)).passed).toBe(true);
94
+ expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ merge_base: OTHER }) }).prUndiverged!(4, HEAD)).reason).toBe("aaaaaaaa does not contain main at bbbbbbbb");
95
+ });
96
+
97
+ test("mergeable: no branch protection reading, and checks from the combined status alone", async () => {
98
+ expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(4, HEAD)).passed).toBe(true);
99
+ const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
100
+ expect(await f.prChecksPassed!(4, HEAD)).toEqual({ passed: true, reason: "1 check passed on aaaaaaaa" });
101
+ });
102
+ });
103
+
104
+ describe("gitlab", () => {
105
+ const project = "/api/v4/projects/9";
106
+ const mr = (over: Record<string, unknown> = {}) => ({ iid: 3, sha: HEAD, author: { username: "alice" }, target_branch: "main", detailed_merge_status: "mergeable", diverged_commits_count: 0, ...over });
107
+ const note = (who: string, body: string, at: string) => ({ system: true, author: { username: who }, body, created_at: at });
108
+ const forge = (routes: Record<string, unknown>) =>
109
+ gitlabForge({
110
+ apiBase: "https://gitlab.example/api/v4",
111
+ project: "9",
112
+ token: "t",
113
+ host: "gitlab.example",
114
+ fetch: fakeFetch({
115
+ [`GET ${project}/merge_requests/3`]: mr(),
116
+ [`GET ${project}/merge_requests/3/commits`]: [],
117
+ "GET /api/v4/user": { id: 1, username: "chant-bot" },
118
+ [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
119
+ ...routes,
120
+ }),
121
+ });
122
+
123
+ test("approved: an approval after the latest push, not the token user's or the author's", async () => {
124
+ const f = forge({
125
+ [`GET ${project}/merge_requests/3/notes`]: [
126
+ note("erin", "approved this merge request", "2026-10-01T09:00:00Z"),
127
+ note("chant-bot", "approved this merge request", "2026-10-01T11:00:00Z"),
128
+ note("alice", "approved this merge request", "2026-10-01T11:00:00Z"),
129
+ ],
130
+ });
131
+ expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
132
+ const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
133
+ expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
134
+ const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
135
+ expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
136
+ });
137
+
138
+ test("mergeable and undiverged read the merge request", async () => {
139
+ expect((await forge({}).prMergeable!(3, HEAD)).passed).toBe(true);
140
+ expect(await forge({ [`GET ${project}/merge_requests/3`]: mr({ detailed_merge_status: "checking" }) }).prMergeable!(3, HEAD)).toMatchObject({ passed: false, pending: true });
141
+ expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ has_conflicts: true, detailed_merge_status: "conflict" }) }).prMergeable!(3, HEAD)).reason).toContain("conflicts");
142
+ expect((await forge({}).prUndiverged!(3, HEAD)).passed).toBe(true);
143
+ expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ diverged_commits_count: 1 }) }).prUndiverged!(3, HEAD)).reason).toBe("aaaaaaaa is 1 commit behind main");
144
+ });
145
+
146
+ test("checks: each name's newest status, a job allowed to fail holding nothing back", async () => {
147
+ const f = forge({
148
+ [`GET ${project}/repository/commits/${HEAD}/statuses`]: [
149
+ { id: 1, name: "test", status: "failed" },
150
+ { id: 2, name: "test", status: "success" },
151
+ { id: 3, name: "flaky", status: "failed", allow_failure: true },
152
+ { id: 4, name: "apply", status: "running" },
153
+ ],
154
+ });
155
+ expect(await f.prChecksPassed!(3, HEAD, ["apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
156
+ expect((await f.prChecksPassed!(3, HEAD)).pending).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe("checkPrRequires", () => {
161
+ const ok = { passed: true, reason: "ok" };
162
+ test("asks each check once in order, and a pending one again until it settles", async () => {
163
+ let reads = 0;
164
+ const forge = {
165
+ kind: "github",
166
+ prApproved: async () => ok,
167
+ prMergeable: async () => (++reads < 3 ? { passed: false, pending: true, reason: "working it out" } : ok),
168
+ prUndiverged: async () => ok,
169
+ prChecksPassed: async (_pr: number, _head: string, ignore?: readonly string[]) => ({ passed: true, reason: `ignored ${ignore?.join(",")}` }),
170
+ } as unknown as PrForge;
171
+ const out = await checkPrRequires(forge, 7, HEAD, { ignore: ["chant/apply"], wait: async () => {} });
172
+ expect(out.map((o) => o.name)).toEqual([...PR_APPLY_REQUIRES]);
173
+ expect(reads).toBe(3);
174
+ expect(out[3]!.reason).toBe("ignored chant/apply");
175
+ expect(unmetRequires(out)).toBeNull();
176
+ });
177
+
178
+ test("a client without a method, or a call that throws, is unmet", async () => {
179
+ const forge = { kind: "gitlab", prApproved: async () => { throw new Error("401"); } } as unknown as PrForge;
180
+ const out = await checkPrRequires(forge, 3, HEAD, { reads: 1 });
181
+ expect(out.every((o) => !o.passed)).toBe(true);
182
+ expect(unmetRequires(out)).toBe("approved: could not ask gitlab: 401; mergeable: this gitlab client cannot say; undiverged: this gitlab client cannot say; checks: this gitlab client cannot say");
183
+ });
184
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The requires checks of an apply before merge (#4301, ws-106): the pull
3
+ * request is approved, mergeable, undiverged and its checks passed, each
4
+ * asked of the forge through its {@link PrForge} method. All four must pass.
5
+ *
6
+ * The checks are terragucci's `apply.requires` (comment-apply.ts:781-860 at
7
+ * INTENTIUS/terragucci e6cb431), with no setting to drop one. A check the
8
+ * forge has not settled, such as GitHub working out whether a pull request
9
+ * can merge, is asked again a few times before it counts as unmet.
10
+ *
11
+ * Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
12
+ * without the TypeScript toolchain.
13
+ */
14
+ import type { PrForge, PrRequireResult } from "./pr-forge";
15
+
16
+ /** The four checks, in the order they run and the report lists them. */
17
+ export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
18
+
19
+ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
20
+
21
+ /** One check as the pull-request report's `beforeMerge.requires` holds it. */
22
+ export interface PrRequireOutcome extends PrRequireResult {
23
+ name: PrApplyRequire;
24
+ }
25
+
26
+ export interface PrRequiresOptions {
27
+ /** Status names the `checks` require leaves out: the apply's own. */
28
+ ignore?: readonly string[];
29
+ /** How many times a pending check is asked in all. Default 5. */
30
+ reads?: number;
31
+ /** The wait between reads. Default 3 seconds. */
32
+ wait?: (ms: number) => Promise<void>;
33
+ }
34
+
35
+ const READS = 5;
36
+ const WAIT_MS = 3000;
37
+
38
+ /**
39
+ * Ask `forge` each of the four checks for pull request `pr` at `head`. A
40
+ * forge client without a method, or a call that throws, gives an unmet
41
+ * check with the reason, so nothing applies on a check nobody made.
42
+ */
43
+ export async function checkPrRequires(forge: PrForge, pr: number, head: string, opts: PrRequiresOptions = {}): Promise<PrRequireOutcome[]> {
44
+ const wait = opts.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
45
+ const reads = Math.max(1, opts.reads ?? READS);
46
+ const ask: Record<PrApplyRequire, (() => Promise<PrRequireResult>) | undefined> = {
47
+ approved: forge.prApproved ? () => forge.prApproved!(pr, head) : undefined,
48
+ mergeable: forge.prMergeable ? () => forge.prMergeable!(pr, head) : undefined,
49
+ undiverged: forge.prUndiverged ? () => forge.prUndiverged!(pr, head) : undefined,
50
+ checks: forge.prChecksPassed ? () => forge.prChecksPassed!(pr, head, opts.ignore ?? []) : undefined,
51
+ };
52
+ const out: PrRequireOutcome[] = [];
53
+ for (const name of PR_APPLY_REQUIRES) {
54
+ const call = ask[name];
55
+ if (!call) {
56
+ out.push({ name, passed: false, reason: `this ${forge.kind} client cannot say` });
57
+ continue;
58
+ }
59
+ let result: PrRequireResult;
60
+ for (let read = 1; ; read++) {
61
+ try {
62
+ result = await call();
63
+ } catch (err) {
64
+ result = { passed: false, reason: `could not ask ${forge.kind}: ${err instanceof Error ? err.message : String(err)}` };
65
+ }
66
+ if (result.passed || !result.pending || read >= reads) break;
67
+ await wait(WAIT_MS);
68
+ }
69
+ out.push({ name, ...result });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The unmet checks as one line, or null when all four passed. */
75
+ export function unmetRequires(outcomes: readonly PrRequireOutcome[]): string | null {
76
+ const unmet = outcomes.filter((o) => !o.passed);
77
+ if (unmet.length === 0) return null;
78
+ return unmet.map((o) => `${o.name}: ${o.reason}`).join("; ");
79
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The review agent bundles alone (#4299): a product such as terragucci
3
+ * imports `@intentius/chant/op/activities/review-agent` into one bundled
4
+ * file, so it must not reach predict-behaviour's estate code. This bundles
5
+ * the module and reads esbuild's list of what went in.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { build } from "esbuild";
10
+ import { describe, expect, test } from "vitest";
11
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
12
+
13
+ describe("the review-agent bundle", () => {
14
+ test("holds the review agent, the note it posts through and nothing else", async () => {
15
+ const result = await build({
16
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "activities", "review-agent.ts")],
17
+ bundle: true,
18
+ platform: "node",
19
+ format: "esm",
20
+ write: false,
21
+ metafile: true,
22
+ outdir: "out",
23
+ logLevel: "silent",
24
+ });
25
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
26
+ "packages/core/src/op/activities/forge-pull-request.ts",
27
+ "packages/core/src/op/activities/gh-host.ts",
28
+ "packages/core/src/op/activities/pr-refs.ts",
29
+ "packages/core/src/op/activities/reconcile.ts",
30
+ "packages/core/src/op/activities/review-agent.ts",
31
+ "packages/core/src/pr-forge.ts",
32
+ ]);
33
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
34
+ });
35
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The stage-hook runner bundles alone (#4288): a product that runs
3
+ * `ci.hooks` around its own stages imports
4
+ * `@intentius/chant/op/stage-hooks` into one bundled file, without the wave
5
+ * loop, the gate or the ledger. This bundles the module and reads esbuild's
6
+ * list of what went in.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { build } from "esbuild";
11
+ import { describe, expect, test } from "vitest";
12
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
13
+
14
+ describe("the stage-hooks bundle", () => {
15
+ test("holds the hook runner and the Op waves spec, and no wave loop, gate or ledger", async () => {
16
+ const result = await build({
17
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "stage-hooks.ts")],
18
+ bundle: true,
19
+ platform: "node",
20
+ format: "esm",
21
+ write: false,
22
+ metafile: true,
23
+ outdir: "out",
24
+ logLevel: "silent",
25
+ });
26
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
27
+ "packages/core/src/lifecycle/gate-seal.ts",
28
+ "packages/core/src/notify-verify.ts",
29
+ "packages/core/src/notify.ts",
30
+ "packages/core/src/op/gate-point.ts",
31
+ "packages/core/src/op/gate-quorum.ts",
32
+ "packages/core/src/op/op-waves.ts",
33
+ "packages/core/src/op/stage-hooks.ts",
34
+ "packages/core/src/wave-layout.ts",
35
+ ]);
36
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(48 * 1024);
37
+ });
38
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The blocked-member rules bundle on their own (#4291): terragucci calls them
3
+ * from its CI stages as one bundled file with no TypeScript toolchain, as it
4
+ * calls `./gated-waves` (#3421).
5
+ */
6
+
7
+ import { join } from "node:path";
8
+ import { build } from "esbuild";
9
+ import { expect, test } from "vitest";
10
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
11
+
12
+ test("wave-blocked bundles with nothing but itself", async () => {
13
+ const result = await build({
14
+ entryPoints: [join(REPO, "packages", "core", "src", "wave-blocked.ts")],
15
+ bundle: true,
16
+ platform: "node",
17
+ format: "esm",
18
+ write: false,
19
+ metafile: true,
20
+ outdir: "out",
21
+ logLevel: "silent",
22
+ });
23
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/"))).toEqual(["packages/core/src/wave-blocked.ts"]);
24
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(12 * 1024);
25
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A failed member blocks only the members that depend on it (#4291): the
3
+ * rule, the walk over earlier waves, and the outcome files a wave job hands
4
+ * the next one. The rules are terragucci's `blocked.ts`, lifted.
5
+ */
6
+
7
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, test } from "vitest";
11
+ import {
12
+ blockedMembers,
13
+ blockedWords,
14
+ earlierWaveOutcomes,
15
+ onlyWaiting,
16
+ runSummary,
17
+ settleEarlierWaves,
18
+ waveOutcomeFileName,
19
+ writeWaveOutcomeFile,
20
+ type WaveMemberOutcome,
21
+ } from "./wave-blocked";
22
+
23
+ /** cluster-a and cluster-b, then app-01 (reads cluster-a) and app-02 (reads cluster-b), then edge (reads app-01). */
24
+ const UPSTREAMS = new Map<string, string[]>([
25
+ ["app-01", ["cluster-a"]],
26
+ ["app-02", ["cluster-b"]],
27
+ ["edge", ["app-01"]],
28
+ ]);
29
+ const WAVES = [["cluster-a", "cluster-b"], ["app-01", "app-02"], ["edge"]];
30
+
31
+ describe("blockedMembers", () => {
32
+ test("a failed upstream blocks its dependents and names itself; the rest go through", () => {
33
+ const outcomes = new Map<string, WaveMemberOutcome>([
34
+ ["cluster-a", { status: "failed" }],
35
+ ["cluster-b", { status: "applied" }],
36
+ ]);
37
+ const blocked = blockedMembers(["app-01", "app-02"], UPSTREAMS, new Set(WAVES[0]), outcomes);
38
+ expect([...blocked]).toEqual([["app-01", "cluster-a"]]);
39
+ });
40
+
41
+ test("a blocked upstream passes on the member that blocked it", () => {
42
+ const outcomes = new Map<string, WaveMemberOutcome>([["app-01", { status: "blocked", by: "cluster-a" }]]);
43
+ expect([...blockedMembers(["edge"], UPSTREAMS, new Set(["app-01"]), outcomes)]).toEqual([["edge", "cluster-a"]]);
44
+ });
45
+
46
+ test("unchanged lets a dependent through; an upstream with no outcome holds it", () => {
47
+ expect(blockedMembers(["app-01"], UPSTREAMS, new Set(["cluster-a"]), new Map([["cluster-a", { status: "unchanged" }]])).size).toBe(0);
48
+ expect([...blockedMembers(["app-01"], UPSTREAMS, new Set(["cluster-a"]), new Map())]).toEqual([["app-01", "cluster-a"]]);
49
+ });
50
+
51
+ test("an upstream outside the earlier waves is no upstream here", () => {
52
+ expect(blockedMembers(["app-01"], UPSTREAMS, new Set(), new Map([["cluster-a", { status: "failed" }]])).size).toBe(0);
53
+ });
54
+
55
+ test("a wave that holds all blocks every member by its name", () => {
56
+ expect([...blockedMembers(["app-01", "app-02"], UPSTREAMS, new Set(), new Map(), 1)]).toEqual([
57
+ ["app-01", "wave-1"],
58
+ ["app-02", "wave-1"],
59
+ ]);
60
+ });
61
+ });
62
+
63
+ describe("settleEarlierWaves", () => {
64
+ test("walks the earlier waves, blocking behind failures, and names what has not run", () => {
65
+ const { outcomes, unsettled } = settleEarlierWaves(
66
+ WAVES,
67
+ 4,
68
+ UPSTREAMS,
69
+ new Map([
70
+ ["cluster-a", { status: "failed" }],
71
+ ["cluster-b", { status: "applied" }],
72
+ ]),
73
+ );
74
+ expect(outcomes.get("app-01")).toEqual({ status: "blocked", by: "cluster-a" });
75
+ expect(outcomes.get("edge")).toEqual({ status: "blocked", by: "cluster-a" });
76
+ expect(unsettled).toEqual(["app-02"]);
77
+ });
78
+ });
79
+
80
+ describe("onlyWaiting, blockedWords and runSummary", () => {
81
+ test("a run whose only holds are a wait, and what it blocked, waits", () => {
82
+ expect(
83
+ onlyWaiting(
84
+ new Map<string, WaveMemberOutcome>([
85
+ ["cluster-a", { status: "waiting" }],
86
+ ["app-01", { status: "blocked", by: "cluster-a" }],
87
+ ["cluster-b", { status: "applied" }],
88
+ ]),
89
+ ),
90
+ ).toBe(true);
91
+ expect(onlyWaiting(new Map<string, WaveMemberOutcome>([["cluster-a", { status: "failed" }]]))).toBe(false);
92
+ expect(onlyWaiting(new Map<string, WaveMemberOutcome>([["cluster-a", { status: "applied" }]]))).toBe(false);
93
+ });
94
+
95
+ test("the blocked set reads grouped by what blocked it", () => {
96
+ expect(blockedWords(new Map([["edge", "cluster-a"], ["app-01", "cluster-a"], ["app-02", "cluster-b"]]))).toBe(
97
+ "app-01, edge (blocked by cluster-a); app-02 (blocked by cluster-b)",
98
+ );
99
+ });
100
+
101
+ test("the run's summary counts each status, and is undefined when all applied", () => {
102
+ expect(runSummary([{ status: "applied" }, { status: "unchanged" }])).toBeUndefined();
103
+ expect(runSummary([{ status: "applied" }, { status: "failed" }, { status: "blocked", by: "x" }, { status: "waiting" }])).toBe(
104
+ "1 applied, 1 failed, 1 blocked, 1 waiting",
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("outcome files", () => {
110
+ test("the newest outcome of a member wins, later waves are left out, and terragucci's roots key reads too", () => {
111
+ const dir = mkdtempSync(join(tmpdir(), "chant-wave-outcomes-"));
112
+ try {
113
+ writeWaveOutcomeFile(dir, 1, {}, { "cluster-a": { status: "failed" }, "cluster-b": { status: "applied" } }, "2026-10-18T10:00:00.000Z");
114
+ const rerun = join(dir, "rerun");
115
+ mkdirSync(rerun);
116
+ writeWaveOutcomeFile(rerun, 1, {}, { "cluster-a": { status: "applied" } }, "2026-10-18T11:00:00.000Z");
117
+ writeFileSync(
118
+ join(dir, waveOutcomeFileName(2, { share: 1 })),
119
+ JSON.stringify({ version: 1, wave: 2, at: "2026-10-18T10:05:00.000Z", roots: { "app-01": { status: "applied" } } }),
120
+ );
121
+ writeWaveOutcomeFile(dir, 3, {}, { edge: { status: "failed" } }, "2026-10-18T10:10:00.000Z");
122
+ writeFileSync(join(dir, "outcome-wave-9.json"), "not json");
123
+
124
+ const { members, holdsAll } = earlierWaveOutcomes([dir], 3);
125
+ expect(holdsAll).toBeUndefined();
126
+ expect(Object.fromEntries([...members].map(([m, o]) => [m, o.status]))).toEqual({
127
+ "cluster-a": "applied",
128
+ "cluster-b": "applied",
129
+ "app-01": "applied",
130
+ });
131
+ } finally {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test("a wave that holds all is named", () => {
137
+ const dir = mkdtempSync(join(tmpdir(), "chant-wave-outcomes-"));
138
+ try {
139
+ writeWaveOutcomeFile(dir, 1, { group: "core/x" }, {}, "2026-10-18T10:00:00.000Z", true);
140
+ expect(waveOutcomeFileName(1, { group: "core/x" })).toBe("outcome-wave-1-group-core_x.json");
141
+ expect(earlierWaveOutcomes([dir], 2).holdsAll).toBe(1);
142
+ } finally {
143
+ rmSync(dir, { recursive: true, force: true });
144
+ }
145
+ });
146
+ });