@intentius/chant 0.124.14 → 0.124.16

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 (178) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/build.js +1 -0
  4. package/dist/change-set.d.ts +24 -2
  5. package/dist/change-set.d.ts.map +1 -1
  6. package/dist/change-set.js +50 -1
  7. package/dist/ci-jobs.d.ts +227 -0
  8. package/dist/ci-jobs.d.ts.map +1 -0
  9. package/dist/ci-jobs.js +448 -0
  10. package/dist/cli/flush.d.ts +16 -0
  11. package/dist/cli/flush.d.ts.map +1 -0
  12. package/dist/cli/flush.js +42 -0
  13. package/dist/cli/handlers/approve.d.ts +98 -0
  14. package/dist/cli/handlers/approve.d.ts.map +1 -0
  15. package/dist/cli/handlers/approve.js +495 -0
  16. package/dist/cli/handlers/operator.d.ts +1 -95
  17. package/dist/cli/handlers/operator.d.ts.map +1 -1
  18. package/dist/cli/handlers/operator.js +4 -488
  19. package/dist/cli/handlers/override-approve.js +1 -1
  20. package/dist/cli/main.d.ts +4 -15
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/main.js +4 -1062
  23. package/dist/cli/parse-args.d.ts +6 -0
  24. package/dist/cli/parse-args.d.ts.map +1 -0
  25. package/dist/cli/parse-args.js +1028 -0
  26. package/dist/components/fan-out-record.d.ts +1 -1
  27. package/dist/components/fan-out-record.d.ts.map +1 -1
  28. package/dist/components/fan-out-record.js +3 -7
  29. package/dist/components/fan-out-run.d.ts +5 -3
  30. package/dist/components/fan-out-run.d.ts.map +1 -1
  31. package/dist/components/fan-out-run.js +7 -5
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +3 -2
  34. package/dist/composite.d.ts +10 -2
  35. package/dist/composite.d.ts.map +1 -1
  36. package/dist/composite.js +8 -0
  37. package/dist/discovery/collect.d.ts +2 -1
  38. package/dist/discovery/collect.d.ts.map +1 -1
  39. package/dist/discovery/collect.js +16 -1
  40. package/dist/discovery/fold-import.d.ts.map +1 -1
  41. package/dist/discovery/fold-import.js +2 -107
  42. package/dist/discovery/host-composite.d.ts +35 -0
  43. package/dist/discovery/host-composite.d.ts.map +1 -0
  44. package/dist/discovery/host-composite.js +93 -0
  45. package/dist/discovery/index.d.ts +9 -0
  46. package/dist/discovery/index.d.ts.map +1 -1
  47. package/dist/discovery/index.js +1 -1
  48. package/dist/gate-decision.d.ts +20 -9
  49. package/dist/gate-decision.d.ts.map +1 -1
  50. package/dist/gate-decision.js +72 -19
  51. package/dist/gated-waves.d.ts +25 -6
  52. package/dist/gated-waves.d.ts.map +1 -1
  53. package/dist/gated-waves.js +21 -6
  54. package/dist/lifecycle/gate-audit.d.ts +69 -2
  55. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  56. package/dist/lifecycle/gate-audit.js +76 -2
  57. package/dist/lifecycle/gate-ledger.d.ts +24 -0
  58. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  59. package/dist/lifecycle/gate-ledger.js +9 -1
  60. package/dist/op/activities/activity-contracts.d.ts +5 -5
  61. package/dist/op/activities/activity-contracts.js +1 -1
  62. package/dist/op/activities/change-set.d.ts +8 -3
  63. package/dist/op/activities/change-set.d.ts.map +1 -1
  64. package/dist/op/activities/change-set.js +5 -2
  65. package/dist/op/activities/reconcile.d.ts +28 -16
  66. package/dist/op/activities/reconcile.d.ts.map +1 -1
  67. package/dist/op/activities/reconcile.js +34 -20
  68. package/dist/op/builders.d.ts +1 -1
  69. package/dist/op/builders.d.ts.map +1 -1
  70. package/dist/op/builders.js +1 -1
  71. package/dist/op/op-waves-run.d.ts +2 -2
  72. package/dist/op/op-waves-run.d.ts.map +1 -1
  73. package/dist/op/op-waves-run.js +8 -9
  74. package/dist/op/op-waves.d.ts.map +1 -1
  75. package/dist/op/op-waves.js +4 -8
  76. package/dist/package-version.d.ts +2 -0
  77. package/dist/package-version.d.ts.map +1 -0
  78. package/dist/package-version.js +10 -0
  79. package/dist/wave-engine.d.ts +62 -0
  80. package/dist/wave-engine.d.ts.map +1 -0
  81. package/dist/wave-engine.js +59 -0
  82. package/dist/wave-layout.d.ts +29 -0
  83. package/dist/wave-layout.d.ts.map +1 -0
  84. package/dist/wave-layout.js +41 -0
  85. package/dist/wave-runtime.d.ts +29 -0
  86. package/dist/wave-runtime.d.ts.map +1 -0
  87. package/dist/wave-runtime.js +103 -0
  88. package/dist/wave-runtime.mjs +331 -0
  89. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  90. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  91. package/dist/workspace/__fixtures__/contract-repo.js +17 -1
  92. package/dist/workspace/__fixtures__/sessions.d.ts +1 -1
  93. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  94. package/dist/workspace/__fixtures__/sessions.js +3 -3
  95. package/dist/workspace/ajv2020.d.ts +5 -0
  96. package/dist/workspace/ajv2020.d.ts.map +1 -0
  97. package/dist/workspace/ajv2020.js +14 -0
  98. package/dist/workspace/change-set.schema.json +5 -3
  99. package/dist/workspace/ci-green-workflow.d.ts +3 -1
  100. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  101. package/dist/workspace/ci-green-workflow.js +5 -2
  102. package/dist/workspace/class-data.d.ts +84 -0
  103. package/dist/workspace/class-data.d.ts.map +1 -0
  104. package/dist/workspace/class-data.js +238 -0
  105. package/dist/workspace/declaration.d.ts.map +1 -1
  106. package/dist/workspace/declaration.js +4 -8
  107. package/dist/workspace/kinds.d.ts +3 -7
  108. package/dist/workspace/kinds.d.ts.map +1 -1
  109. package/dist/workspace/kinds.js +8 -45
  110. package/dist/workspace/points.d.ts.map +1 -1
  111. package/dist/workspace/points.js +2 -5
  112. package/dist/workspace/principal-classes.d.ts +4 -11
  113. package/dist/workspace/principal-classes.d.ts.map +1 -1
  114. package/dist/workspace/principal-classes.js +13 -99
  115. package/package.json +5 -3
  116. package/src/build.ts +8 -0
  117. package/src/change-set.test.ts +48 -0
  118. package/src/change-set.ts +52 -2
  119. package/src/ci-jobs.test.ts +49 -0
  120. package/src/ci-jobs.ts +515 -0
  121. package/src/cli/flush.ts +44 -0
  122. package/src/cli/handlers/approve.ts +618 -0
  123. package/src/cli/handlers/operator.ts +10 -608
  124. package/src/cli/handlers/override-approve.test.ts +1 -1
  125. package/src/cli/handlers/override-approve.ts +1 -1
  126. package/src/cli/main.ts +5 -816
  127. package/src/cli/parse-args.ts +781 -0
  128. package/src/components/fan-out-record.ts +3 -7
  129. package/src/components/fan-out-run.ts +7 -5
  130. package/src/components/pr-pipeline.ts +3 -2
  131. package/src/composite.ts +15 -3
  132. package/src/discovery/collect.ts +15 -1
  133. package/src/discovery/fold-build-parity.test.ts +71 -0
  134. package/src/discovery/fold-import.ts +2 -113
  135. package/src/discovery/fold-project-provenance.test.ts +21 -4
  136. package/src/discovery/host-composite.ts +101 -0
  137. package/src/discovery/index.ts +11 -1
  138. package/src/gate-decision-bundle.test.ts +6 -4
  139. package/src/gate-decision-identity.test.ts +59 -6
  140. package/src/gate-decision.ts +81 -21
  141. package/src/gated-waves.ts +28 -6
  142. package/src/lifecycle/gate-audit.test.ts +55 -1
  143. package/src/lifecycle/gate-audit.ts +116 -3
  144. package/src/lifecycle/gate-ledger.test.ts +20 -1
  145. package/src/lifecycle/gate-ledger.ts +33 -1
  146. package/src/op/activities/activity-contracts.ts +1 -1
  147. package/src/op/activities/change-set.ts +11 -3
  148. package/src/op/activities/lexicon-upgrade.test.ts +2 -0
  149. package/src/op/activities/reconcile.test.ts +62 -2
  150. package/src/op/activities/reconcile.ts +46 -22
  151. package/src/op/builders.ts +1 -1
  152. package/src/op/op-waves-run.ts +9 -9
  153. package/src/op/op-waves.test.ts +2 -0
  154. package/src/op/op-waves.ts +4 -8
  155. package/src/op/work-lease-run.test.ts +12 -4
  156. package/src/package-version.ts +11 -0
  157. package/src/wave-engine.test.ts +107 -0
  158. package/src/wave-engine.ts +98 -0
  159. package/src/wave-layout.ts +44 -0
  160. package/src/wave-runtime-bundle.test.ts +70 -0
  161. package/src/wave-runtime.e2e.test.ts +177 -0
  162. package/src/wave-runtime.ts +104 -0
  163. package/src/workspace/__fixtures__/contract-repo.ts +17 -1
  164. package/src/workspace/__fixtures__/sessions.ts +3 -3
  165. package/src/workspace/ajv2020.ts +17 -0
  166. package/src/workspace/change-set.schema.json +5 -3
  167. package/src/workspace/ci-green-workflow.test.ts +4 -4
  168. package/src/workspace/ci-green-workflow.ts +5 -2
  169. package/src/workspace/class-data.test.ts +64 -0
  170. package/src/workspace/class-data.ts +236 -0
  171. package/src/workspace/declaration.ts +4 -10
  172. package/src/workspace/kinds.ts +8 -40
  173. package/src/workspace/points.ts +2 -5
  174. package/src/workspace/principal-classes.ts +13 -99
  175. package/src/workspace/records-formats.test.ts +7 -2
  176. package/src/workspace/records-sessions-write.test.ts +3 -3
  177. package/src/workspace/status-contract.e2e.test.ts +37 -0
  178. package/src/workspace/status-contract.test.ts +23 -21
