@intentius/chant 0.124.50 → 0.124.52

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 (37) hide show
  1. package/dist/cli/handlers/pr.d.ts.map +1 -1
  2. package/dist/cli/handlers/pr.js +2 -1
  3. package/dist/components/capability.d.ts +9 -0
  4. package/dist/components/capability.d.ts.map +1 -1
  5. package/dist/components/covered-round.d.ts +173 -0
  6. package/dist/components/covered-round.d.ts.map +1 -0
  7. package/dist/components/covered-round.js +200 -0
  8. package/dist/components/driver.d.ts +8 -0
  9. package/dist/components/driver.d.ts.map +1 -1
  10. package/dist/components/driver.js +8 -0
  11. package/dist/components/index.d.ts +1 -0
  12. package/dist/components/index.d.ts.map +1 -1
  13. package/dist/components/index.js +1 -0
  14. package/dist/components/pr-run.d.ts +21 -3
  15. package/dist/components/pr-run.d.ts.map +1 -1
  16. package/dist/components/pr-run.js +147 -16
  17. package/dist/components/wave-plan.d.ts +2 -0
  18. package/dist/components/wave-plan.d.ts.map +1 -1
  19. package/dist/components/wave-plan.js +4 -1
  20. package/dist/pr-loop.d.ts +36 -2
  21. package/dist/pr-loop.d.ts.map +1 -1
  22. package/dist/pr-loop.js +11 -2
  23. package/dist/wave-runtime.mjs +3 -3
  24. package/dist/workspace/pr-report.schema.json +25 -1
  25. package/package.json +1 -1
  26. package/src/cli/handlers/pr.ts +2 -1
  27. package/src/components/capability.ts +9 -0
  28. package/src/components/covered-round.test.ts +166 -0
  29. package/src/components/covered-round.ts +293 -0
  30. package/src/components/driver.ts +12 -0
  31. package/src/components/index.ts +17 -0
  32. package/src/components/pr-run.test.ts +212 -1
  33. package/src/components/pr-run.ts +166 -16
  34. package/src/components/wave-plan.ts +5 -2
  35. package/src/pr-loop.test.ts +14 -1
  36. package/src/pr-loop.ts +43 -4
  37. package/src/workspace/pr-report.schema.json +25 -1
@@ -30,7 +30,7 @@ import {
30
30
  } from "./pr-run";
31
31
  import { changeSetDigest, changeSetDocumentDigest, composeChangeSet, type ChangeSetEntry, type ChangeSetPart } from "../change-set";
32
32
  import type { PrApplyRecord } from "./fan-out-record";
33
- import { renderPrNote } from "../pr-loop";
33
+ import { renderPrNote, type PrApproval } from "../pr-loop";
34
34
  import type { FanOutPlan } from "./fan-out";
35
35
  import { computePlanDigest } from "../lifecycle/plan-digest";
36
36
  import type { DriverComponent } from "./driver";
@@ -78,6 +78,8 @@ function world() {
78
78
  predict: {} as Record<string, (() => PlannedOutputs | undefined) | undefined>,
79
79
  /** What `a` writes when it applies, when not what its version gives. */
80
80
  appliesAs: undefined as string | undefined,
81
+ /** `a` applying makes `app` plan a change its first plan did not show. */
82
+ bumpAppOnApply: false,
81
83
  };
82
84
  w.predict.net = () => ({ known: { ...(w.state.net ?? {}) }, unknown: [] });
83
85
  w.predict.a = () => ({ known: { id: `a-${w.versions.a ?? "v1"}` }, unknown: [] });
@@ -122,6 +124,7 @@ function world() {
122
124
  w.ranWith[input.root] = input.vars;
123
125
  if (w.failing.has(input.root)) throw new Error(`${input.root} failed`);
124
126
  w.applied.push(input.root);
127
+ if (input.root === "a" && w.bumpAppOnApply) w.versions.app = "v2";
125
128
  if (input.root === "a") w.state.a = { id: w.appliesAs ?? `a-${w.versions.a ?? "v1"}` };
126
129
  return { outputs: { ...(w.state[input.root] ?? {}) } };
127
130
  },
@@ -404,6 +407,19 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
404
407
  expect(w.applied).toEqual(["a", "app"]);
405
408
  });
406
409
 
410
+ test("each member names every upstream output it reads, the known ones too, and the apply report keeps them", async () => {
411
+ const w = world();
412
+ w.versions.a = "v2";
413
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
414
+ // a reads net, which is not in the change: a read of a deployed output is a read too.
415
+ expect(set.members.map((m) => [m.member, m.reads])).toEqual([
416
+ ["a", [{ upstream: "net", outputs: ["cidr"] }]],
417
+ ["app", [{ upstream: "a", outputs: ["id"] }]],
418
+ ]);
419
+ const applied = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set });
420
+ expect(applied.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"] }]);
421
+ });
422
+
407
423
  test("an output the change moves: the dependent plans on the moved value, not the one standing now", async () => {
408
424
  const w = world();
409
425
  w.versions.a = "v2";
@@ -429,6 +445,7 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
429
445
  ["a", undefined],
430
446
  ["app", ["a.id"]],
431
447
  ]);
