@intentius/chant 0.124.31 → 0.124.32

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 (321) hide show
  1. package/bin/chant +7 -2
  2. package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-plugin.js +10 -7
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.js +18 -12
  7. package/dist/cli/commands/import.d.ts +10 -0
  8. package/dist/cli/commands/import.d.ts.map +1 -1
  9. package/dist/cli/commands/import.js +37 -0
  10. package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
  11. package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
  12. package/dist/cli/commands/onboard.d.ts.map +1 -1
  13. package/dist/cli/commands/onboard.js +8 -4
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +2 -1
  16. package/dist/cli/dependencies.d.ts +18 -0
  17. package/dist/cli/dependencies.d.ts.map +1 -0
  18. package/dist/cli/dependencies.js +120 -0
  19. package/dist/cli/handlers/build.d.ts.map +1 -1
  20. package/dist/cli/handlers/build.js +23 -4
  21. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  22. package/dist/cli/handlers/fan-out.js +12 -4
  23. package/dist/cli/handlers/graph.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.js +16 -6
  25. package/dist/cli/handlers/lint.d.ts.map +1 -1
  26. package/dist/cli/handlers/lint.js +6 -0
  27. package/dist/cli/handlers/operator.d.ts +2 -2
  28. package/dist/cli/handlers/operator.js +3 -3
  29. package/dist/cli/handlers/pr.d.ts +14 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +488 -32
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +3 -1
  34. package/dist/cli/handlers/run.d.ts +13 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +38 -15
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +44 -16
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +15 -0
  41. package/dist/cli/registry.d.ts +13 -4
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/registry.js +6 -2
  44. package/dist/codegen/generate-post-synth-barrel.js +2 -2
  45. package/dist/codegen/lexicon-regen.d.ts +4 -0
  46. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  47. package/dist/codegen/lexicon-regen.js +1 -1
  48. package/dist/codegen/pinned-upgrade.js +2 -2
  49. package/dist/components/capability.d.ts +44 -3
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/capability.js +21 -0
  52. package/dist/components/changed-files.d.ts +7 -1
  53. package/dist/components/changed-files.d.ts.map +1 -1
  54. package/dist/components/changed-files.js +9 -1
  55. package/dist/components/driver.d.ts +38 -0
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/driver.js +47 -6
  58. package/dist/components/fan-out-record.d.ts +6 -1
  59. package/dist/components/fan-out-record.d.ts.map +1 -1
  60. package/dist/components/fan-out-record.js +24 -0
  61. package/dist/components/fan-out-run.d.ts +20 -0
  62. package/dist/components/fan-out-run.d.ts.map +1 -1
  63. package/dist/components/fan-out-run.js +21 -1
  64. package/dist/components/pr-pipeline.d.ts +65 -2
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +107 -15
  67. package/dist/components/pr-run.d.ts +127 -29
  68. package/dist/components/pr-run.d.ts.map +1 -1
  69. package/dist/components/pr-run.js +290 -70
  70. package/dist/components/wave-plan.d.ts +5 -2
  71. package/dist/components/wave-plan.d.ts.map +1 -1
  72. package/dist/components/wave-plan.js +12 -1
  73. package/dist/discovery/fold-import.d.ts.map +1 -1
  74. package/dist/discovery/fold-import.js +16 -6
  75. package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
  76. package/dist/discovery/sandbox/bundle.js +6 -0
  77. package/dist/discovery/specifier-resolve.d.ts.map +1 -1
  78. package/dist/discovery/specifier-resolve.js +8 -2
  79. package/dist/fold/fold.d.ts.map +1 -1
  80. package/dist/fold/fold.js +68 -1
  81. package/dist/fold/subset.d.ts +37 -0
  82. package/dist/fold/subset.d.ts.map +1 -1
  83. package/dist/fold/subset.js +88 -0
  84. package/dist/lexicon-module.d.ts +10 -0
  85. package/dist/lexicon-module.d.ts.map +1 -1
  86. package/dist/lexicon-module.js +53 -8
  87. package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
  88. package/dist/lexicon-plugin-helpers.js +6 -4
  89. package/dist/lexicon.d.ts +2 -0
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/affected.d.ts.map +1 -1
  92. package/dist/lifecycle/affected.js +7 -1
  93. package/dist/lifecycle/release-ledger.d.ts +21 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +24 -0
  96. package/dist/lifecycle/work-dir.d.ts +45 -0
  97. package/dist/lifecycle/work-dir.d.ts.map +1 -0
  98. package/dist/lifecycle/work-dir.js +78 -0
  99. package/dist/lint/discover.d.ts +17 -9
  100. package/dist/lint/discover.d.ts.map +1 -1
  101. package/dist/lint/discover.js +62 -54
  102. package/dist/module-source.d.ts +57 -0
  103. package/dist/module-source.d.ts.map +1 -0
  104. package/dist/module-source.js +104 -0
  105. package/dist/op/activities/factory.js +1 -1
  106. package/dist/op/discover.d.ts.map +1 -1
  107. package/dist/op/discover.js +13 -3
  108. package/dist/op/gate.d.ts.map +1 -1
  109. package/dist/op/gate.js +3 -0
  110. package/dist/op/op-waves-run.d.ts.map +1 -1
  111. package/dist/op/op-waves-run.js +20 -5
  112. package/dist/op/operator.d.ts +2 -2
  113. package/dist/op/operator.js +2 -2
  114. package/dist/op/steward.d.ts +6 -4
  115. package/dist/op/steward.d.ts.map +1 -1
  116. package/dist/op/steward.js +5 -3
  117. package/dist/op/work-lease-run.d.ts +20 -7
  118. package/dist/op/work-lease-run.d.ts.map +1 -1
  119. package/dist/op/work-lease-run.js +56 -12
  120. package/dist/pr-forge.d.ts +10 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +39 -0
  123. package/dist/pr-loop.d.ts +141 -4
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +122 -6
  126. package/dist/pr-report-url.d.ts +2 -2
  127. package/dist/pr-report-url.d.ts.map +1 -1
  128. package/dist/pr-report-url.js +5 -5
  129. package/dist/ts-loader.d.ts +38 -4
  130. package/dist/ts-loader.d.ts.map +1 -1
  131. package/dist/ts-loader.js +190 -16
  132. package/dist/wave-runtime.mjs +208 -208
  133. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  134. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  135. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  136. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  137. package/dist/workspace/chant-migrations.js +2 -0
  138. package/dist/workspace/checks/boxes.d.ts +6 -6
  139. package/dist/workspace/checks/boxes.js +9 -9
  140. package/dist/workspace/ci-green-forge.d.ts +6 -4
  141. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  142. package/dist/workspace/ci-green.d.ts +28 -12
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +49 -21
  145. package/dist/workspace/ci-list.schema.json +12 -6
  146. package/dist/workspace/ci-report-url.d.ts +18 -0
  147. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  148. package/dist/workspace/ci-report-url.js +33 -0
  149. package/dist/workspace/ci-run-index.d.ts +12 -0
  150. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  151. package/dist/workspace/ci-run-index.js +6 -4
  152. package/dist/workspace/ci-run-index.schema.json +5 -3
  153. package/dist/workspace/ci-run.d.ts +75 -2
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +195 -53
  156. package/dist/workspace/ci-runner-key.d.ts +16 -3
  157. package/dist/workspace/ci-runner-key.d.ts.map +1 -1
  158. package/dist/workspace/ci-runner-key.js +30 -10
  159. package/dist/workspace/ci-serve.d.ts +54 -4
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +175 -26
  162. package/dist/workspace/declaration.d.ts +8 -0
  163. package/dist/workspace/declaration.d.ts.map +1 -1
  164. package/dist/workspace/declaration.js +11 -2
  165. package/dist/workspace/declaration.schema.json +7 -2
  166. package/dist/workspace/export.d.ts +1 -1
  167. package/dist/workspace/export.d.ts.map +1 -1
  168. package/dist/workspace/export.js +7 -3
  169. package/dist/workspace/ls.d.ts +6 -1
  170. package/dist/workspace/ls.d.ts.map +1 -1
  171. package/dist/workspace/ls.js +1 -0
  172. package/dist/workspace/ls.schema.json +7 -1
  173. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  174. package/dist/workspace/pin-integrity.js +3 -2
  175. package/dist/workspace/pr-report.schema.json +74 -7
  176. package/dist/workspace/reason-codes.d.ts +1 -1
  177. package/dist/workspace/reason-codes.d.ts.map +1 -1
  178. package/dist/workspace/reason-codes.js +2 -2
  179. package/dist/workspace/run-event.schema.json +3 -1
  180. package/dist/workspace/status.schema.json +3 -1
  181. package/dist/workspace/write-scope.d.ts +9 -0
  182. package/dist/workspace/write-scope.d.ts.map +1 -1
  183. package/dist/workspace/write-scope.js +11 -1
  184. package/package.json +1 -1
  185. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  186. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  187. package/src/cli/commands/audit.test.ts +2 -2
  188. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  189. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  190. package/src/cli/commands/check-lexicon.test.ts +1 -1
  191. package/src/cli/commands/check-lexicon.ts +16 -11
  192. package/src/cli/commands/import.ts +40 -0
  193. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  194. package/src/cli/commands/init-lexicon.test.ts +8 -5
  195. package/src/cli/commands/onboard.test.ts +45 -0
  196. package/src/cli/commands/onboard.ts +8 -4
  197. package/src/cli/commands/update.test.ts +6 -0
  198. package/src/cli/commands/update.ts +4 -1
  199. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  200. package/src/cli/dependencies.e2e.test.ts +101 -0
  201. package/src/cli/dependencies.test.ts +93 -0
  202. package/src/cli/dependencies.ts +122 -0
  203. package/src/cli/handlers/build.test.ts +69 -0
  204. package/src/cli/handlers/build.ts +20 -4
  205. package/src/cli/handlers/fan-out.test.ts +73 -0
  206. package/src/cli/handlers/fan-out.ts +11 -4
  207. package/src/cli/handlers/graph.test.ts +76 -0
  208. package/src/cli/handlers/graph.ts +19 -12
  209. package/src/cli/handlers/lint.test.ts +42 -0
  210. package/src/cli/handlers/lint.ts +6 -0
  211. package/src/cli/handlers/operator.ts +3 -3
  212. package/src/cli/handlers/pr-waves.test.ts +449 -0
  213. package/src/cli/handlers/pr.test.ts +134 -0
  214. package/src/cli/handlers/pr.ts +523 -31
  215. package/src/cli/handlers/promote.test.ts +26 -1
  216. package/src/cli/handlers/promote.ts +3 -2
  217. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  218. package/src/cli/handlers/run-signer.test.ts +79 -0
  219. package/src/cli/handlers/run.ts +34 -16
  220. package/src/cli/main.test.ts +18 -0
  221. package/src/cli/main.ts +43 -16
  222. package/src/cli/parse-args.ts +12 -0
  223. package/src/cli/registry.ts +19 -6
  224. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  225. package/src/codegen/lexicon-regen.ts +1 -1
  226. package/src/codegen/pinned-upgrade.ts +2 -2
  227. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  228. package/src/components/capability.ts +51 -3
  229. package/src/components/changed-files.test.ts +16 -1
  230. package/src/components/changed-files.ts +9 -1
  231. package/src/components/driver.test.ts +36 -1
  232. package/src/components/driver.ts +76 -4
  233. package/src/components/fan-out-record.ts +29 -1
  234. package/src/components/fan-out-run.test.ts +35 -0
  235. package/src/components/fan-out-run.ts +40 -1
  236. package/src/components/pr-pipeline.test.ts +100 -0
  237. package/src/components/pr-pipeline.ts +132 -17
  238. package/src/components/pr-run.test.ts +323 -18
  239. package/src/components/pr-run.ts +354 -75
  240. package/src/components/wave-plan.ts +16 -4
  241. package/src/discovery/fold-host-method.test.ts +87 -0
  242. package/src/discovery/fold-import.ts +15 -5
  243. package/src/discovery/fold-package-composite.test.ts +27 -0
  244. package/src/discovery/fold-specifiers.test.ts +8 -0
  245. package/src/discovery/sandbox/bundle.ts +6 -0
  246. package/src/discovery/specifier-resolve.ts +8 -2
  247. package/src/fold/fold.test.ts +72 -0
  248. package/src/fold/fold.ts +71 -0
  249. package/src/fold/subset.test.ts +31 -0
  250. package/src/fold/subset.ts +99 -0
  251. package/src/lexicon-dist.e2e.test.ts +254 -0
  252. package/src/lexicon-module.test.ts +47 -0
  253. package/src/lexicon-module.ts +49 -7
  254. package/src/lexicon-plugin-helpers.ts +6 -4
  255. package/src/lexicon.ts +2 -0
  256. package/src/lifecycle/affected.test.ts +7 -0
  257. package/src/lifecycle/affected.ts +6 -1
  258. package/src/lifecycle/release-ledger.ts +34 -0
  259. package/src/lifecycle/work-dir.test.ts +77 -0
  260. package/src/lifecycle/work-dir.ts +82 -0
  261. package/src/lint/discover.test.ts +46 -2
  262. package/src/lint/discover.ts +55 -49
  263. package/src/module-source.test.ts +85 -0
  264. package/src/module-source.ts +112 -0
  265. package/src/op/activities/factory.ts +1 -1
  266. package/src/op/discover-root.test.ts +31 -2
  267. package/src/op/discover.ts +14 -4
  268. package/src/op/gate.test.ts +30 -0
  269. package/src/op/gate.ts +3 -0
  270. package/src/op/op-waves-run.ts +20 -5
  271. package/src/op/op-waves.test.ts +49 -0
  272. package/src/op/operator.ts +2 -2
  273. package/src/op/steward.ts +6 -4
  274. package/src/op/work-lease-run.test.ts +88 -2
  275. package/src/op/work-lease-run.ts +52 -12
  276. package/src/pr-forge-bundle.test.ts +2 -2
  277. package/src/pr-forge.test.ts +42 -0
  278. package/src/pr-forge.ts +40 -0
  279. package/src/pr-loop-bundle.test.ts +2 -1
  280. package/src/pr-loop.test.ts +117 -1
  281. package/src/pr-loop.ts +252 -10
  282. package/src/pr-report-url.ts +5 -5
  283. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  284. package/src/ts-loader.test.ts +89 -1
  285. package/src/ts-loader.ts +180 -13
  286. package/src/workspace/changes.test.ts +1 -1
  287. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  288. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  289. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  290. package/src/workspace/chant-migrations.ts +2 -0
  291. package/src/workspace/checks/boxes.ts +9 -9
  292. package/src/workspace/ci-green-forge.ts +6 -4
  293. package/src/workspace/ci-green-runs.test.ts +53 -11
  294. package/src/workspace/ci-green.test.ts +1 -1
  295. package/src/workspace/ci-green.ts +50 -22
  296. package/src/workspace/ci-list.schema.json +12 -6
  297. package/src/workspace/ci-report-url.test.ts +220 -0
  298. package/src/workspace/ci-report-url.ts +33 -0
  299. package/src/workspace/ci-run-index.schema.json +5 -3
  300. package/src/workspace/ci-run-index.test.ts +42 -3
  301. package/src/workspace/ci-run-index.ts +18 -4
  302. package/src/workspace/ci-run.test.ts +185 -7
  303. package/src/workspace/ci-run.ts +224 -54
  304. package/src/workspace/ci-runner-key.test.ts +55 -17
  305. package/src/workspace/ci-runner-key.ts +36 -11
  306. package/src/workspace/ci-serve.test.ts +150 -6
  307. package/src/workspace/ci-serve.ts +169 -25
  308. package/src/workspace/declaration.schema.json +7 -2
  309. package/src/workspace/declaration.test.ts +23 -0
  310. package/src/workspace/declaration.ts +19 -3
  311. package/src/workspace/export.ts +6 -3
  312. package/src/workspace/ls.schema.json +7 -1
  313. package/src/workspace/ls.ts +7 -2
  314. package/src/workspace/pin-integrity.test.ts +5 -0
  315. package/src/workspace/pin-integrity.ts +3 -2
  316. package/src/workspace/pr-report.schema.json +74 -7
  317. package/src/workspace/reason-codes.ts +2 -2
  318. package/src/workspace/run-event.schema.json +3 -1
  319. package/src/workspace/status.schema.json +3 -1
  320. package/src/workspace/write-scope.test.ts +29 -1
  321. package/src/workspace/write-scope.ts +12 -1