@@ -139,18 +139,71 @@ describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it",
139
139
  expect(judgeBoth(invalid.r, invalid.base, invalid.approvals).ship![1]).toMatch(/no signers file/);
140
140
  });
141
141
 
142
- test("a domain class the caller read from a pin, and a pin this reader does not load", () => {
143
- const { r, base, approvals } = setup(
144
- (k) => ({ ".chant/allowed_signers": `alice ${k.alice.pub}\n`, ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"] } }) }),
145
- { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] },
142
+ /** A plugin publishing `classes` at ./workspace-principals, as files under `dir`. */
143
+ const plugin = (dir: string, classes: unknown[], version = "1.0.0"): Record<string, string> => ({
144
+ [`${dir}/package.json`]: JSON.stringify({ name: "@acme/ops-classes", version, exports: { "./workspace-principals": "./principals.json" } }),
145
+ [`${dir}/principals.json`]: JSON.stringify({ schema: 1, classes }),
146
+ });
147
+ const OPERATOR = { name: "operator", description: "people who apply infrastructure", role: "operator" };
148
+ const operatorPolicy = (k: Record<string, Key>) => ({
149
+ ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
150
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { operator: ["alice"], runner: ["bob"] } }),
151
+ });
152
+ const install = (r: TestRepo, files: Record<string, string>) => {
153
+ for (const [path, text] of Object.entries(files)) r.write(path, text);
154
+ };
155
+
156
+ test("a class only a pinned package supplies, read from the installed package as chant reads it (#3888)", () => {
157
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
158
+ install(r, plugin("node_modules/@acme/ops-classes", [OPERATOR]));
159
+ const ours = judgeBoth(r, base, approvals);
160
+ // alice holds the operator role at base, so she is an operator and no longer human.
161
+ expect(ours.ops![1]).toBeNull();
162
+ expect(ours.ops![2]).toBe("BOB is not in the class operator that gate ops needs: no role grant at base puts them there");
163
+ expect(ours.deploy![1]).toMatch(/^alice is not in the class human that gate deploy needs/);
164
+
165
+ // A bare clone holds no node_modules; the caller names where the packages are installed.
166
+ const bare = `${r.dir}.git`;
167
+ r.git(["clone", "-q", "--bare", r.dir, bare]);
168
+ const fromBare = identityGateAdmissionAt(bare, base, "ops", { packages: r.dir })!;
169
+ expect(approvals("ops").map((a) => fromBare.refuses(a))).toEqual(ours.ops);
170
+ });
171
+
172
+ test("a path pin's class is read at base, so the change being applied can't remap it", () => {
173
+ const { r, base, approvals } = setup((k) => ({ ...operatorPolicy(k), ...plugin("plugins/ops", [OPERATOR]) }), { pins: [{ path: "plugins/ops" }] });
174
+ // After base the plugin moves the operator class to bob's role; base still says operator.
175
+ install(r, plugin("plugins/ops", [{ ...OPERATOR, role: "runner-ops" }]));
176
+ r.commit("remaps operator");
177
+ const ours = judgeBoth(r, base, approvals);
178
+ expect(ours.ops![1]).toBeNull();
179
+ });
180
+
181
+ test("a pin it cannot read refuses a gate whose class the pin could change, and says why", () => {
182
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
183
+ const missing = identityGateAdmissionAt(r.dir, base, "ops")!;
184
+ expect(missing.refuses(approvals("ops")[1])).toBe(
185
+ `identity.gates.ops names the class operator, and the pins in chant.workspace.json at base could not all be read, so no approval can show it is in that class: @acme/ops-classes is not installed under ${r.dir}`,
146
186
  );
147
- const unread = identityGateAdmissionAt(r.dir, base, "ops")!;
148
- expect(unread.refuses(approvals("ops")[1])).toMatch(/is not a core class, and this reader does not load the classes the pinned packages in chant\.workspace\.json at base supply/);
187
+ // human is everyone in no other class, so an unread pin leaves it unknown too; runner and no class are not affected.
188
+ expect(identityGateAdmissionAt(r.dir, base, "deploy")!.refuses(approvals("deploy")[1])).toMatch(/names the class human, and the pins .* could not all be read/);
189
+ expect(identityGateAdmissionAt(r.dir, base, "release")!.refuses(approvals("release")[2])).toBeNull();
190
+ expect(identityGateAdmissionAt(r.dir, base, "ship")!.refuses(approvals("ship")[1])).toBeNull();
191
+
192
+ install(r, plugin("node_modules/@acme/ops-classes", [OPERATOR], "2.0.0"));
193
+ expect(identityGateAdmissionAt(r.dir, base, "ops")!.refuses(approvals("ops")[1])).toMatch(/: @acme\/ops-classes is pinned at 1\.0\.0, and 2\.0\.0 is installed under /);
194
+
195
+ // A class the caller read stands for the pins, as before.
149
196
  const read = identityGateAdmissionAt(r.dir, base, "ops", { classes: [{ name: "operator", role: "operator" }] })!;
150
197
  expect(read.refuses(approvals("ops")[1])).toBeNull();
151
198
  expect(read.refuses(approvals("ops")[0])).toMatch(/not signed/);
152
199
  });
153
200
 
201
+ test("an installed package that supplies no such class refuses as chant does", () => {
202
+ const { r, base, approvals } = setup(operatorPolicy, { pins: [{ package: "@acme/ops-classes", version: "1.0.0" }] });
203
+ install(r, plugin("node_modules/@acme/ops-classes", [{ ...OPERATOR, name: "deployer" }]));
204
+ expect(judgeBoth(r, base, approvals).ops![1]).toMatch(/names the class operator, which no pinned package supplies, .*known classes: human, agent, runner, service, deployer/);
205
+ });
206
+
154
207
  test("passed to decideGateApproval as refuses, it counts what chant's wave gate counts", async () => {
155
208
  const { r, base, approvals } = setup((k) => ({
156
209
  ".chant/allowed_signers": `alice ${k.alice.pub}\nbob ${k.bob.pub}\n`,
@@ -18,8 +18,9 @@
18
18
  *
19
19
  * A gate the declaration at base names under `identity.gates` counts only a
20
20
  * sealed approval from its class: {@link identityGateAdmissionAt} reads that
21
- * rule from a bare clone at base and judges by `admitApprover`, the function
22
- * chant's `gateAdmission` judges by (#3862); pass its `refuses` to
21
+ * rule from a bare clone at base, with the principal classes the pinned
22
+ * packages supply read as data (#3888), and judges by `admitApprover`, the
23
+ * function chant's `gateAdmission` judges by (#3862); pass its `refuses` to
23
24
  * {@link decideGateApproval}.
24
25
  *
25
26
  * With `spend` (#3784), an approval a wave already applied under is spent and
@@ -27,14 +28,17 @@
27
28
  * the lines of `_gates/<op>/applied.jsonl` ({@link parseAppliedGateLines}).
28
29
  *
29
30
  * The module reads the ledger lines and the review it is handed; reading
30
- * them is the caller's. It imports no TypeScript, esbuild, zod, lint, fold or
31
- * codegen module, and none of chant's git ledger code, which
31
+ * them is the caller's. It imports no TypeScript, esbuild, zod, ajv, lint,
32
+ * fold or codegen module, no package's code, and none of chant's git ledger
33
+ * code, which
32
34
  * `gate-decision-bundle.test.ts` holds it to. Its rules are chant's own:
33
35
  * `./lifecycle/gate-ledger-select.ts` is what `evaluateGate` decides by, and
34
36
  * `gate-decision.test.ts` decides the same ledgers both ways.
35
37
  */
36
38
 
37
39
  import { spawnSync } from "node:child_process";
40
+ import { readFileSync } from "node:fs";
41
+ import { join, posix } from "node:path";
38
42
  import {
39
43
  isPendingGateExpired,
40
44
  latestPendingGate,
@@ -50,7 +54,8 @@ import {
50
54
  trustConfigProblems,
51
55
  } from "./workspace/trust/trust-files";
52
56
  import { parseJsonText } from "./workspace/jsonc";
53
- import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule } from "./workspace/identity-gate-rule";
57
+ import { CORE_CLASSES, admitApprover, type ClassGrant, type GateAdmissionRule, type PrincipalClass } from "./workspace/identity-gate-rule";
58
+ import { diskFiles, findInstalledPackage, mergePinClasses, parseClassDataByHand, readClassFiles, type PinClassRead } from "./workspace/class-data";
54
59
  import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
55
60
  import type { MergedReview } from "./op/gate-review";
56
61
  import type { GateApprovalSource } from "./op/op-waves";
@@ -207,6 +212,56 @@ export interface IdentityGateAdmission {
207
212
  refuses(approval: SealedGateApproval): string | null;
208
213
  }
209
214
 
215
+ /** The domain classes the pins of the declaration at base supply, as chant's `loadClassRegistry` reads them, and the pins that could not be read. */
216
+ interface PinnedClasses {
217
+ classes: PrincipalClass[];
218
+ /** Pins whose classes are not known, one line each: a package pin that is not installed, or not at its pinned version. */
219
+ unread: string[];
220
+ }
221
+
222
+ /**
223
+ * Read the classes each pin supplies, as chant's `gateAdmission(cwd, gate,
224
+ * base)` reads them: a path pin's `./workspace-principals` file from `base`
225
+ * itself, so a change can't remap a class by editing its own copy of the
226
+ * plugin; a package pin's from the package installed under `packages` (its
227
+ * `node_modules` or a parent's), which must be at the pinned version. Reads
228
+ * data only and imports no package.
229
+ */
230
+ function pinnedClassesAt(repo: string, base: string, pins: unknown, packages: string): PinnedClasses {
231
+ const reads: PinClassRead[] = [];
232
+ const unread: string[] = [];
233
+ (Array.isArray(pins) ? pins : []).forEach((raw: unknown, i) => {
234
+ const pin = (raw !== null && typeof raw === "object" ? raw : {}) as { package?: unknown; version?: unknown; path?: unknown };
235
+ if (typeof pin.path === "string" && pin.path) {
236
+ const dir = posix.normalize(pin.path);
237
+ const files = (path: string) => showAt(repo, base, dir === "." ? path : posix.join(dir, path));
238
+ reads.push({ pin: i, source: pin.path, read: readClassFiles(files, pin.path, parseClassDataByHand) });
239
+ return;
240
+ }
241
+ if (typeof pin.package !== "string" || !pin.package) return;
242
+ const dir = findInstalledPackage(pin.package, packages);
243
+ if (!dir) {
244
+ unread.push(`${pin.package} is not installed under ${packages}`);
245
+ return;
246
+ }
247
+ let installed: unknown;
248
+ try {
249
+ installed = (JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as { version?: unknown }).version;
250
+ } catch {
251
+ installed = undefined;
252
+ }
253
+ if (typeof pin.version === "string" && pin.version && installed !== pin.version) {
254
+ unread.push(`${pin.package} is pinned at ${pin.version}, and ${String(installed ?? "an unknown version")} is installed under ${packages}`);
255
+ return;
256
+ }
257
+ reads.push({ pin: i, source: pin.package, read: readClassFiles(diskFiles(dir), pin.package, parseClassDataByHand) });
258
+ });
259
+ return { classes: mergePinClasses(reads).classes, unread };
260
+ }
261
+
262
+ /** The core classes whose members no domain class can change: every one but `human`, which is the principals in no other class. */
263
+ const PIN_FREE = new Set(CORE_CLASSES.filter((c) => c.role !== null).map((c) => c.name));
264
+
210
265
  /**
211
266
  * The `identity.gates` rule for `gate` at `base`, or null when the
212
267
  * declaration there names none: what chant's `gateAdmission(cwd, gate, base)`
@@ -215,34 +270,39 @@ export interface IdentityGateAdmission {
215
270
  * class, the role grants in `.chant/trust.json` at base put the approver in
216
271
  * it. Both judge with the same function ({@link admitApprover}).
217
272
  *
218
- * The classes are the four core ones, then `classes`, the domain classes the
219
- * caller read from the pinned packages (`./workspace-principals`), in pin
220
- * order. This reader loads no package. A gate whose class is in neither
221
- * refuses every approval, and when the declaration at base pins anything the
222
- * reason says the pins were not read, since one of them may supply it.
273
+ * The classes are the four core ones, then the domain classes the
274
+ * declaration's pins supply, in pin order (#3888), read as chant reads them:
275
+ * a path pin's `./workspace-principals` file at `base`, and a package pin's
276
+ * from the package installed under `packages` (default `repo`) at the
277
+ * pinned version. A bare clone has no `node_modules`, so a caller that reads
278
+ * one passes the directory its packages are installed under. Only data is
279
+ * read; no package is imported. `classes`, when given, stands for every
280
+ * domain class instead and no pin is read.
281
+ *
282
+ * When a pin can't be read (its package is not installed, or not at the
283
+ * pinned version), a gate whose class it could change (a domain class, or
284
+ * `human`) refuses every approval and says which pins were not read; a gate
285
+ * naming `agent`, `runner` or `service`, or no class, is judged as usual.
223
286
  * Throws when the declaration at base cannot be parsed.
224
287
  */
225
288
  export function identityGateAdmissionAt(
226
289
  repo: string,
227
290
  base: string,
228
291
  gate: string,
229
- opts: { classes?: readonly ClassGrant[] } = {},
292
+ opts: { classes?: readonly ClassGrant[]; packages?: string } = {},
230
293
  ): IdentityGateAdmission | null {
231
294
  const declaration = declarationAt(repo, base);
232
295
  const requirement = declaration === null ? undefined : identityGatesOf(declaration.value).find((g) => g.gate === gate);
233
296
  if (!requirement) return null;
234
297
  const { signers, roles } = trustAt(repo, base);
235
298
  const pins = (declaration!.value as { pins?: unknown }).pins;
236
- const rule: GateAdmissionRule = {
237
- gate,
238
- class: requirement.class,
239
- signers,
240
- roles,
241
- classes: [...CORE_CLASSES, ...(opts.classes ?? [])],
242
- ...(Array.isArray(pins) && pins.length > 0
243
- ? { unknownClass: `is not a core class, and this reader does not load the classes the pinned packages in ${declaration!.file} at base supply` }
244
- : {}),
245
- };
299
+ const pinned = opts.classes ? { classes: opts.classes, unread: [] } : pinnedClassesAt(repo, base, pins, opts.packages ?? repo);
300
+ const want = requirement.class;
301
+ if (want !== null && !PIN_FREE.has(want) && pinned.unread.length > 0) {
302
+ const reason = `identity.gates.${gate} names the class ${want}, and the pins in ${declaration!.file} at base could not all be read, so no approval can show it is in that class: ${pinned.unread.join("; ")}`;
303
+ return { requirement, refuses: () => reason };
304
+ }
305
+ const rule: GateAdmissionRule = { gate, class: want, signers, roles, classes: [...CORE_CLASSES, ...pinned.classes] };
246
306
  return { requirement, refuses: (a) => admitApprover(rule, a) };
247
307
  }
248
308
 
@@ -2,12 +2,17 @@
2
2
  * Gated waves (#3049): one change rolled out across many roots, a wave at a
3
3
  * time, each wave behind its own gate bound to that wave's set digest.
4
4
  *
5
- * `chant components fan-out --wave-gate` is the runner. This module holds the
6
- * parts that need no runner: how a set of roots is cut into waves, what a
7
- * wave's digest is, what its gate is called, and what the attempt record says
8
- * about each wave. They are kept apart from the runner because terragucci
9
- * calls them from its CI stages as one bundled file with no TypeScript
10
- * toolchain (#3421). Everything here imports `./change-set` and nothing else,
5
+ * Two runners use it (#3766): `chant components fan-out --wave-gate` and the
6
+ * Op waves spec's `chant run wave` (`./op/op-waves-run.ts`). This module
7
+ * holds the parts that need no runner: how a set of roots is cut into waves,
8
+ * what a wave's digest is and which members it covers, what its gate is
9
+ * called, and what the attempt record says about each wave. A gate name
10
+ * pattern and the share split of a wide wave are in `./wave-layout.ts`;
11
+ * `./wave-engine.ts` puts them together with the gate decision call and the
12
+ * record file writer, which need the gate ledger and the filesystem.
13
+ *
14
+ * They are kept apart from the runners because terragucci calls them from
15
+ * its CI stages as one bundled file with no TypeScript toolchain (#3421). Everything here imports `./change-set` and nothing else,
11
16
  * and `gated-waves-bundle.test.ts` holds that.
12
17
  *
13
18
  * ## The set digest
@@ -144,6 +149,23 @@ export function waveGateName(gate: string, wave: number): string {
144
149
  return `${gate}-wave-${wave}`;
145
150
  }
146
151
 
152
+ /** Which members a wave's set digest covers (#3821). */
153
+ export type WaveDigestOver = "all" | "changing";
154
+
155
+ /**
156
+ * The members a wave's set digest is taken over (#3821, #3766). `all` (the
157
+ * default): every member. `changing`: the members whose plan is not
158
+ * `empty`, or every member when all of them are empty. `extras`, members
159
+ * that are no run (such as a wave's cost), are always in it.
160
+ */
161
+ export function waveDigestMembers<M extends WaveMember & { empty?: boolean }>(
162
+ members: readonly M[],
163
+ options: { over?: WaveDigestOver; extras?: readonly WaveMember[] } = {},
164
+ ): WaveMember[] {
165
+ const changing = options.over === "changing" ? members.filter((m) => m.empty !== true) : [];
166
+ return [...(changing.length > 0 ? changing : members), ...(options.extras ?? [])];
167
+ }
168
+
147
169
  /** Where a wave got to. */
148
170
  export type WaveStatus =
149
171
  /** Planned, and its gate is not approved for this digest. Nothing in it ran. */
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { deriveGateAudit, gateLedgerLines, gateLedgerLinesFromLog, readGateAuditLedgers, isOverrideGate } from "./gate-audit";
2
+ import { readGateAuditDecisions, deriveGateAudit, gateLedgerLines, gateLedgerLinesFromLog, readGateAuditLedgers, isOverrideGate } from "./gate-audit";
3
3
  import { memoryGateLedgerPort } from "../op/gate";
4
4
 
5
5
  const D1 = "jcs1-sha256:" + "a".repeat(64);
@@ -12,6 +12,15 @@ const resolution = (o: Record<string, unknown> = {}) =>
12
12
  const ledger = (op: string, ...texts: string[]) => ({ op, lines: texts.map((text) => ({ text })) });
13
13
 
14
14
  describe("lifecycle/gate-audit", () => {
15
+ test("a hold's heldBy and approvalMode are surfaced (#3886, #3887)", () => {
16
+ const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ approvalMode: "sealed", heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } }))] });
17
+ expect(entries[0]).toMatchObject({ approvalMode: "sealed", heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } });
18
+ expect(entries[0]!.detail).toEqual({});
19
+ const bad = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ approvalMode: "x", heldBy: { hook: 1 } }))] });
20
+ expect(bad.entries[0]!.heldBy).toBeUndefined();
21
+ expect(bad.entries[0]!.approvalMode).toBeUndefined();
22
+ });
23
+
15
24
  test("a pending fact's wave is surfaced (#3839)", () => {
16
25
  const { entries } = deriveGateAudit({ ledgers: [ledger("tf-apply", pending({ wave: { number: 2, name: "prod", op: "tg" } }))] });
17
26
  expect(entries[0]).toMatchObject({ wave: { number: 2, name: "prod", op: "tg" } });
@@ -179,4 +188,49 @@ describe("lifecycle/gate-audit", () => {
179
188
  expect(fromPort.entries.map((e) => e.kind)).toEqual(["approval-requested", "approved"]);
180
189
  expect(fromPort.entries.map((e) => e.at)).toEqual(fromText.entries.map((e) => e.at));
181
190
  });
191
+
192
+ describe("op-wave decision records (#3810)", () => {
193
+ const outcomes = [
194
+ { moment: "before-plan", name: "lint", exit: 0, outcome: "passed" },
195
+ { moment: "after-plan", name: "cost", target: "prod", exit: 2, outcome: "held" },
196
+ { moment: "before-apply", name: "backup", exit: 1, outcome: "failed" },
197
+ { moment: "after-apply", name: "skip", exit: 0, outcome: "skipped", reason: "nothing applied" },
198
+ ];
199
+ const record = { wave: 1, name: "prod", op: "tf-apply", gate: "wave-1", digest: D1, heldBy: ["cost"], outcomes };
200
+
201
+ test("each outcome becomes a hook entry linked to the approval and the apply", () => {
202
+ const { entries, gaps } = deriveGateAudit({
203
+ ledgers: [ledger("tf-apply", pending(), resolution())],
204
+ applies: [{ ref: "reports/1", at: "2026-10-01T12:00:00.000Z", gate: "wave-1", digest: D1 }],
205
+ decisions: [{ ref: "wave-1.json", at: "2026-10-01T12:05:00.000Z", record }],
206
+ });
207
+ expect(gaps).toEqual([]);
208
+ const hooks = entries.filter((e) => e.kind === "hook");
209
+ expect(hooks.map((h) => h.hook?.outcome)).toEqual(["passed", "held", "failed", "skipped"]);
210
+ expect(hooks[1]!.hook).toMatchObject({ moment: "after-plan", name: "cost", target: "prod", exit: 2 });
211
+ expect(hooks[3]!.hook?.reason).toBe("nothing applied");
212
+ const approved = entries.find((e) => e.kind === "approved")!;
213
+ const applied = entries.find((e) => e.kind === "applied")!;
214
+ for (const h of hooks) expect(h).toMatchObject({ under: approved.id, applied: applied.id, gate: "wave-1", digest: D1, wave: { number: 1, name: "prod" } });
215
+ expect(new Set(hooks.map((h) => h.id)).size).toBe(4);
216
+ });
217
+
218
+ test("text input, no approval on file, and a malformed record", () => {
219
+ const { entries, gaps } = deriveGateAudit({
220
+ ledgers: [],
221
+ decisions: [{ ref: "a.json", at: "2026-10-01T12:00:00.000Z", text: JSON.stringify(record) }, { ref: "b.json", at: "2026-10-01T12:00:00.000Z", text: "nope" }],
222
+ });
223
+ expect(entries.filter((e) => e.kind === "hook")).toHaveLength(4);
224
+ expect(entries[0]).not.toHaveProperty("under");
225
+ expect(gaps.map((g) => g.kind)).toEqual(["malformed-line"]);
226
+ });
227
+
228
+ test("readGateAuditDecisions reads the json files under a directory", async () => {
229
+ const files: Record<string, string> = { "w1.json": JSON.stringify(record), "notes.txt": "x" };
230
+ const port = { list: async () => Object.keys(files), read: async (_d: string, f: string) => files[f] ?? null };
231
+ const got = await readGateAuditDecisions(port, "_wave-decisions/tf", "2026-10-01T12:00:00.000Z");
232
+ expect(got.map((d) => d.ref)).toEqual(["_wave-decisions/tf/w1.json"]);
233
+ expect(deriveGateAudit({ ledgers: [], decisions: got }).entries).toHaveLength(4);
234
+ });
235
+ });
182
236
  });
@@ -21,6 +21,7 @@
21
21
  * | `revoked` | a resolution a commit removed from the file (`subject` says which) |
22
22
  * | `applied` | an apply the caller passes in, with the approval it ran under |
23
23
  * | `override-spent` | an override an apply the caller passes in cites |
24
+ * | `hook` | a hook outcome in an op-wave decision record the caller passes in |
24
25
  *
25
26
  * An override gate is, by default, any gate of the op `policy-override`
26
27
  * (terragucci writes one gate per root) or named `override-<rule>` (SQL
@@ -79,6 +80,8 @@ import { canonicalJson } from "../effect-receipt";
79
80
  import { contentDigest } from "../content-digest";
80
81
  import { samePlanDigest } from "./plan-digest";
81
82
  import type { GateLedgerPort } from "../op/gate";
83
+ import type { LedgerReadPort } from "./ledger-port";
84
+ import type { GateApprovalSource } from "../op/op-waves";
82
85
 
83
86
  export const GATE_AUDIT_KINDS = [
84
87
  "approval-requested",
@@ -88,6 +91,7 @@ export const GATE_AUDIT_KINDS = [
88
91
  "revoked",
89
92
  "applied",
90
93
  "override-spent",
94
+ "hook",
91
95
  ] as const;
92
96
  export type GateAuditKind = (typeof GATE_AUDIT_KINDS)[number];
93
97
 
@@ -155,7 +159,7 @@ export interface GateAuditSource {
155
159
  commit?: string;
156
160
  /** The commit that added the line, or for a revocation removed it, when the line came from history. */
157
161
  author?: GateLedgerCommit;
158
- change: "added" | "removed" | "apply";
162
+ change: "added" | "removed" | "apply" | "decision";
159
163
  }
160
164
 
161
165
  export interface GateAuditEntry {
@@ -193,8 +197,16 @@ export interface GateAuditEntry {
193
197
  description?: string;
194
198
  /** Pending facts an op wave wrote for a policy denial or a hold (#3839): the wave they belong to. */
195
199
  wave?: { number: number; name: string; op?: string };
200
+ /** Pending facts of a hold (#3886): the hook that holds the wave. */
201
+ heldBy?: { hook: string; moment: string; target?: string };
202
+ /** Pending facts of an op wave (#3861): the approval mode the wave asked under. */
203
+ approvalMode?: GateApprovalSource;
196
204
  /** Overrides: the rules the denial named (from the denial line when there is one). */
197
205
  rules?: string[];
206
+ /** A hook entry: the hook's moment, name, run, how it came out, and why a moment was skipped (#3810). */
207
+ hook?: { moment: string; name: string; target?: string; outcome: "passed" | "failed" | "held" | "skipped"; exit: number; reason?: string };
208
+ /** A hook entry: the applied entry of the same wave and digest, if the caller passed that apply in. */
209
+ applied?: string;
198
210
  /** A revoked entry: what was revoked. */
199
211
  subject?: "approval" | "override";
200
212
  /** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
@@ -212,6 +224,35 @@ export interface GateAuditGap {
212
224
  detail: string;
213
225
  }
214
226
 
227
+ /**
228
+ * An op-wave decision record (`.chant/op-waves/<name>/wave-<k>.json`, written
229
+ * by `chant run wave`), as far as the audit reads it: the wave's gate and
230
+ * digest, and the `outcomes` of its hooks (#3804, #3811, #3847). Typed
231
+ * structurally so this file imports nothing from `../op/`.
232
+ */
233
+ export interface WaveDecisionRecord {
234
+ wave: number;
235
+ name: string;
236
+ op: string;
237
+ gate: string;
238
+ digest?: string;
239
+ heldBy?: string[];
240
+ heldOn?: unknown;
241
+ outcomes?: ReadonlyArray<{ moment: string; name: string; target?: string; exit: number; outcome: string; reason?: string }>;
242
+ }
243
+
244
+ /**
245
+ * A decision record the caller passes in. The record carries no time and no
246
+ * place, so `ref` (a file path, an artifact, a row) and `at` (when it was
247
+ * written) come from the caller. Give the parsed `record`, or its `text`.
248
+ */
249
+ export interface GateAuditDecision {
250
+ ref: string;
251
+ at: string;
252
+ record?: unknown;
253
+ text?: string;
254
+ }
255
+
215
256
  export interface GateAuditOptions {
216
257
  /** Whether a gate is an override gate. Default: op `policy-override`, or a gate named `override-...`. */
217
258
  isOverride?: (op: string, gate: string) => boolean;
@@ -228,6 +269,8 @@ export interface GateAuditOptions {
228
269
  export interface GateAuditInput extends GateAuditOptions {
229
270
  ledgers: readonly GateAuditLedger[];
230
271
  applies?: readonly GateAuditApply[];
272
+ /** Op-wave decision records: each hook outcome in them becomes a `hook` entry linked to the wave's approval and apply (#3810). */
273
+ decisions?: readonly GateAuditDecision[];
231
274
  }
232
275
 
233
276
  export const POLICY_OVERRIDE_OP = "policy-override";
@@ -241,13 +284,18 @@ type Row = Record<string, unknown>;
241
284
 
242
285
  const KNOWN = new Set([
243
286
  "version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
244
- "approver", "seal", "review", "runId", "expiresAt", "description", "wave", "rules", "pr", "head", "reviewers",
287
+ "approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers",
245
288
  ]);
246
289
 
247
290
  const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
248
291
  const w = v as { number?: unknown; name?: unknown; op?: unknown } | null;
249
292
  return w && typeof w === "object" && typeof w.number === "number" && typeof w.name === "string" ? { number: w.number, name: w.name, ...(typeof w.op === "string" ? { op: w.op } : {}) } : undefined;
250
293
  };
294
+ const heldByOf = (v: unknown): { hook: string; moment: string; target?: string } | undefined => {
295
+ const h = v as { hook?: unknown; moment?: unknown; target?: unknown } | null;
296
+ return h && typeof h === "object" && typeof h.hook === "string" && typeof h.moment === "string" ? { hook: h.hook, moment: h.moment, ...(typeof h.target === "string" ? { target: h.target } : {}) } : undefined;
297
+ };
298
+ const modeOf = (v: unknown): GateApprovalSource | undefined => (v === "ledger" || v === "pr-review" || v === "sealed" ? v : undefined);
251
299
  const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
252
300
  const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : undefined);
253
301
  const time = (iso: string): number => Date.parse(iso) || 0;
@@ -302,6 +350,23 @@ interface Seen {
302
350
  /** Whether two digests, either absent, can be the same plan: absent matches anything, as an old line records none. */
303
351
  const compatible = (a: string | undefined, b: string | undefined): boolean => a === undefined || b === undefined || samePlanDigest(a, b);
304
352
 
353
+ function decisionOf(d: GateAuditDecision): WaveDecisionRecord | undefined {
354
+ let v: unknown = d.record;
355
+ if (v === undefined && d.text !== undefined) {
356
+ try {
357
+ v = JSON.parse(d.text);
358
+ } catch {
359
+ return undefined;
360
+ }
361
+ }
362
+ const r = v as Row | null;
363
+ if (!r || typeof r !== "object" || typeof r.wave !== "number" || typeof r.name !== "string" || typeof r.op !== "string" || typeof r.gate !== "string") return undefined;
364
+ const outcomes = Array.isArray(r.outcomes)
365
+ ? r.outcomes.filter((o): o is NonNullable<WaveDecisionRecord["outcomes"]>[number] => !!o && typeof o === "object" && typeof (o as Row).moment === "string" && typeof (o as Row).name === "string" && typeof (o as Row).exit === "number" && typeof (o as Row).outcome === "string")
366
+ : undefined;
367
+ return { wave: r.wave, name: r.name, op: r.op, gate: r.gate, ...(str(r.digest) ? { digest: str(r.digest) } : {}), ...(strings(r.heldBy) ? { heldBy: strings(r.heldBy) } : {}), ...(r.heldOn !== undefined ? { heldOn: r.heldOn } : {}), ...(outcomes ? { outcomes } : {}) };
368
+ }
369
+
305
370
  /** The entries `input` adds up to, oldest first, and the gaps they show. */
306
371
  export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntry[]; gaps: GateAuditGap[] } {
307
372
  const isOverride = input.isOverride ?? isOverrideGate;
@@ -359,7 +424,7 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
359
424
  ...base,
360
425
  kind: override ? "override-requested" : "approval-requested",
361
426
  at: String(r.timestamp),
362
- ...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules), wave: waveOf(r.wave) }),
427
+ ...drop({ runId: str(r.runId), expiresAt: str(r.expiresAt), description: str(r.description), url: str(r.url), rules: strings(r.rules), wave: waveOf(r.wave), heldBy: heldByOf(r.heldBy), approvalMode: modeOf(r.approvalMode) }),
363
428
  detail: extra,
364
429
  }));