448
+ expect(set.members.find((m) => m.member === "app")?.reads).toEqual([{ upstream: "a", outputs: ["id"], unknown: ["id"] }]);
432
449
 
433
450
  // No approval of the plan made after a applied: a applies, app waits.
434
451
  const waiting = await applyPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", set, decideRound: decideWith(new Set()) });
@@ -457,6 +474,121 @@ describe("a dependent planned on its upstream's planned outputs (#3999)", () =>
457
474
  expect(applied.members.find((m) => m.member === "app")).toMatchObject({ status: "applied", round: 2 });
458
475
  });
459
476
 
477
+ const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
478
+
479
+ test("the first approval covers the round that only fills in what was unknown: no gate of its own, recorded as covered (#4419)", async () => {
480
+ const w = world();
481
+ w.versions.a = "v2";
482
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
483
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
484
+ let asked = 0;
485
+ const applied = await applyPrSet({
486
+ components: ESTATE,
487
+ plan: plan(),
488
+ registry: w.registry,
489
+ env: "prod",
490
+ set,
491
+ approval: approvedFirst,
492
+ decideRound: async () => {
493
+ asked++;
494
+ return { satisfied: false, approval: { status: "pending" } };
495
+ },
496
+ });
497
+ expect(asked).toBe(0);
498
+ expect(applied.status).toBe("applied");
499
+ expect(w.applied).toEqual(["a", "app"]);
500
+ expect(w.ranWith.app).toEqual({ subnet: "a-v2" });
501
+ expect(applied.rounds).toHaveLength(1);
502
+ expect(applied.rounds![0]).toMatchObject({
503
+ round: 2,
504
+ status: "applied",
505
+ approval: approvedFirst,
506
+ covered: { digest: set.doc.digest, filledInputs: ["a.id"] },
507
+ });
508
+ });
509
+
510
+ test("a round that plans a change the first plan did not show asks for its own approval, with the reason (#4419)", async () => {
511
+ const w = world();
512
+ w.versions.a = "v2";
513
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
514
+ w.bumpAppOnApply = true;
515
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
516
+ const seen: Array<string | undefined> = [];
517
+ const applied = await applyPrSet({
518
+ components: ESTATE,
519
+ plan: plan(),
520
+ registry: w.registry,
521
+ env: "prod",
522
+ set,
523
+ approval: approvedFirst,
524
+ decideRound: async ({ notCovered }) => {
525
+ seen.push(notCovered);
526
+ return { satisfied: false, approval: { status: "pending" } };
527
+ },
528
+ });
529
+ expect(applied.status).toBe("waiting");
530
+ expect(w.applied).toEqual(["a"]);
531
+ expect(seen).toEqual(["app now plans to update terraform_data.x, which the first plan did not"]);
532
+ expect(applied.rounds![0]).toMatchObject({ status: "waiting", notCovered: seen[0] });
533
+ expect(applied.rounds![0]!.covered).toBeUndefined();
534
+ });
535
+
536
+ 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 () => {
537
+ const chain: DriverComponent[] = [
538
+ root("x"),
539
+ root("y", ["x"], { id: { stackOutput: { stack: "x", name: "id" } } }),
540
+ root("z", ["y"], { port: { stackOutput: { stack: "y", name: "port" } } }),
541
+ ];
542
+ const w = world();
543
+ w.state = { x: { id: "x-v1" }, y: { port: "8080" }, z: {} };
544
+ w.versions.x = "v2";
545
+ w.versions.y = "v2";
546
+ w.versions.z = "v2";
547
+ w.predict.x = () => ({ known: {}, unknown: ["id"] });
548
+ w.predict.y = () => ({ known: { port: "8080" }, unknown: [] });
549
+ w.predict.z = () => undefined;
550
+ const chainPlan = planFanOut({ components: chain, changed: ["x"] });
551
+ const set = await planPrSet({ components: chain, plan: chainPlan, registry: w.registry, env: "prod" });
552
+ const seen: Array<string | undefined> = [];
553
+ const applied = await applyPrSet({
554
+ components: chain,
555
+ plan: chainPlan,
556
+ registry: w.registry,
557
+ env: "prod",
558
+ set,
559
+ approval: approvedFirst,
560
+ decideRound: async ({ notCovered }) => {
561
+ seen.push(notCovered);
562
+ return { satisfied: false, approval: { status: "pending" } };
563
+ },
564
+ });
565
+ expect(seen).toEqual([]);
566
+ expect(applied.status).toBe("applied");
567
+ expect(w.applied).toEqual(["x", "y", "z"]);
568
+ expect(applied.rounds![0]).toMatchObject({ round: 2, components: ["y", "z"], covered: { digest: set.doc.digest, filledInputs: ["x.id"] } });
569
+ });
570
+
571
+ test("an input the first plan read with a value that moved is not covered (#4419)", async () => {
572
+ const w = world();
573
+ w.versions.a = "v2";
574
+ w.appliesAs = "a-v2-real";
575
+ const set = await planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
576
+ const seen: Array<string | undefined> = [];
577
+ await applyPrSet({
578
+ components: ESTATE,
579
+ plan: plan(),
580
+ registry: w.registry,
581
+ env: "prod",
582
+ set,
583
+ approval: approvedFirst,
584
+ decideRound: async ({ notCovered }) => {
585
+ seen.push(notCovered);
586
+ return { satisfied: false, approval: { status: "pending" } };
587
+ },
588
+ });
589
+ expect(seen).toEqual(["app reads a.id, which the first plan read as another value"]);
590
+ });
591
+
460
592
  test("an upstream new in the same pull request plans, and so does its dependent", async () => {
461
593
  const pair: DriverComponent[] = [root("vpc"), root("svc", ["vpc"], { vpc: { stackOutput: { stack: "vpc", name: "id" } } })];
462
594
  const w = world();
@@ -889,6 +1021,85 @@ describe("planCoveredBy", () => {
889
1021
  });
890
1022
  });