@@ -4,28 +4,33 @@
4
4
  * real `tofu` roots is `lexicons/terraform/src/components/pr-loop.tofu.test.ts`.
5
5
  *
6
6
  * The capability stands in for `terraform-apply`: `plan` returns the root, a
7
- * digest over what it would write (its `version` and every input) and a
8
- * change-set part; `outputs` reads what it exposes now; `run` applies.
7
+ * digest over what it would write (its `version` and every input), a
8
+ * change-set part and, where `predict` says, the outputs it will expose
9
+ * (#3999); `outputs` reads what it exposes now; `run` applies.
9
10
  */
10
11
 
11
12
  import { describe, expect, test } from "vitest";
12
- import { CapabilityRegistry, type DeployContext } from "./capability";
13
+ import { CapabilityRegistry, type DeployContext, type PlannedOutputs } from "./capability";
13
14
  import { memoryGateLedgerPort } from "../op/gate";
14
15
  import { planFanOut } from "./fan-out";
15
16
  import {
16
17
  applyPrSet,
17
18
  approvalOf,
19
+ approversWhoAuthored,
18
20
  approversWithoutReview,
19
21
  decidePrGate,
20
22
  planCoveredBy,
21
23
  planPrSet,
22
24
  prReport,
25
+ prWavePlan,
23
26
  readOnlyLedger,
24
27
  resumePrSet,
28
+ waveNotCovered,
25
29
  type PrPlanSet,
26
30
  } from "./pr-run";
27
31
  import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
28
32
  import type { PrApplyRecord } from "./fan-out-record";
33
+ import { renderPrNote } from "../pr-loop";
29
34
  import type { FanOutPlan } from "./fan-out";
30
35
  import { computePlanDigest } from "../lifecycle/plan-digest";
31
36
  import type { DriverComponent } from "./driver";
@@ -65,7 +70,17 @@ function world() {
65
70
  fromPlan: {} as Record<string, boolean>,
66
71
  failing: new Set<string>(),
67
72
  planFailing: new Set<string>(),
73
+ /**
74
+ * What each root's plan says it will expose (#3999). By default `net`
75
+ * keeps the cidr it has, and `a` will expose the id its version gives,
76
+ * as a terraform plan's `planned_values` would say.
77
+ */
78
+ predict: {} as Record<string, (() => PlannedOutputs | undefined) | undefined>,
79
+ /** What `a` writes when it applies, when not what its version gives. */
80
+ appliesAs: undefined as string | undefined,
68
81
  };
82
+ w.predict.net = () => ({ known: { ...(w.state.net ?? {}) }, unknown: [] });
83
+ w.predict.a = () => ({ known: { id: `a-${w.versions.a ?? "v1"}` }, unknown: [] });
69
84
  const digestOf = (input: RootInput) => computePlanDigest("fake-root", { root: input.root, version: w.versions[input.root] ?? "v1", vars: input.vars ?? null });
70
85
  w.registry.register({
71
86
  kind: "fake-root",
@@ -74,10 +89,12 @@ function world() {
74
89
  if (w.planFailing.has(input.root)) throw new Error(`${input.root}: no value for a required variable`);
75
90
  const planDigest = digestOf(input);
76
91
  const changes = (w.versions[input.root] ?? "v1") !== "v1";
92
+ const outputs = w.predict[input.root]?.();
77
93
  return {
78
94
  member: input.root,
79
95
  planDigest,
80
96
  artifact: { planDigest },
97
+ ...(outputs ? { outputs } : {}),
81
98
  changeSet: {
82
99
  member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] },
83
100
  entries: changes
@@ -105,7 +122,7 @@ function world() {
105
122
  w.ranWith[input.root] = input.vars;
106
123
  if (w.failing.has(input.root)) throw new Error(`${input.root} failed`);
107
124
  w.applied.push(input.root);
108
- if (input.root === "a") w.state.a = { id: `a-${w.versions.a ?? "v1"}` };
125
+ if (input.root === "a") w.state.a = { id: w.appliesAs ?? `a-${w.versions.a ?? "v1"}` };
109
126
  return { outputs: { ...(w.state[input.root] ?? {}) } };
110
127
  },
111
128
  } as never);
@@ -126,19 +143,23 @@ const approval = (planDigest: string, resolvedBy = "github:alice") => ({
126
143
  });
127
144
 
128
145
  describe("planPrSet", () => {
129
- test("plans the changed root and its dependents, each against the outputs its dependency has now", async () => {
146
+ test("plans the changed root and its dependents: a dependency outside the change as deployed, one in it as its plan says (#3999)", async () => {
130
147
  const w = world();
131
148
  w.versions.a = "v2";
132
149
  const plan = planFanOut({ components: ESTATE, changed: ["a"] });
133
150
  const set = await planPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod" });
134
151
 
135
152
  expect(plan.order).toEqual(["a", "app"]);
153
+ // app reads the id a's plan says a will have, not the one a has now.
136
154
  expect(w.planned).toEqual([
137
155
  { root: "a", vars: { cidr: "10.0.0.0/16" } },
138
- { root: "app", vars: { subnet: "a-v1" } },
156
+ { root: "app", vars: { subnet: "a-v2" } },
139
157
  ]);
140
- // net is read for a, and a for app, before anything applies.
141
- expect(w.outputsRead).toEqual(["net", "a"]);
158
+ // net is read from the estate; a is not read at all.
159
+ expect(w.outputsRead).toEqual(["net"]);
160
+ expect(set.deployedOutputs).toEqual({ net: ["cidr"] });
161
+ expect(set.plannedOutputs).toEqual({ a: ["id"] });
162
+ expect(set.members.every((m) => m.unknownInputs === undefined)).toBe(true);
142
163
  expect(set.members.map((m) => [m.member, m.status, m.counts.update])).toEqual([
143
164
  ["a", "planned", 1],
144
165
  ["app", "planned", 0],
@@ -281,7 +302,8 @@ describe("a dependency outside the selection (#4104)", () => {
281
302
  const plan = planFanOut({ components: ESTATE, changed: ["a"] });
282
303
  const { set, first, record } = await firstAttempt(w, plan, ["app"]);
283
304
  expect(first.run.completed).toEqual(["a"]);
284
- expect(set.deployedOutputs).toEqual({ a: ["id"], net: ["cidr"] });
305
+ expect(set.deployedOutputs).toEqual({ net: ["cidr"] });
306
+ expect(set.plannedOutputs).toEqual({ a: ["id"] });
285
307
 
286
308
  w.outputsRead.length = 0;
287
309
  w.state.net = { cidr: "10.9.0.0/16" };
@@ -318,22 +340,29 @@ describe("the gate", () => {
318
340
  expect(approversWithoutReview(["github:alice", "GitHub:Bob"], ["github:alice", "github:bob"])).toEqual([]);
319
341
  expect(approversWithoutReview(["github:mallory"], ["github:alice"])).toEqual(["github:mallory"]);
320
342
  });
343
+
344
+ test("an approver who wrote the change is named, the forge's naming compared without case (#4000)", () => {
345
+ expect(approversWhoAuthored(["github:alice", "GitHub:Carol"], ["github:carol", "github:dave"])).toEqual(["GitHub:Carol"]);
346
+ expect(approversWhoAuthored(["github:alice"], ["github:carol"])).toEqual([]);
347
+ });
321
348
  });
322
349
 
323
350
  describe("applyPrSet", () => {
324
- test("applies each member's approved plan in order and marks a dependent whose inputs moved", async () => {
351
+ test("applies each member's approved plan in order; a dependent planned on the output its upstream applied as applies as approved (#3999)", async () => {
325
352
  const w = world();
326
353
  w.versions.a = "v2";
327
354
  const plan = planFanOut({ components: ESTATE, changed: ["a"] });
328
355
  const set = await planPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod" });
329
- const { members, status } = await applyPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", set });
356
+ const { members, status, rounds } = await applyPrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", set });
330
357
 
331
358
  expect(status).toBe("applied");
332
359
  expect(w.applied).toEqual(["a", "app"]);
333
360
  expect(w.fromPlan).toEqual({ a: true, app: true });
334
- expect(members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
335
- ["a", "applied", undefined],
336
- ["app", "applied", ["a"]],
361
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
362
+ expect(rounds).toBeUndefined();
363
+ expect(members.map((m) => [m.member, m.status, m.inputsMoved, m.round])).toEqual([
364
+ ["a", "applied", undefined, undefined],
365
+ ["app", "applied", undefined, undefined],
337
366
  ]);
338
367
  });
339
368
 
@@ -355,6 +384,226 @@ describe("applyPrSet", () => {
355
384
  });
356
385
  });
357
386
 
387
+ describe("a dependent planned on its upstream's planned outputs (#3999)", () => {
388
+ /** `a` and `app` reading its `id`, the change touching `a`. */
389
+ const plan = () => planFanOut({ components: ESTATE, changed: ["a"] });
390
+ const approveAll = (digest: string, gate: string) =>
391
+ decidePrGate(memoryGateLedgerPort({ resolutions: [{ ...approval(digest), gate }] }), { op: "pr-12", gate, digest, now: NOW });
392
+ const decideWith = (approved: Set<string>) => async ({ gate, set }: { gate: string; set: PrPlanSet }) => {
393
+ const check = approved.has(set.doc.digest) ? await approveAll(set.doc.digest, gate) : await decidePrGate(readOnlyLedger(memoryGateLedgerPort()), { op: "pr-12", gate, digest: set.doc.digest, now: NOW });
394
+ return { satisfied: check.satisfied, approval: approvalOf(check) };
395
+ };
396
+
397
+ test("an output the change leaves as it is: the dependent plans on the planned value, which is the deployed one", async () => {
398
+ const w = world();
399
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
400
+ expect(w.planned.find((p) => p.root === "app")?.vars).toEqual({ subnet: "a-v1" });
401
+ expect(set.plannedOutputs).toEqual({ a: ["id"] });
402
+ const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
403
+ expect(applied.status).toBe("applied");
404
+ expect(w.applied).toEqual(["a", "app"]);
405
+ });
406
+
407
+ test("an output the change moves: the dependent plans on the moved value, not the one standing now", async () => {
408
+ const w = world();
409
+ w.versions.a = "v2";
410
+ expect(w.state.a).toEqual({ id: "a-v1" });
411
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
412
+ expect(w.planned.find((p) => p.root === "app")?.vars).toEqual({ subnet: "a-v2" });
413
+ expect(w.outputsRead).not.toContain("a");
414
+ // The reviewer approves app's plan against a-v2, and that is what applies.
415
+ const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
416
+ expect(applied.status).toBe("applied");
417
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
418
+ });
419
+
420
+ test("an output known only after apply is listed on the dependent, which the apply plans again and gates once its upstream applied", async () => {
421
+ const w = world();
422
+ w.versions.a = "v2";
423
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
424
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
425
+ expect(set.failed).toBe(false);
426
+ expect(w.planned.find((p) => p.root === "app")?.vars?.subnet).toBeUndefined();
427
+ expect(set.plannedOutputs).toEqual({ a: [] });
428
+ expect(set.members.map((m) => [m.member, m.unknownInputs])).toEqual([
429
+ ["a", undefined],
430
+ ["app", ["a.id"]],
431
+ ]);
432
+
433
+ // No approval of the plan made after a applied: a applies, app waits.
434
+ const waiting = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set, decideRound: decideWith(new Set()) });
435
+ expect(waiting.status).toBe("waiting");
436
+ expect(w.applied).toEqual(["a"]);
437
+ expect(waiting.rounds).toHaveLength(1);
438
+ const round = waiting.rounds![0]!;
439
+ expect(round).toMatchObject({ round: 2, gate: "pr-apply-round-2", components: ["app"], status: "waiting", approval: { status: "pending" } });
440
+ // Round 2 planned app against the id a applied as.
441
+ expect(w.planned.at(-1)).toEqual({ root: "app", vars: { subnet: "a-v2" } });
442
+ const app = waiting.members.find((m) => m.member === "app")!;
443
+ expect(app).toMatchObject({ status: "waiting", round: 2, held: "its plan read a.id before that output applied as another value" });
444
+ expect(app.planDigest).toBe(round.changeSet.members[0]!.planDigest);
445
+ expect(app.planDigest).not.toBe(set.members.find((m) => m.member === "app")!.planDigest);
446
+
447
+ // With that plan approved, app applies against the value a applied as.
448
+ const w2 = world();
449
+ w2.versions.a = "v2";
450
+ w2.predict.a = () => ({ known: {}, unknown: ["id"] });
451
+ const set2 = await planPrSet({ components: ESTATE, plan: plan(), registry: w2.registry, env: "prod" });
452
+ const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w2.registry, env: "prod", set: set2, decideRound: decideWith(new Set([round.digest])) });
453
+ expect(applied.status).toBe("applied");
454
+ expect(w2.applied).toEqual(["a", "app"]);
455
+ expect(w2.ranWith.app).toEqual({ subnet: "a-v2" });
456
+ expect(applied.rounds?.map((r) => [r.round, r.status, r.approval.status])).toEqual([[2, "applied", "approved"]]);
457
+ expect(applied.members.find((m) => m.member === "app")).toMatchObject({ status: "applied", round: 2 });
458
+ });
459
+
460
+ test("an upstream new in the same pull request plans, and so does its dependent", async () => {
461
+ const pair: DriverComponent[] = [root("vpc"), root("svc", ["vpc"], { vpc: { stackOutput: { stack: "vpc", name: "id" } } })];
462
+ const w = world();
463
+ w.state = {};
464
+ // A new root: its plan creates it, and its id is known only after apply.
465
+ w.versions.vpc = "v2";
466
+ w.predict.vpc = () => ({ known: {}, unknown: ["id"] });
467
+ const p = planFanOut({ components: pair, changed: ["vpc", "svc"] });
468
+ const set = await planPrSet({ components: pair, plan: p, registry: w.registry, env: "prod" });
469
+ expect(set.failed).toBe(false);
470
+ expect(set.members.map((m) => [m.member, m.status, m.unknownInputs])).toEqual([
471
+ ["svc", "planned", ["vpc.id"]],
472
+ ["vpc", "planned", undefined],
473
+ ]);
474
+ expect(w.outputsRead).toEqual([]);
475
+ expect(set.deployedOutputs).toBeUndefined();
476
+ });
477
+
478
+ test("an upstream that applies as another value than its plan said holds the dependent back, which does not apply its approved plan", async () => {
479
+ const w = world();
480
+ w.versions.a = "v2";
481
+ w.appliesAs = "a-v2-real";
482
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
483
+ expect(w.planned.find((p) => p.root === "app")?.vars).toEqual({ subnet: "a-v2" });
484
+
485
+ // Without a way to decide a later round, app is left waiting.
486
+ const held = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
487
+ expect(held.status).toBe("waiting");
488
+ expect(w.applied).toEqual(["a"]);
489
+ expect(held.members.find((m) => m.member === "app")).toMatchObject({ status: "waiting", held: "its plan read a.id before that output applied as another value" });
490
+ expect(held.run.held).toEqual([{ component: "app", reason: "its plan read a.id before that output applied as another value" }]);
491
+ });
492
+
493
+ test("a resumed apply carries on from a waiting round once its plan is approved", async () => {
494
+ const w = world();
495
+ w.versions.a = "v2";
496
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
497
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
498
+ const first = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set, decideRound: decideWith(new Set()) });
499
+ expect(first.status).toBe("waiting");
500
+ const roundDigest = first.rounds![0]!.digest;
501
+
502
+ const record = recordOf(set);
503
+ const resumed = await resumePrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", record, completed: ["a"] });
504
+ if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
505
+ expect(resumed.plan.order).toEqual(["app"]);
506
+ const second = await applyPrSet({
507
+ components: ESTATE,
508
+ plan: resumed.plan,
509
+ registry: w.registry,
510
+ env: "prod",
511
+ set: resumed.set,
512
+ resumed: { completed: ["a"], outputs: { a: first.run.componentOutputs.a! } },
513
+ decideRound: decideWith(new Set([roundDigest])),
514
+ });
515
+ expect(second.status).toBe("applied");
516
+ expect(second.rounds?.[0]?.digest).toBe(roundDigest);
517
+ expect(w.applied).toEqual(["a", "app"]);
518
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
519
+ });
520
+ });
521
+
522
+ describe("a digest a build in the same pull request publishes (#3999)", () => {
523
+ /** `web-image` builds and publishes an image; `web` deploys it, and with `withSubnet` also reads `a.id`. */
524
+ const estate = (withSubnet = false): DriverComponent[] => [
525
+ root("a"),
526
+ { name: "web-image", dependsOn: [], deploy: [{ phase: "Publish", steps: [{ kind: "fake-build" }] }] },
527
+ root("web", withSubnet ? ["web-image", "a"] : ["web-image"], {
528
+ image: "@web-image.publish.digest",
529
+ ...(withSubnet ? { subnet: { stackOutput: { stack: "a", name: "id" } } } : {}),
530
+ }),
531
+ ];
532
+ function buildWorld() {
533
+ const w = world();
534
+ let n = 0;
535
+ w.registry.register({
536
+ kind: "fake-build",
537
+ async run() {
538
+ n++;
539
+ return { uri: "registry.example/web", digest: `sha256:built-${n}` };
540
+ },
541
+ } as never);
542
+ return w;
543
+ }
544
+ const neverApproved = async () => ({ satisfied: false, approval: { status: "pending" as const } });
545
+
546
+ test("image plus deploy in one pull request: the deploy plans with the digest unknown, lists it as the build's, and applies in one round", async () => {
547
+ const w = buildWorld();
548
+ const components = estate();
549
+ const plan = planFanOut({ components, changed: ["web-image", "web"] });
550
+ const set = await planPrSet({ components, plan, registry: w.registry, env: "prod" });
551
+ expect(set.failed).toBe(false);
552
+ const web = set.members.find((m) => m.member === "web")!;
553
+ expect(web.buildInputs).toEqual(["web-image.publish.digest"]);
554
+ expect(web.unknownInputs).toBeUndefined();
555
+
556
+ let asked = 0;
557
+ const applied = await applyPrSet({ components, plan, registry: w.registry, env: "prod", set, decideRound: async () => (asked++, neverApproved()) });
558
+ expect(applied.status).toBe("applied");
559
+ expect(asked).toBe(0);
560
+ expect(applied.rounds).toBeUndefined();
561
+ expect(w.ranWith.web).toEqual({ image: "sha256:built-1" });
562
+ expect(applied.members.find((m) => m.member === "web")).toMatchObject({ status: "applied" });
563
+
564
+ const note = renderPrNote(prReport({ stage: "plan", pr: 12, env: "prod", base: "1".repeat(40), head: "2".repeat(40), op: "pr-12", gate: "pr-apply", plan, signal: { changed: ["web-image", "web"], indeterminate: [], unclaimed: [] }, set, approval: { status: "pending" }, status: "planned" }));
565
+ expect(note).toContain("From this pull request's build: `web` reads `web-image.publish.digest`. The image digest comes from this pull request's build");
566
+ expect(note).not.toContain("Known only after apply");
567
+ });
568
+
569
+ test("a deploy that also reads an output known only after apply still goes to round 2", async () => {
570
+ const w = buildWorld();
571
+ w.versions.a = "v2";
572
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
573
+ const components = estate(true);
574
+ const plan = planFanOut({ components, changed: ["a", "web-image", "web"] });
575
+ const set = await planPrSet({ components, plan, registry: w.registry, env: "prod" });
576
+ const web = set.members.find((m) => m.member === "web")!;
577
+ expect(web.buildInputs).toEqual(["web-image.publish.digest"]);
578
+ expect(web.unknownInputs).toEqual(["a.id"]);
579
+
580
+ const applied = await applyPrSet({ components, plan, registry: w.registry, env: "prod", set, decideRound: neverApproved });
581
+ expect(applied.status).toBe("waiting");
582
+ expect(w.ranWith.web).toBeUndefined();
583
+ // Held for a.id alone: the image digest is not a reason.
584
+ expect(applied.members.find((m) => m.member === "web")).toMatchObject({ status: "waiting", round: 2, held: "its plan read a.id before that output applied as another value" });
585
+ expect(applied.rounds?.map((r) => [r.gate, r.components])).toEqual([["pr-apply-round-2", ["web"]]]);
586
+ });
587
+
588
+ test("under --wave-gate, the approved set covers a wave whose member waited only on the build's digest", () => {
589
+ const hole = (member: string, digest: string): ChangeSetPart => ({
590
+ member: { member, lexicon: "aws", planner: "chant", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes: [{ address: member, reason: "inputs from web-image; re-checked on apply" }] },
591
+ entries: [],
592
+ });
593
+ const approvedDoc = composeChangeSet([hole("web", "1")]);
594
+ const fresh = composeChangeSet([
595
+ {
596
+ member: { member: "web", lexicon: "aws", planner: "chant", status: "planned", planDigest: `jcs1-sha256:${"2".repeat(64)}`, holes: [] },
597
+ entries: [{ member: "web", lexicon: "aws", planner: "chant", address: "Service", type: "AWS::ECS::Service", action: "update", attributes: [{ path: "Image", after: "sha256:built-1" }] }],
598
+ },
599
+ ]);
600
+ const member = (extra: Record<string, unknown>) => [{ member: "web", component: "web", planDigest: null, status: "planned" as const, counts: { create: 0, update: 0, replace: 0, delete: 0 }, ...extra }];
601
+ expect(waveNotCovered(approvedDoc, fresh, member({ buildInputs: ["web-image.publish.digest"] }))).toEqual([]);
602
+ expect(waveNotCovered(approvedDoc, fresh, member({ buildInputs: ["web-image.publish.digest"], unknownInputs: ["a.id"] }))).toHaveLength(1);
603
+ expect(waveNotCovered(approvedDoc, fresh)).toHaveLength(1);
604
+ });
605
+ });
606
+
358
607
  /** The record a first attempt leaves (`pr-apply --resume`), from the set it applied. */
359
608
  function recordOf(set: PrPlanSet, approvedBy = "github:alice"): PrApplyRecord {
360
609
  return {
@@ -463,7 +712,7 @@ describe("resuming an apply that failed partway (#3464)", () => {
463
712
  expect(report.resumed).toEqual({ applied: ["b", "dns", "net"] });
464
713
  });
465
714
 
466
- test("a dependent whose dependency applied in the earlier attempt applies its approved plan and says its inputs moved", async () => {
715
+ test("a dependent whose dependency applied as planned in the earlier attempt applies its approved plan", async () => {
467
716
  const w = world();
468
717
  w.versions.a = "v2";
469
718
  const plan = planFanOut({ components: ESTATE, changed: ["a"] });
@@ -475,8 +724,8 @@ describe("resuming an apply that failed partway (#3464)", () => {
475
724
  w.planned.length = 0;
476
725
  const resumed = await resumePrSet({ components: ESTATE, plan, registry: w.registry, env: "prod", record, completed: ["a"] });
477
726
  if (!resumed.ok) throw new Error(resumed.reasons.join("; "));
478
- // app is planned against the value it was approved with, not the one a wrote since.
479
- expect(w.planned).toEqual([{ root: "app", vars: { subnet: "a-v1" } }]);
727
+ // app is planned against the value it was approved with: the one a's plan said, which a wrote.
728
+ expect(w.planned).toEqual([{ root: "app", vars: { subnet: "a-v2" } }]);
480
729
 
481
730
  const second = await applyPrSet({
482
731
  components: ESTATE,
@@ -486,9 +735,11 @@ describe("resuming an apply that failed partway (#3464)", () => {
486
735
  set: resumed.set,
487
736
  resumed: { completed: ["a"], outputs },
488
737
  });
738
+ expect(second.status).toBe("applied");
739
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
489
740
  expect(second.members.map((m) => [m.member, m.status, m.inputsMoved])).toEqual([
490
741
  ["a", "applied", undefined],
491
- ["app", "applied", ["a"]],
742
+ ["app", "applied", undefined],
492
743
  ]);
493
744
  });
494
745
 
@@ -637,3 +888,57 @@ describe("planCoveredBy", () => {
637
888
  expect(planCoveredBy(approved, other, "edge")).toBe("edge was not in the approved plan");
638
889
  });
639
890
  });
891
+
892
+ describe("a gate per wave on merge (#4003)", () => {
893
+ const estate = [root("net"), root("a", ["net"]), root("b", ["net"]), root("app", ["a"])];
894
+ const plan = planFanOut({ components: estate, changed: ["net"] });
895
+
896
+ test("prWavePlan narrows the derivation to one wave, less what applied, with the earlier waves as seeds and the same digest", () => {
897
+ expect(plan.waves).toEqual([["net"], ["a", "b"], ["app"]]);
898
+ const two = prWavePlan(plan, estate, 2);
899
+ expect(two).toMatchObject({ order: ["a", "b"], waves: [["a", "b"]], seeds: ["net"], digest: plan.digest });
900
+ expect(prWavePlan(plan, estate, 2, ["a"])).toMatchObject({ order: ["b"], waves: [["b"]] });
901
+ expect(prWavePlan(plan, estate, 2, ["a", "b"])).toMatchObject({ order: [], waves: [] });
902
+ expect(prWavePlan(plan, estate, 4)).toMatchObject({ order: [], waves: [] });
903
+ });
904
+
905
+ const member = (name: string, digest: string, planner: "tofu" | "chant" = "tofu") => ({
906
+ member: name,
907
+ lexicon: planner === "tofu" ? "terraform" : "chant",
908
+ planner,
909
+ status: "planned" as const,
910
+ planDigest: `jcs1-sha256:${digest.repeat(64)}`,
911
+ holes: [],
912
+ });
913
+ const write = (name: string, cidr: unknown): ChangeSetEntry => ({
914
+ member: name,
915
+ lexicon: "terraform",
916
+ planner: "tofu",
917
+ address: `${name}.main`,
918
+ type: "aws_subnet",
919
+ action: "update",
920
+ attributes: cidr === undefined ? [{ path: "cidr", unknown: true }] : [{ path: "cidr", after: cidr }],
921
+ });
922
+
923
+ test("waveNotCovered: the same plan, or one the approved plan knew only after apply, is covered; a moved value is not", () => {
924
+ const approved = composeChangeSet([
925
+ { member: member("a", "1"), entries: [write("a", "10.0.0.0/16")] },
926
+ { member: member("b", "2"), entries: [write("b", undefined)] },
927
+ ]);
928
+ const same = composeChangeSet([{ member: member("a", "1"), entries: [write("a", "10.0.0.0/16")] }]);
929
+ expect(waveNotCovered(approved, same)).toEqual([]);
930
+ // b's cidr was known only after net applied: the value it has now is covered.
931
+ const known = composeChangeSet([{ member: member("b", "3"), entries: [write("b", "10.1.0.0/16")] }]);
932
+ expect(waveNotCovered(approved, known)).toEqual([]);
933
+ const moved = composeChangeSet([{ member: member("a", "4"), entries: [write("a", "10.1.0.0/16")] }]);
934
+ expect(waveNotCovered(approved, moved)).toEqual(["a now writes a.main.cidr to a different value than the approved plan"]);
935
+ });
936
+
937
+ test("waveNotCovered: a member whose planner describes no changes is covered only by the digest it was approved with", () => {
938
+ const approved = composeChangeSet([{ member: member("job", "1", "chant"), entries: [] }]);
939
+ expect(waveNotCovered(approved, composeChangeSet([{ member: member("job", "1", "chant"), entries: [] }]))).toEqual([]);
940
+ const moved = waveNotCovered(approved, composeChangeSet([{ member: member("job", "2", "chant"), entries: [] }]));
941
+ expect(moved).toHaveLength(1);
942
+ 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
+ });
944
+ });