365
430
  continue;
@@ -450,6 +515,35 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
450
515
  }
451
516
  }
452
517
 
518
+ for (const d of input.decisions ?? []) {
519
+ const rec = decisionOf(d);
520
+ if (!rec) {
521
+ gaps.push({ kind: "malformed-line", detail: `${d.ref} is not an op-wave decision record` });
522
+ continue;
523
+ }
524
+ const approval = rec.digest === undefined ? undefined : entries
525
+ .filter((e) => e.kind === "approved" && e.gate === rec.gate && e.op === rec.op && e.digest !== undefined && samePlanDigest(e.digest, rec.digest!))
526
+ .sort((x, y) => time(y.at) - time(x.at))[0];
527
+ const applied = rec.digest === undefined ? undefined : entries.find((e) => e.kind === "applied" && e.gate === rec.gate && e.digest !== undefined && samePlanDigest(e.digest, rec.digest!));
528
+ for (const [i, o] of (rec.outcomes ?? []).entries()) {
529
+ if (o.outcome !== "passed" && o.outcome !== "failed" && o.outcome !== "held" && o.outcome !== "skipped") continue;
530
+ entries.push(keyed({
531
+ id: idOf("hook", d.ref, d.at, i, o),
532
+ kind: "hook",
533
+ at: d.at,
534
+ gate: rec.gate,
535
+ op: rec.op,
536
+ ...(rec.digest ? { digest: rec.digest } : {}),
537
+ ...(approval ? { under: approval.id } : {}),
538
+ ...(applied ? { applied: applied.id } : {}),
539
+ hook: { moment: o.moment, name: o.name, ...(o.target ? { target: o.target } : {}), outcome: o.outcome, exit: o.exit, ...(o.reason ? { reason: o.reason } : {}) },
540
+ wave: { number: rec.wave, name: rec.name, op: rec.op },
541
+ detail: drop({ heldBy: rec.heldBy?.length ? rec.heldBy : undefined, heldOn: o.outcome === "held" ? rec.heldOn : undefined }),
542
+ source: { path: d.ref, op: rec.op, change: "decision" },
543
+ }));
544
+ }
545
+ }
546
+
453
547
  entries.sort((x, y) => time(x.at) - time(y.at));