891
1023
 
1024
+ /**
1025
+ * One decision for a round, a resume and a wave (#4422). The same estate and
1026
+ * the same two outcomes go through `applyPrSet`'s round, `resumePrSet` and
1027
+ * `waveNotCovered` (what `--wave-gate` uses), and each path must give the same
1028
+ * verdict with the same reason.
1029
+ */
1030
+ describe("one coverage decision on a round, a resume and a wave (#4422)", () => {
1031
+ const plan = () => planFanOut({ components: ESTATE, changed: ["a"] });
1032
+ const approvedFirst: PrApproval = { status: "approved", approvedBy: ["github:rev"] };
1033
+ /** `a` v2, whose id is known only after apply; `app` reads it. */
1034
+ async function first(w: ReturnType<typeof world>) {
1035
+ w.versions.a = "v2";
1036
+ w.predict.a = () => ({ known: {}, unknown: ["id"] });
1037
+ return planPrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod" });
1038
+ }
1039
+
1040
+ const paths: Record<string, (extra: boolean) => Promise<string | undefined>> = {
1041
+ round: async (extra) => {
1042
+ const w = world();
1043
+ const set = await first(w);
1044
+ w.bumpAppOnApply = extra;
1045
+ let reason: string | undefined;
1046
+ const applied = await applyPrSet({
1047
+ components: ESTATE,
1048
+ plan: plan(),
1049
+ registry: w.registry,
1050
+ env: "prod",
1051
+ set,
1052
+ approval: approvedFirst,
1053
+ decideRound: async ({ notCovered }) => {
1054
+ reason = notCovered;
1055
+ return { satisfied: false, approval: { status: "pending" } };
1056
+ },
1057
+ });
1058
+ expect(applied.status).toBe(extra ? "waiting" : "applied");
1059
+ return reason;
1060
+ },
1061
+ resume: async (extra) => {
1062
+ const w = world();
1063
+ const set = await first(w);
1064
+ w.state.a = { id: "a-v2" };
1065
+ if (extra) w.versions.app = "v2";
1066
+ const resumed = await resumePrSet({ components: ESTATE, plan: plan(), registry: w.registry, env: "prod", record: recordOf(set), completed: ["a"] });
1067
+ return resumed.ok ? undefined : resumed.reasons[0];
1068
+ },
1069
+ wave: async (extra) => {
1070
+ const w = world();
1071
+ const set = await first(w);
1072
+ if (extra) w.versions.app = "v2";
1073
+ const wavePlan = prWavePlan(plan(), ESTATE, 2);
1074
+ const fresh = await planPrSet({ components: ESTATE, plan: wavePlan, registry: w.registry, env: "prod", seededOutputs: { ...set.outputs, a: { id: "a-v2" } } });
1075
+ return waveNotCovered(set.doc, fresh.doc, set.members)[0];
1076
+ },
1077
+ };
1078
+
1079
+ test.each([
1080
+ ["only the previewed unknown filled in", false, undefined],
1081
+ ["an entry the first plan did not show", true, "app now plans to update terraform_data.x, which the first plan did not"],
1082
+ ] as const)("%s", async (_name, extra, expected) => {
1083
+ for (const [path, decide] of Object.entries(paths)) {
1084
+ expect({ path, reason: await decide(extra) }).toEqual({ path, reason: expected });
1085
+ }
1086
+ });
1087
+
1088
+ test("a member that was a hole with no preview is not covered by an approval that never saw its shape", () => {
1089
+ const part = (digest: string, entries: ChangeSetEntry[], holes: ChangeSetPart["member"]["holes"]): ChangeSetPart => ({
1090
+ member: { member: "svc", lexicon: "aws", planner: "chant", status: "planned", planDigest: `jcs1-sha256:${digest.repeat(64)}`, holes },
1091
+ entries,
1092
+ });
1093
+ const approved = composeChangeSet([part("1", [], [{ address: "svc", reason: "inputs from net; re-checked on apply" }])]);
1094
+ const entry: ChangeSetEntry = { member: "svc", lexicon: "aws", planner: "chant", address: "Queue", type: "AWS::SQS::Queue", action: "create", attributes: [] };
1095
+ const fresh = composeChangeSet([part("2", [entry], [])]);
1096
+ const members = [{ member: "svc", component: "svc", planDigest: null, status: "planned" as const, counts: { create: 0, update: 0, replace: 0, delete: 0 }, unknownInputs: ["net.id"] }];
1097
+ // Even with the input named unknown, nothing was previewed to cover a create.
1098
+ expect(waveNotCovered(approved, fresh, members)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
1099
+ expect(waveNotCovered(approved, fresh)).toEqual(["svc now plans to create Queue, which the first plan did not"]);
1100
+ });
1101
+ });
1102
+
892
1103
  describe("a gate per wave on merge (#4003)", () => {
893
1104
  const estate = [root("net"), root("a", ["net"]), root("b", ["net"]), root("app", ["a"])];
894
1105
  const plan = planFanOut({ components: estate, changed: ["net"] });
@@ -23,8 +23,9 @@
23
23
  * component in the project and not the selection.
24
24
  * - the upstream is in the change and its plan cannot say, or says the value
25
25
  * is known only after apply: no value. The dependent's members list the
26
- * reference under `unknownInputs`, and the approval does not cover what
27
- * it will do with it.
26
+ * reference under `unknownInputs`. The apply plans it again once the
27
+ * upstream applied, and the approval covers that round when it matches the
28
+ * preview (see `coveredRound`).
28
29
  *
29
30
  * `--seed-outputs` wins over both. The apply stage's plan reads the same
30
31
  * values, so on the same estate it gets the digest the plan stage showed.
@@ -78,6 +79,7 @@ import {
78
79
  PR_REPORT_SCHEMA_ID,
79
80
  type PrApproval,
80
81
  type PrMember,
82
+ type PrMemberRead,
81
83
  type PrRefusal,
82
84
  type PrRound,
83
85
  type PrReport,
@@ -88,12 +90,14 @@ import {
88
90
  deployContext,
89
91
  movedWiringReferences,
90
92
  unresolvedWiringReferences,
93
+ wiringReads,
91
94
  type DriverComponent,
92
95
  type DriverComponentResult,
93
96
  } from "./driver";
94
97
  import { deployUnits } from "./deploy-units";
95
98
  import { runFanOut, type FanOutRunResult } from "./fan-out-run";
96
99
  import { remainingFanOut, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
100
+ import { coveredRound, type PlanShape, type RoundCoverage } from "./covered-round";
97
101
  import type { PrApplyRecord } from "./fan-out-record";
98
102
  import type { CapabilityRegistry, PlannedOutputs } from "./capability";
99
103
  import type { RunProgressEvent } from "./run-progress";
@@ -145,6 +149,8 @@ export interface PrPlanSet {
145
149
  * report's `plannedOutputs`.
146
150
  */
147
151
  plannedOutputs?: Record<string, string[]>;
152
+ /** The shapes lexicons supplied with their plans (#4419), by member. */
153
+ shapes?: Record<string, PlanShape>;
148
154
  /** Whether any component failed to plan. */
149
155
  failed: boolean;
150
156
  }
@@ -178,7 +184,9 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
178
184
  const parts: ChangeSetPart[] = [];
179
185
  const componentOf = new Map<string, string>();
180
186
  const unknownOf = new Map<string, { unknown: string[]; build: string[] }>();
187
+ const readsBy = new Map<string, PrMemberRead[]>();
181
188
  const plans: Record<string, Record<string, unknown>> = {};
189
+ const shapes: Record<string, PlanShape> = {};
182
190
  let failed = false;
183
191
  const emit = options.onRunEvent;
184
192
  emit?.({ type: "phase-start", component: null, phase: PR_PLAN_PHASE });
@@ -223,6 +231,8 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
223
231
  const component = byName.get(p.component)!;
224
232
  const unknown = unknownInputsOf(component, outputs, inChange);
225
233
  if (unknown.unknown.length > 0 || unknown.build.length > 0) unknownOf.set(p.component, unknown);
234
+ const reads = readsOf(component, outputs);
235
+ if (reads.length > 0) readsBy.set(p.component, reads);
226
236
  if ("error" in p) {
227
237
  failed = true;
228
238
  plannedBy.set(p.component, undefined);
@@ -234,6 +244,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
234
244
  }
235
245
  plannedBy.set(p.component, p.outputs);
236
246
  plans[p.component] = p.plans;
247
+ Object.assign(shapes, p.shapes);
237
248
  for (const part of p.parts) {
238
249
  parts.push(part);
239
250
  componentOf.set(part.member.member, p.component);
@@ -246,6 +257,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
246
257
  const members: PrMember[] = doc.members.map((m) => {
247
258
  const component = componentOf.get(m.member) ?? m.member;
248
259
  const unknown = unknownOf.get(component);
260
+ const reads = readsBy.get(component);
249
261
  return {
250
262
  member: m.member,
251
263
  component,
@@ -255,6 +267,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
255
267
  ...(m.error ? { error: m.error } : {}),
256
268
  ...(unknown?.unknown.length ? { unknownInputs: unknown.unknown } : {}),
257
269
  ...(unknown?.build.length ? { buildInputs: unknown.build } : {}),
270
+ ...(reads ? { reads } : {}),
258
271
  };
259
272
  });
260
273
  const sorted = (r: Record<string, string[]>) => Object.fromEntries(Object.entries(r).sort(([a], [b]) => a.localeCompare(b)));
@@ -266,6 +279,7 @@ export async function planPrSet(options: PrPlanSetOptions): Promise<PrPlanSet> {
266
279
  plans,
267
280
  outputs,
268
281
  failed,
282
+ ...(Object.keys(shapes).length > 0 ? { shapes } : {}),
269
283
  ...(Object.keys(deployedOutputs).length > 0 ? { deployedOutputs } : {}),
270
284
  ...(Object.keys(plannedOutputs).length > 0 ? { plannedOutputs } : {}),
271
285
  };
@@ -295,6 +309,31 @@ function unknownInputsOf(
295
309
  return { unknown: [...unknown].sort(), build: [...build].sort() };
296
310
  }
297
311
 
312
+ /**
313
+ * Every other component's output `component`'s steps read, one entry per
314
+ * upstream, sorted: what the report's `reads` says. A read whose value
315
+ * `outputs` holds is listed as well as one it lacks (`unknown`), so the
316
+ * report names each read between components whether or not the plan knew
317
+ * its value. A component's reads of its own outputs are left out.
318
+ */
319
+ function readsOf(component: DriverComponent, outputs: Record<string, Record<string, unknown>>): PrMemberRead[] {
320
+ const by = new Map<string, { outputs: Set<string>; unknown: Set<string> }>();
321
+ for (const step of capabilitySteps(component.deploy)) {
322
+ const { kind: _kind, ...rest } = step;
323
+ for (const r of wiringReads(rest, outputs)) {
324
+ if (r.source === component.name) continue;
325
+ const name = r.ref.slice(r.source.length + 1);
326
+ const at = by.get(r.source) ?? { outputs: new Set<string>(), unknown: new Set<string>() };
327
+ by.set(r.source, at);
328
+ at.outputs.add(name);
329
+ if (!r.known) at.unknown.add(name);
330
+ }
331
+ }
332
+ return [...by.entries()]
333
+ .sort(([a], [b]) => a.localeCompare(b))
334
+ .map(([upstream, r]) => ({ upstream, outputs: [...r.outputs].sort(), ...(r.unknown.size > 0 ? { unknown: [...r.unknown].sort() } : {}) }));
335
+ }
336
+
298
337
  /**
299
338
  * Why `component` cannot apply the plan that read `read` now that `now`
300
339
  * stands (#3999): each reference its steps make that resolves to another
@@ -409,13 +448,23 @@ export interface PrApplyOptions {
409
448
  * The gate is {@link prRoundGate}. Without it, held members are left
410
449
  * `waiting` and nothing is planned again.
411
450
  */
412
- decideRound?: (round: { round: number; gate: string; set: PrPlanSet; plan: FanOutPlan }) => Promise<PrRoundDecision>;
451
+ /**
452
+ * Where the approval of `set` stands (#4419). When it is `approved`, a round
453
+ * whose fresh plan does only what `set` previewed, with only the inputs it
454
+ * marked unknown filled in, applies under that approval without a gate of
455
+ * its own (`coveredRound`), and the round records it (`PrRound.covered`).
456
+ * Without it every round decides its own gate.
457
+ */
458
+ approval?: PrApproval;
459
+ decideRound?: (round: { round: number; gate: string; set: PrPlanSet; plan: FanOutPlan; notCovered?: string }) => Promise<PrRoundDecision>;
413
460
  }
414
461
 
415
462
  /** How a round's gate was decided: whether it applies, and where its approval stands. */
416
463
  export interface PrRoundDecision {
417
464
  satisfied: boolean;
418
465
  approval: PrApproval;
466
+ /** Set when the first approval covered the round (#4419): no gate of its own was decided. */
467
+ covered?: PrRound["covered"];
419
468
  /** Why it does not apply, when an approval stands but does not count (`--require-review`). */
420
469
  message?: string;
421
470
  }
@@ -430,6 +479,66 @@ export interface PrApplyResult {
430
479
  rounds?: PrRound[];
431
480
  }
432
481
 
482
+ /**
483
+ * Whether the first approval covers a round's fresh plan (#4419). The
484
+ * approval binds `options.set.doc.digest`; the round's components and
485
+ * members must be ones that plan previewed, and what differs is only what
486
+ * it marked unknown.
487
+ */
488
+ function roundCover(
489
+ options: PrApplyOptions,
490
+ fresh: PrPlanSet,
491
+ plan: FanOutPlan,
492
+ now: Record<string, Record<string, unknown>>,
493
+ inChange: ReadonlySet<string>,
494
+ byName: ReadonlyMap<string, DriverComponent>,
495
+ ): RoundCoverage {
496
+ if (options.approval?.status !== "approved") return { covered: false, reason: "no approval of the first plan stands" };
497
+ // The values the round reads of a component it plans again are the ones
498
+ // that plan says it will expose: it has not applied yet, so the outputs
499
+ // standing say nothing of it. An output the first plan knew and the round's
500
+ // plan knows as the same value is not a changed input, whichever of the
501
+ // two applies first.
502
+ const reads: Record<string, Record<string, unknown>> = { ...now };
503
+ for (const name of plan.order) if (fresh.outputs[name]) reads[name] = fresh.outputs[name]!;
504
+ const changedKnown: Record<string, string[]> = {};
505
+ for (const name of plan.order) {
506
+ const component = byName.get(name);
507
+ if (!component) continue;
508
+ const refs = new Set<string>();
509
+ for (const step of capabilitySteps(component.deploy)) {
510
+ const { kind: _kind, ...rest } = step;
511
+ for (const r of movedWiringReferences(rest, options.set.outputs, reads)) if (!(r.publish && inChange.has(r.source))) refs.add(r.ref);
512
+ }
513
+ changedKnown[name] = [...refs].sort();
514
+ }
515
+ const approved = { doc: options.set.doc, members: options.set.members, ...(options.set.shapes ? { shapes: options.set.shapes } : {}) };
516
+ return coveredRound({
517
+ approved,
518
+ approvedDigest: options.set.doc.digest,
519
+ fresh: { doc: fresh.doc, members: fresh.members, ...(fresh.shapes ? { shapes: fresh.shapes } : {}) },
520
+ changedKnown,
521
+ attributes: (member) => memberAttributes(options.set.doc, fresh.doc, member, fresh.shapes?.[member] !== undefined),
522
+ });
523
+ }
524
+
525
+ /**
526
+ * The part of coverage that reads the change set's own attributes, shared by
527
+ * a round, a resume and a wave: a member whose planner describes no changes
528
+ * (and gave no shape) is covered only by the digest it was approved with, and
529
+ * anything else is {@link planCoveredBy}'s.
530
+ */
531
+ function memberAttributes(approved: ChangeSetDocument, fresh: ChangeSetDocument, member: string, hasShape: boolean): string | undefined {
532
+ if (!hasShape) {
533
+ const now = fresh.members.find((m) => m.member === member);
534
+ const was = approved.members.find((m) => m.member === member);
535
+ if (now && was && now.planner === "chant" && now.lexicon === "chant" && now.status === "planned" && now.planDigest !== was.planDigest) {
536
+ return `${member} now plans to ${now.planDigest}, not the approved ${was.planDigest ?? "(none)"}, and its planner describes no changes to compare`;
537
+ }
538
+ }
539
+ return planCoveredBy(approved, fresh, member);
540
+ }
541
+
433
542
  /** Two runs of one apply as one: `b` ran after `a`, against the outputs `a` left. */
434
543
  function joinRuns(a: FanOutRunResult, b: FanOutRunResult): FanOutRunResult {
435
544
  const status: FanOutRunResult["status"] = a.status === "fail" || b.status === "fail" ? "fail" : a.status === "gated" || b.status === "gated" ? "gated" : "ok";
@@ -500,22 +609,37 @@ export async function applyPrSet(options: PrApplyOptions): Promise<PrApplyResult
500
609
  ...(options.targets ? { targets: options.targets } : {}),
501
610
  });
502
611
  for (const name of plan.order) roundOf.set(name, { round, set });
503
- const record = (status: PrRound["status"], approval: PrApproval, message?: string): void => {
504
- rounds.push({ round, gate, digest: set.doc.digest, components: plan.order, approval, status, ...(message ? { message } : {}), changeSet: set.doc });
612
+ const cover = roundCover(options, set, plan, run.componentOutputs, inChange, byName);
613
+ const record = (status: PrRound["status"], approval: PrApproval, message?: string, covered?: PrRound["covered"]): void => {
614
+ rounds.push({
615
+ round,
616
+ gate,
617
+ digest: set.doc.digest,
618
+ components: plan.order,
619
+ approval,
620
+ status,
621
+ ...(message ? { message } : {}),
622
+ ...(covered ? { covered } : {}),
623
+ ...(!covered && options.approval?.status === "approved" && cover.covered === false ? { notCovered: cover.reason } : {}),
624
+ changeSet: set.doc,
625
+ });
505
626
  };
506
627
  if (set.failed) {
507
628
  record("plan-failed", { status: "pending" });
508
629
  stopped = "plan-failed";
509
630
  break;
510
631
  }
511
- const decision = await options.decideRound({ round, gate, set, plan });
632
+ const decision: PrRoundDecision =
633
+ cover.covered && options.approval
634
+ ? { satisfied: true, approval: options.approval, covered: { digest: cover.digest, filledInputs: cover.filledInputs } }
635
+ : await options.decideRound({ round, gate, set, plan, ...(options.approval?.status === "approved" && !cover.covered ? { notCovered: cover.reason } : {}) });
512
636
  if (!decision.satisfied) {
513
637
  record("waiting", decision.approval, decision.message);
514
638
  stopped = "waiting";
515
639
  break;
516
640
  }
517
641
  const next = await runRound(plan, set.plans, set.outputs, run.componentOutputs);
518
- record(next.status === "fail" ? "failed" : "applied", decision.approval);
642
+ record(next.status === "fail" ? "failed" : "applied", decision.approval, undefined, decision.covered);
519
643
  run = joinRuns(run, next);
520
644
  }
521
645
 
@@ -688,9 +812,8 @@ export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
688
812
  seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
689
813
  ...(options.targets ? { targets: options.targets } : {}),
690
814
  });
691
- const reasons = fresh.doc.members
692
- .map((m) => planCoveredBy(options.record.changeSet, fresh.doc, m.member))
693
- .filter((r): r is string => r !== undefined);
815
+ // #4422: decided as a round decides (`coveredRound`), by the same function a wave uses.
816
+ const reasons = waveNotCovered(options.record.changeSet, fresh.doc, options.record.members);
694
817
  if (reasons.length > 0) return { ok: false, reasons, fresh };
695
818
  return {
696
819
  ok: true,
@@ -731,20 +854,47 @@ export function prWavePlan(plan: FanOutPlan, components: DriverComponent[], wave
731
854
  * returns no change set) is covered only by the digest it was approved
732
855
  * with, since with no entries there is nothing to compare a moved plan by.
733
856
  */
734
- export function waveNotCovered(approved: ChangeSetDocument, fresh: ChangeSetDocument, approvedMembers: readonly PrMember[] = []): string[] {
857
+ export function waveNotCovered(
858
+ approved: ChangeSetDocument,
859
+ fresh: ChangeSetDocument,
860
+ approvedMembers: readonly PrMember[] = [],
861
+ shapes: { approved?: Record<string, PlanShape>; fresh?: Record<string, PlanShape> } = {},
862
+ ): string[] {
735
863
  // #3999: a member whose approved plan waited only on an artifact a build in
736
864
  // the change publishes is covered by that build's provenance, as it is
737
865
  // without --wave-gate.
738
866
  const byBuild = new Set(approvedMembers.filter((m) => m.buildInputs?.length && !m.unknownInputs?.length).map((m) => m.member));
867
+ const prMember = new Map(approvedMembers.map((m) => [m.member, m]));
868
+ // #4422: the same decision a round makes (`coveredRound`), so a resume, a
869
+ // wave and a round agree for equivalent inputs. A member the document names
870
+ // and the caller does not stands for a component of its own name.
871
+ const approvedSide = {
872
+ doc: approved,
873
+ members: approved.members.map((m) => ({
874
+ member: m.member,
875
+ component: prMember.get(m.member)?.component ?? m.member,
876
+ ...(prMember.get(m.member)?.unknownInputs?.length ? { unknownInputs: prMember.get(m.member)!.unknownInputs } : {}),
877
+ })),
878
+ ...(shapes.approved && shapes.fresh ? { shapes: shapes.approved } : {}),
879
+ };
739
880
  return fresh.members
740
881
  .map((m) => {
741
882
  if (byBuild.has(m.member)) return undefined;
742
883
  const was = approved.members.find((a) => a.member === m.member);
743
- const opaque = m.planner === "chant" && m.lexicon === "chant" && m.status === "planned";
744
- if (opaque && was && was.planDigest !== m.planDigest) {
745
- return `${m.member} now plans to ${m.planDigest}, not the approved ${was.planDigest ?? "(none)"}, and its planner describes no changes to compare`;
746
- }
747
- return planCoveredBy(approved, fresh, m.member);
884
+ if (!was) return planCoveredBy(approved, fresh, m.member);
885
+ if (was.planDigest !== null && m.planDigest === was.planDigest) return undefined;
886
+ const hasShape = shapes.approved !== undefined && shapes.fresh?.[m.member] !== undefined;
887
+ const cover = coveredRound({
888
+ approved: approvedSide,
889
+ approvedDigest: approved.digest,
890
+ fresh: {
891
+ doc: fresh,
892
+ members: [{ member: m.member, component: prMember.get(m.member)?.component ?? m.member }],
893
+ ...(hasShape ? { shapes: shapes.fresh! } : {}),
894
+ },
895
+ attributes: (member) => memberAttributes(approved, fresh, member, hasShape),
896
+ });
897
+ return cover.covered ? undefined : cover.reason;
748
898
  })
749
899
  .filter((r): r is string => r !== undefined);
750
900
  }
@@ -27,6 +27,7 @@
27
27
  * `@<component>.publish.*`) resolves.
28
28
  */
29
29
 
30
+ import type { PlanShape } from "./covered-round";
30
31
  import { assertTargetsAccepted, resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
31
32
  import { componentPlanDigest } from "./gate-plan";
32
33
  import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
@@ -44,7 +45,7 @@ import type { ChangeSetPart } from "../change-set";
44
45
  * merges a run's outputs; absent when no step's plan says.
45
46
  */
46
47
  export type WaveComponentPlan =
47
- | { component: string; members: WaveMember[]; plans: Record<string, unknown>; parts: ChangeSetPart[]; outputs?: PlannedOutputs }
48
+ | { component: string; members: WaveMember[]; plans: Record<string, unknown>; parts: ChangeSetPart[]; outputs?: PlannedOutputs; shapes?: Record<string, PlanShape> }
48
49
  | { component: string; error: string };
49
50
 
50
51
  /** A part with no entries, for a member whose planner describes no changes. `unbound` steps become its holes (#4155). */
@@ -101,6 +102,7 @@ export async function planWaveComponent(
101
102
  const plans: Record<string, unknown> = {};
102
103
  const parts: ChangeSetPart[] = [];
103
104
  let outputs: PlannedOutputs | undefined;
105
+ const shapes: Record<string, PlanShape> = {};
104
106
  assertTargetsAccepted(component, registry, ctx);
105
107
  for (const step of capabilitySteps(component.deploy)) {
106
108
  const capability = registry.resolve(step.kind);
@@ -112,6 +114,7 @@ export async function planWaveComponent(
112
114
  members.push(member);
113
115
  parts.push(planned.changeSet ?? emptyPart(member));
114
116
  if (planned.artifact !== undefined) plans[planned.member] = planned.artifact;
117
+ if (planned.shape) shapes[planned.member] = { ...planned.shape, member: planned.member };
115
118
  if (planned.outputs) outputs = mergePlannedOutputs(outputs, planned.outputs);
116
119
  }
117
120
  // #4155: the steps without a plan, which no member above covers.
@@ -141,7 +144,7 @@ export async function planWaveComponent(
141
144
  parts.push(emptyPart(member, read.unbound));
142
145
  }
143
146
  }
144
- return { component: component.name, members, plans, parts, ...(outputs ? { outputs } : {}) };
147
+ return { component: component.name, members, plans, parts, ...(outputs ? { outputs } : {}), ...(Object.keys(shapes).length > 0 ? { shapes } : {}) };
145
148
  } catch (err) {
146
149
  return { component: component.name, error: err instanceof Error ? err.message : String(err) };
147
150
  }
@@ -204,7 +204,8 @@ describe("the note", () => {
204
204
  test("a plan names the outputs read from upstream plans and the ones known only after apply (#3999)", () => {
205
205
  const note = renderPrNote(unknownReport);
206
206
  expect(note).toContain("Planned against the outputs this change's plans say its upstreams will have: `a` (`arn`).");
207
- expect(note).toContain("Known only after apply: `app` reads `a.id`. This approval does not cover what those members do with them.");
207
+ expect(note).toContain("Known only after apply: `app` reads `a.id`. The apply plans those members again once their upstream applied.");
208
+ expect(note).toContain("anything else asks for an approval of its own");
208
209
  expect(renderPrNote(planReport)).not.toContain("Known only after apply");
209
210
  expect(note).toContain("From this pull request's build: `app` reads `img.publish.digest`. The image digest comes from this pull request's build");
210
211
  expect(renderPrNote(planReport)).not.toContain("From this pull request's build");
@@ -212,6 +213,18 @@ describe("the note", () => {
212
213
  expect(schema.required).not.toContain("plannedOutputs");
213
214
  });
214
215
 
216
+ test("a covered round names the first digest and the inputs filled in; an uncovered one gives the reason (#4419)", () => {
217
+ const covered = renderPrNote({
218
+ ...unknownReport,
219
+ status: "applied",
220
+ rounds: [{ ...waitingReport.rounds![0]!, status: "applied", approval: { status: "approved", approvedBy: ["github:rev"] }, covered: { digest: unknownReport.digest, filledInputs: ["a.id"] } }],
221
+ } as never);
222
+ expect(covered).toContain(`covered by the approval of \`${unknownReport.digest}\`, with \`a.id\` filled in, so it applied without a new approval`);
223
+ const not = renderPrNote({ ...unknownReport, rounds: [{ ...waitingReport.rounds![0]!, notCovered: "app now plans to create Bucket, which the first plan did not" }] } as never);
224
+ expect(not).toContain(`not covered by the approval of \`${unknownReport.digest}\`: app now plans to create Bucket, which the first plan did not.`);
225
+ expect(not).toContain("Not approved yet");
226
+ });
227
+
215
228
  test("a waiting apply names the round's digest and the command that approves it (#3999)", () => {
216
229
  const note = renderPrNote(waitingReport, { approver: "github:<your-login>" });
217
230
  expect(note).toContain("Applied 1 of 2 members; 1 wait on an approval of the plan made after their upstream applied.");