454
548
  return { entries, gaps };
455
549
  }
@@ -510,3 +604,22 @@ export async function readGateAuditLedgers(port: Pick<GateLedgerPort, "read">, o
510
604
  }
511
605
  return out;
512
606
  }
607
+
608
+ /**
609
+ * The thin reader for decision records kept on the ledger branch: every
610
+ * `.json` file directly under `dir` (such as a product's `_wave-decisions/<name>`),
611
+ * as input for {@link GateAuditInput.decisions}. A decision record holds no
612
+ * time, so `at` stamps them all: when the product collected them. Where the
613
+ * records live is the product's business; chant writes them to
614
+ * `.chant/op-waves/<name>/wave-<k>.json` in the job, and a product that
615
+ * collects them elsewhere passes them in directly.
616
+ */
617
+ export async function readGateAuditDecisions(port: Pick<LedgerReadPort, "read" | "list">, dir: string, at: string): Promise<GateAuditDecision[]> {
618
+ const out: GateAuditDecision[] = [];
619
+ for (const file of (await port.list(dir)).sort()) {
620
+ if (!file.endsWith(".json")) continue;
621
+ const text = await port.read(dir, file);
622
+ if (text !== null) out.push({ ref: `${dir}/${file}`, at, text });
623
+ }
624
+ return out;
625
+ }
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import {
7
7
  appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
8
8
  latestResolutionSince, latestPendingGate, isPendingGateExpired,
9
- resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan,
9
+ resolveApprovalUrl, isApprovalUrl, latestResolutionForPlan, parseGateLedger,
10
10
  type PendingGateRecord, type GateResolutionRecord,
11
11
  } from "./gate-ledger";
12
12
  import { readBlobFromPath, writeBlobToPath } from "./git";
@@ -484,3 +484,22 @@ describe("gate ledger newline termination (#3849)", () => {
484
484
  });
485
485
  });
486
486
  });
487
+
488
+ describe("pending fact wave and heldBy (#3886, #3887)", () => {
489
+ const line = (o: Record<string, unknown>) =>
490
+ JSON.stringify({ version: 1, kind: "pending", op: "tg", gate: "g", timestamp: "2026-10-01T00:00:00Z", expiresAt: "2026-10-02T00:00:00Z", ...o });
491
+ test("the reader types wave, heldBy and approvalMode", () => {
492
+ const { pending } = parseGateLedger(line({ approvalMode: "pr-review", wave: { number: 2, name: "prod", op: "tg" }, heldBy: { hook: "migrate", moment: "before-apply", target: "vpc" } }));
493
+ const p: PendingGateRecord = pending[0]!;
494
+ expect(p.wave).toEqual({ number: 2, name: "prod", op: "tg" });
495
+ expect(p.heldBy).toEqual({ hook: "migrate", moment: "before-apply", target: "vpc" });
496
+ expect(p.approvalMode).toBe("pr-review");
497
+ });
498
+ test("a wave or heldBy of the wrong shape is dropped, the line is kept", () => {
499
+ const { pending, malformed } = parseGateLedger(line({ wave: { number: "2" }, heldBy: "migrate" }));
500
+ expect(malformed).toBe(0);
501
+ expect(pending).toHaveLength(1);
502
+ expect(pending[0]!.wave).toBeUndefined();
503
+ expect(pending[0]!.heldBy).toBeUndefined();
504
+ });
505
+ });