@intentius/chant 0.124.41 → 0.124.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +3 -0
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -3
  5. package/dist/cli/handlers/pr.d.ts +3 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +112 -2
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.js +11 -0
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +8 -2
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +15 -0
  14. package/dist/cli/registry.d.ts +19 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/capability.d.ts +47 -0
  17. package/dist/components/capability.d.ts.map +1 -1
  18. package/dist/components/capability.js +44 -0
  19. package/dist/components/cli-support.d.ts +6 -0
  20. package/dist/components/cli-support.d.ts.map +1 -1
  21. package/dist/components/cli-support.js +8 -1
  22. package/dist/components/driver.d.ts +10 -0
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/driver.js +52 -2
  25. package/dist/components/fan-out-run.d.ts +9 -0
  26. package/dist/components/fan-out-run.d.ts.map +1 -1
  27. package/dist/components/fan-out-run.js +17 -1
  28. package/dist/components/gate-plan.d.ts +2 -0
  29. package/dist/components/gate-plan.d.ts.map +1 -1
  30. package/dist/components/gate-plan.js +1 -0
  31. package/dist/components/pr-pipeline.d.ts +20 -0
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +54 -16
  34. package/dist/components/pr-run.d.ts +6 -0
  35. package/dist/components/pr-run.d.ts.map +1 -1
  36. package/dist/components/pr-run.js +3 -0
  37. package/dist/components/wave-plan.d.ts +14 -1
  38. package/dist/components/wave-plan.d.ts.map +1 -1
  39. package/dist/components/wave-plan.js +34 -2
  40. package/dist/op/activities/pr-refs.d.ts +10 -0
  41. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  42. package/dist/op/activities/pr-refs.js +26 -0
  43. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  44. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  45. package/dist/op/activities/predict-behaviour.js +2 -21
  46. package/dist/op/activities/review-agent.js +1 -1
  47. package/dist/op/op-waves-run.d.ts +3 -79
  48. package/dist/op/op-waves-run.d.ts.map +1 -1
  49. package/dist/op/op-waves-run.js +4 -133
  50. package/dist/op/stage-hooks.d.ts +132 -0
  51. package/dist/op/stage-hooks.d.ts.map +1 -0
  52. package/dist/op/stage-hooks.js +142 -0
  53. package/dist/pr-forge.d.ts +28 -0
  54. package/dist/pr-forge.d.ts.map +1 -1
  55. package/dist/pr-forge.js +251 -46
  56. package/dist/pr-loop.d.ts +18 -1
  57. package/dist/pr-loop.d.ts.map +1 -1
  58. package/dist/pr-loop.js +7 -0
  59. package/dist/pr-requires.d.ts +38 -0
  60. package/dist/pr-requires.d.ts.map +1 -0
  61. package/dist/pr-requires.js +48 -0
  62. package/dist/wave-runtime.mjs +201 -201
  63. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  64. package/dist/workspace/checks/ci-workflow.js +19 -4
  65. package/dist/workspace/ci-init.d.ts.map +1 -1
  66. package/dist/workspace/ci-init.js +5 -4
  67. package/dist/workspace/ci-run.d.ts.map +1 -1
  68. package/dist/workspace/ci-run.js +2 -1
  69. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  70. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  71. package/dist/workspace/ci-workflow-at.js +17 -0
  72. package/dist/workspace/ci-workflow.d.ts +11 -3
  73. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/ci-workflow.js +10 -2
  75. package/dist/workspace/declaration.d.ts +6 -3
  76. package/dist/workspace/declaration.d.ts.map +1 -1
  77. package/dist/workspace/declaration.schema.json +2 -2
  78. package/dist/workspace/pr-report.schema.json +23 -3
  79. package/dist/workspace/reason-codes.d.ts +1 -1
  80. package/dist/workspace/reason-codes.d.ts.map +1 -1
  81. package/dist/workspace/reason-codes.js +1 -1
  82. package/package.json +1 -1
  83. package/src/cli/handlers/build.ts +3 -0
  84. package/src/cli/handlers/fan-out.ts +27 -3
  85. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  86. package/src/cli/handlers/pr.ts +109 -2
  87. package/src/cli/handlers/run.ts +10 -0
  88. package/src/cli/main.ts +8 -2
  89. package/src/cli/parse-args.ts +11 -0
  90. package/src/cli/registry.ts +19 -0
  91. package/src/components/capability-targets.test.ts +162 -0
  92. package/src/components/capability.ts +71 -0
  93. package/src/components/cli-support.ts +14 -1
  94. package/src/components/driver.ts +54 -3
  95. package/src/components/fan-out-run.ts +19 -1
  96. package/src/components/gate-plan.ts +3 -0
  97. package/src/components/pr-pipeline.ts +64 -16
  98. package/src/components/pr-run.ts +9 -0
  99. package/src/components/wave-plan.ts +38 -3
  100. package/src/op/activities/pr-refs.ts +28 -0
  101. package/src/op/activities/predict-behaviour.ts +3 -23
  102. package/src/op/activities/review-agent.ts +1 -1
  103. package/src/op/op-waves-run.ts +20 -228
  104. package/src/op/stage-hooks.ts +251 -0
  105. package/src/pr-forge-bundle.test.ts +2 -2
  106. package/src/pr-forge.ts +235 -37
  107. package/src/pr-loop-bundle.test.ts +2 -2
  108. package/src/pr-loop.ts +25 -1
  109. package/src/pr-requires.test.ts +184 -0
  110. package/src/pr-requires.ts +79 -0
  111. package/src/review-agent-bundle.test.ts +35 -0
  112. package/src/stage-hooks-bundle.test.ts +38 -0
  113. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  114. package/src/workspace/checks/ci-workflow.ts +18 -4
  115. package/src/workspace/ci-init.ts +5 -4
  116. package/src/workspace/ci-run.ts +2 -1
  117. package/src/workspace/ci-workflow-at.ts +23 -0
  118. package/src/workspace/ci-workflow.ts +13 -2
  119. package/src/workspace/declaration.schema.json +2 -2
  120. package/src/workspace/declaration.ts +6 -3
  121. package/src/workspace/pr-report.schema.json +23 -3
  122. package/src/workspace/reason-codes.ts +1 -1
@@ -27,11 +27,11 @@
27
27
  * `@<component>.publish.*`) resolves.
28
28
  */
29
29
 
30
- import { resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
30
+ import { assertTargetsAccepted, resolveStepInput, unresolvedWiringSources, type DriverComponent, type DriverPhase, type DriverStep } from "./driver";
31
31
  import { componentPlanDigest } from "./gate-plan";
32
32
  import { componentDeployInputs, type ComponentDeployInputs } from "./deploy-inputs";
33
33
  import { computePlanDigest } from "../lifecycle/plan-digest";
34
- import type { CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
34
+ import type { CapabilityPlan, CapabilityRegistry, DeployContext, PlannedOutputs } from "./capability";
35
35
  import type { WaveMember } from "../gated-waves";
36
36
  import type { ChangeSetPart } from "../change-set";
37
37
 
@@ -101,12 +101,13 @@ export async function planWaveComponent(
101
101
  const plans: Record<string, unknown> = {};
102
102
  const parts: ChangeSetPart[] = [];
103
103
  let outputs: PlannedOutputs | undefined;
104
+ assertTargetsAccepted(component, registry, ctx);
104
105
  for (const step of capabilitySteps(component.deploy)) {
105
106
  const capability = registry.resolve(step.kind);
106
107
  if (!capability.plan) continue;
107
108
  const { kind: _kind, ...rest } = step;
108
109
  const input = resolveStepInput(rest, {}, componentOutputs);
109
- const planned = await capability.plan(planContext(ctx, rest, componentOutputs), input as never);
110
+ const planned = bindTargets(step.kind, ctx, await capability.plan(planContext(ctx, rest, componentOutputs), input as never));
110
111
  const member = { member: planned.member, planDigest: planned.planDigest };
111
112
  members.push(member);
112
113
  parts.push(planned.changeSet ?? emptyPart(member));
@@ -124,6 +125,7 @@ export async function planWaveComponent(
124
125
  component,
125
126
  ...(ctx.vars ? { vars: ctx.vars } : {}),
126
127
  inputs: read.inputs,
128
+ ...(ctx.targets ? { targets: ctx.targets } : {}),
127
129
  }),
128
130
  };
129
131
  members.push(member);
@@ -145,6 +147,39 @@ export async function planWaveComponent(
145
147
  }
146
148
  }
147
149
 
150
+ /**
151
+ * A member's digest with its targets in it (#4300), the way terragucci's
152
+ * cone plan digest binds a cone: the digest over the plan's own digest and
153
+ * the sorted targets, so an approval of one narrowing covers no other.
154
+ */
155
+ export function targetedPlanDigest(planDigest: string, targets: readonly string[]): string {
156
+ return computePlanDigest("capability-targets", { plan: planDigest, targets: [...new Set(targets)].sort() });
157
+ }
158
+
159
+ /**
160
+ * A step's plan made with `ctx.targets` (#4300), its digest bound to them.
161
+ * The plan must give back the targets it was asked for, which says it planned
162
+ * those alone; one that gives back others, or none, refuses. Without
163
+ * `ctx.targets`, the plan as it came.
164
+ */
165
+ export function bindTargets(kind: string, ctx: DeployContext, planned: CapabilityPlan): CapabilityPlan {
166
+ const asked = ctx.targets;
167
+ if (!asked || asked.length === 0) return planned;
168
+ const want = [...new Set(asked)].sort();
169
+ const got = [...new Set(planned.targets ?? [])].sort();
170
+ if (got.length !== want.length || got.some((t, i) => t !== want[i])) {
171
+ throw new Error(
172
+ `component "${ctx.component}": ${kind} was asked to plan ${want.join(", ")} but its plan of ${planned.member} ` +
173
+ (got.length === 0 ? "names no targets" : `names ${got.join(", ")}`),
174
+ );
175
+ }
176
+ const planDigest = targetedPlanDigest(planned.planDigest, want);
177
+ const changeSet = planned.changeSet
178
+ ? { ...planned.changeSet, member: { ...planned.changeSet.member, planDigest } }
179
+ : undefined;
180
+ return { ...planned, planDigest, targets: want, ...(changeSet ? { changeSet } : {}) };
181
+ }
182
+
148
183
  /** `next` over `prior`, as a later step's outputs win over an earlier one's in a run. */
149
184
  function mergePlannedOutputs(prior: PlannedOutputs | undefined, next: PlannedOutputs): PlannedOutputs {
150
185
  const known = { ...(prior?.known ?? {}), ...next.known };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The pull request's base and head branch names, read off the run's own
3
+ * event (#4299). Its own module so the review agent bundles without
4
+ * predict-behaviour's estate code.
5
+ */
6
+
7
+ /** Where the base branch name comes from, most specific first. Pure — exported for testing. */
8
+ export function baseRefFrom(
9
+ env: Record<string, string | undefined>,
10
+ explicit?: string,
11
+ ): string | undefined {
12
+ const fromArgs = explicit?.trim();
13
+ if (fromArgs) return fromArgs;
14
+ for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
15
+ const value = env[source]?.trim();
16
+ if (value) return value;
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
22
+ export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
23
+ for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
24
+ const value = env[source]?.trim();
25
+ if (value) return value;
26
+ }
27
+ return shortSha?.trim() || "head";
28
+ }
@@ -85,6 +85,9 @@ import type { BehaviourKinds } from "../../behaviour-kinds";
85
85
  import { createBehaviourPredict } from "../../behaviour-predict";
86
86
  import type { SerializerResult } from "../../serializer";
87
87
  import { markerSlug, reconcilePr, suppliedMarker, type ReconcileResult } from "./reconcile";
88
+ import { baseRefFrom, headRefFrom } from "./pr-refs";
89
+
90
+ export { baseRefFrom, headRefFrom };
88
91
 
89
92
  const execAsync = promisify(exec);
90
93
 
@@ -294,29 +297,6 @@ export function noBaseRefMessage(): string {
294
297
  );
295
298
  }
296
299
 
297
- /** Where the base branch name comes from, most specific first. Pure — exported for testing. */
298
- export function baseRefFrom(
299
- env: Record<string, string | undefined>,
300
- explicit?: string,
301
- ): string | undefined {
302
- const fromArgs = explicit?.trim();
303
- if (fromArgs) return fromArgs;
304
- for (const source of ["GITHUB_BASE_REF", "CI_MERGE_REQUEST_TARGET_BRANCH_NAME"]) {
305
- const value = env[source]?.trim();
306
- if (value) return value;
307
- }
308
- return undefined;
309
- }
310
-
311
- /** What the head side is called in the finding: the source branch when the run names one, else the short commit. */
312
- export function headRefFrom(env: Record<string, string | undefined>, shortSha?: string): string {
313
- for (const source of ["GITHUB_HEAD_REF", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]) {
314
- const value = env[source]?.trim();
315
- if (value) return value;
316
- }
317
- return shortSha?.trim() || "head";
318
- }
319
-
320
300
  /** A checked-out base branch: where its project root is, and how to remove it. */
321
301
  export interface BaseCheckout {
322
302
  /** The base branch's counterpart of the working directory. */
@@ -27,7 +27,7 @@ import { spawn, execFile } from "node:child_process";
27
27
  import { promisify } from "node:util";
28
28
  import { readFile } from "node:fs/promises";
29
29
  import type { ChangeSetDocument, ChangeSetEntry } from "../../change-set";
30
- import { baseRefFrom } from "./predict-behaviour";
30
+ import { baseRefFrom } from "./pr-refs";
31
31
  import { markerSlug, reconcilePr, suppliedMarker } from "./reconcile";
32
32
 
33
33
  const execFileAsync = promisify(execFile);
@@ -105,6 +105,26 @@ import {
105
105
  type OpWaveRun,
106
106
  type OpWavesSpec,
107
107
  } from "./op-waves";
108
+ import {
109
+ OP_WAVE_PENDING_RESERVED,
110
+ opWaveArgv,
111
+ pendingFields,
112
+ readHoldRequest,
113
+ runHookList,
114
+ runStageHooks,
115
+ spawnExec,
116
+ type HookPlace,
117
+ type OpWaveExec,
118
+ type OpWaveHoldRequest,
119
+ type OpWaveHookOutcome,
120
+ type OpWaveHookRun,
121
+ type OtherGateHold,
122
+ type RunStageHooksOptions,
123
+ type StageHooksRun,
124
+ } from "./stage-hooks";
125
+
126
+ export { OP_WAVE_PENDING_RESERVED, opWaveArgv, runStageHooks };
127
+ export type { OpWaveExec, OpWaveHoldRequest, OpWaveHookOutcome, OpWaveHookRun, RunStageHooksOptions, StageHooksRun };
108
128
 
109
129
  /** What a run's plan command writes to `{plan}`. */
110
130
  export interface OpWavePlanFile {
@@ -154,12 +174,6 @@ export interface OpWavePlansFile {
154
174
  waves?: number;
155
175
  }
156
176
 
157
- /** Pending-fact keys chant writes itself, which a plans file's `pending` may not set (#3821). */
158
- export const OP_WAVE_PENDING_RESERVED = [
159
- "version", "kind", "op", "gate", "timestamp", "expiresAt", "planDigest", "runId", "url", "resume",
160
- "environment", "origin", "neverOverMcp", "approval", "approvalMode", "answer", "wave", "heldBy",
161
- ] as const;
162
-
163
177
  /** What chant writes to `{runs}` for a wave-level command or a hook (#3762). */
164
178
  export interface OpWaveRunsFile {
165
179
  /** The wave's name. */
@@ -305,18 +319,6 @@ export interface OpWaveApplyOutcome {
305
319
 
306
320
  export const OP_WAVE_APPLY_OUTCOMES = ["stood-down", "held", "replan"] as const;
307
321
 
308
- /** What a hook asks for when it holds a wave on another gate (#3821): it writes this to `CHANT_HOLD` and exits non-zero. */
309
- export interface OpWaveHoldRequest {
310
- /** The op the gate is recorded under. Default: the spec's name. */
311
- op?: string;
312
- gate: string;
313
- /** The digest an approval binds. */
314
- digest: string;
315
- description?: string;
316
- /** Fields for the pending fact, as a plans file's `pending`. */
317
- pending?: Record<string, unknown>;
318
- }
319
-
320
322
  /** A hold on another gate, as the decision records it (#3821). */
321
323
  export interface OpWaveHeldOn {
322
324
  op: string;
@@ -347,26 +349,6 @@ export interface OpWaveHeldOnApproval {
347
349
  approvedAt: string;
348
350
  }
349
351
 
350
- /** One hook's run (#3762). */
351
- export interface OpWaveHookOutcome {
352
- moment: OpWaveHookMoment;
353
- name: string;
354
- /** The run, for a `perRun` hook. */
355
- target?: string;
356
- exit: number;
357
- outcome: "passed" | "failed" | "held" | "skipped";
358
- /** Why the moment was skipped, for `skipped` (#3811). */
359
- reason?: string;
360
- }
361
-
362
- /** One run as a hook sees it (#3804). */
363
- export interface OpWaveHookRun {
364
- target: string;
365
- op: string;
366
- /** The run's plan file, once planned. */
367
- plan?: string;
368
- }
369
-
370
352
  /**
371
353
  * What chant hands an apply command in `CHANT_HOOKS` (#3804), and what
372
354
  * {@link opWaveHooksAtBase} builds for an apply outside the waves. Paths are
@@ -442,15 +424,6 @@ export interface OpWaveApplyHooksRun {
442
424
  proceed: boolean;
443
425
  }
444
426
 
445
- /** Runs one argv; returns its exit code. `env` is added to the job's environment. Injected so tests run no processes. */
446
- export type OpWaveExec = (argv: string[], cwd: string, env?: Record<string, string>) => number;
447
-
448
- const spawnExec: OpWaveExec = (argv, cwd, env) => {
449
- const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit", ...(env ? { env: { ...process.env, ...env } } : {}) });
450
- if (result.error) throw result.error;
451
- return result.status ?? 1;
452
- };
453
-
454
427
  /** Reads one file at one commit, or null when the commit has no such file. Injected for tests. */
455
428
  export type OpWaveShowAtBase = (base: string, path: string, cwd: string) => { sha: string; text: string | null };
456
429
 
@@ -581,22 +554,6 @@ function pointAtBase(spec: OpWavesSpec, name: string, where: string): Pick<OpWav
581
554
  return { point: opWavePointAt(spec, name)! };
582
555
  }
583
556
 
584
- /** Fill one argv template. A placeholder whose value is not given is left as it is. */
585
- export function opWaveArgv(
586
- template: readonly string[],
587
- values: { op: string; wave: string; target?: string; plan?: string; runs?: string; plans?: string },
588
- ): string[] {
589
- const pairs: Array<[string, string | undefined]> = [
590
- ["{op}", values.op],
591
- ["{target}", values.target],
592
- ["{wave}", values.wave],
593
- ["{plans}", values.plans],
594
- ["{plan}", values.plan],
595
- ["{runs}", values.runs],
596
- ];
597
- return template.map((part) => pairs.reduce((out, [key, value]) => (value === undefined ? out : out.replaceAll(key, value)), part));
598
- }
599
-
600
557
  /** Where a run's plan file goes. */
601
558
  export function opWavePlanPath(spec: Pick<OpWavesSpec, "name">, wave: number, target: string): string {
602
559
  return `.chant/op-waves/${spec.name}/wave-${wave}/${target.replace(/[^A-Za-z0-9._-]/g, "_")}.plan.json`;
@@ -853,16 +810,6 @@ function extraMembers(value: unknown, targets: ReadonlySet<string>, where: strin
853
810
  return out.length > 0 ? out : undefined;
854
811
  }
855
812
 
856
- /** A plans file's `pending` (#3821): an object whose keys chant does not write itself, `description` aside. Undefined when absent or empty. */
857
- function pendingFields(value: unknown, where: string): Record<string, unknown> | undefined {
858
- if (value === undefined) return undefined;
859
- if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${where}: \`pending\` must be an object of fields for the pending fact`);
860
- const reserved = Object.keys(value).filter((key) => (OP_WAVE_PENDING_RESERVED as readonly string[]).includes(key));
861
- if (reserved.length > 0) throw new Error(`${where}: \`pending\` sets ${reserved.join(", ")}, which chant writes itself`);
862
- if ("description" in value && typeof (value as { description?: unknown }).description !== "string") throw new Error(`${where}: pending.description must be a string`);
863
- return Object.keys(value).length > 0 ? (value as Record<string, unknown>) : undefined;
864
- }
865
-
866
813
  /**
867
814
  * A wave's set digest (#3821): {@link waveSetDigest} over its runs' plans,
868
815
  * or under `digest.over: "changing"` over the runs whose plan is not empty,
@@ -883,115 +830,6 @@ interface HooksOutcome {
883
830
  waitingOn?: OpWaveHeldOn;
884
831
  }
885
832
 
886
- /** A hook call that held the wave on another gate (#3821), kept so it can run again once that gate is approved. */
887
- interface OtherGateHold {
888
- label: string;
889
- name: string;
890
- moment: OpWaveHookMoment;
891
- target?: string;
892
- argv: string[];
893
- env: Record<string, string>;
894
- request: OpWaveHoldRequest;
895
- }
896
-
897
- /** Read the hold file a hook wrote (#3821), or undefined when it wrote none or one that names no gate and digest. */
898
- function readHoldRequest(path: string, log: (line: string) => void, label: string): OpWaveHoldRequest | undefined {
899
- if (!existsSync(path)) return undefined;
900
- try {
901
- const r = JSON.parse(readFileSync(path, "utf-8")) as Partial<OpWaveHoldRequest>;
902
- if (typeof r.gate !== "string" || r.gate === "" || typeof r.digest !== "string" || r.digest === "") throw new Error("it names no gate and digest");
903
- if (r.op !== undefined && (typeof r.op !== "string" || r.op === "")) throw new Error("its op is not a name");
904
- return { gate: r.gate, digest: r.digest, ...(r.op ? { op: r.op } : {}), ...(typeof r.description === "string" ? { description: r.description } : {}), ...(r.pending ? { pending: pendingFields(r.pending, `the hold ${label} wrote`) ?? {} } : {}) };
905
- } catch (err) {
906
- log(`${label} wrote a hold file that cannot be read (${err instanceof Error ? err.message : String(err)}), so it holds the wave on its set digest`);
907
- return undefined;
908
- }
909
- }
910
-
911
- /** Where hooks run: the wave they belong to and the job's tools. */
912
- interface HookPlace {
913
- /** The spec's name and `op`. */
914
- name: string;
915
- op: string;
916
- waveName: string;
917
- k: number;
918
- cwd: string;
919
- exec: OpWaveExec;
920
- log: (line: string) => void;
921
- /** Where a hook writes a hold on another gate (`CHANT_HOLD`). Default: under the wave's directory. */
922
- holdPath?: string;
923
- }
924
-
925
- /**
926
- * Run one moment's hooks, in order: each once for the wave, or once per run
927
- * for a `perRun` hook. A hook that fails stops the rest, unless its
928
- * `onFailure` is `approve`: then it holds the wave and the hooks after it run.
929
- */
930
- function runHookList(
931
- at: HookPlace,
932
- moment: OpWaveHookMoment,
933
- hooks: readonly OpWaveHook[] | undefined,
934
- runs: readonly OpWaveHookRun[],
935
- runsPath: string,
936
- extraEnv: Record<string, string> = {},
937
- ): HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } {
938
- const out: HooksOutcome & { outcomes: OpWaveHookOutcome[]; otherGates: OtherGateHold[] } = { holds: [], outcomes: [], otherGates: [] };
939
- if (!hooks || hooks.length === 0) return out;
940
- const { name: specName, op, waveName, k, cwd, exec, log } = at;
941
- // #3821: a hook that holds the wave on another gate names it in this file.
942
- const holdPath = at.holdPath ?? `.chant/op-waves/${specName}/wave-${k}/hold.json`;
943
- const env = { CHANT_OP_WAVES: specName, CHANT_WAVE: waveName, CHANT_WAVE_NUMBER: String(k), CHANT_HOOK: moment, CHANT_RUNS: runsPath, CHANT_HOLD: holdPath, ...extraEnv };
944
- for (const hook of hooks) {
945
- const name = hook.name ?? (hook.command.join(" ").length > 60 ? `${hook.command.join(" ").slice(0, 57)}...` : hook.command.join(" "));
946
- // #4288: a hook with `targets` is for the runs they match. Once for the wave, it runs when one matches or no run is known yet.
947
- const mine = opWaveHookRuns(hook, runs);
948
- if (hook.targets && (hook.perRun ? mine.length === 0 : mine.length === 0 && runs.length > 0)) {
949
- log(`${moment} hook ${name}: no run matches its targets (${hook.targets.join(", ")}), so it did not run`);
950
- continue;
951
- }
952
- const calls = hook.perRun
953
- ? mine.map((run) => ({
954
- target: run.target as string | undefined,
955
- argv: opWaveArgv(hook.command, { op: run.op, wave: waveName, target: run.target, runs: runsPath, ...(run.plan ? { plan: run.plan } : {}) }),
956
- env: { ...env, CHANT_TARGET: run.target, ...(run.plan ? { CHANT_PLAN: run.plan } : {}) } as Record<string, string>,
957
- }))
958
- : [{ target: undefined, argv: opWaveArgv(hook.command, { op, wave: waveName, runs: runsPath }), env: env as Record<string, string> }];
959
- for (const call of calls) {
960
- const label = `${moment} hook ${name}${call.target ? ` (${call.target})` : ""}`;
961
- let code: number;
962
- rmSync(join(cwd, holdPath), { force: true });
963
- try {
964
- code = exec(call.argv, cwd, call.env);
965
- } catch (err) {
966
- log(`${label} did not run: ${err instanceof Error ? err.message : String(err)}`);
967
- code = 127;
968
- }
969
- const base = { moment, name, ...(call.target ? { target: call.target } : {}), exit: code };
970
- if (code === 0) {
971
- out.outcomes.push({ ...base, outcome: "passed" });
972
- continue;
973
- }
974
- if (hook.onFailure === "approve") {
975
- out.outcomes.push({ ...base, outcome: "held" });
976
- const request = readHoldRequest(join(cwd, holdPath), log, label);
977
- if (request) {
978
- out.otherGates.push({ label, name, moment, ...(call.target ? { target: call.target } : {}), argv: call.argv, env: call.env, request });
979
- log(`${label} exited ${code} and holds wave ${k} (${waveName}) on gate ${request.op ?? specName} / ${request.gate}, digest ${request.digest}`);
980
- continue;
981
- }
982
- out.holds.push(label);
983
- log(`${label} exited ${code}, so wave ${k} (${waveName}) waits for an approval of its set digest`);
984
- continue;
985
- }
986
- out.outcomes.push({ ...base, outcome: "failed" });
987
- out.failed = `${label} exited ${code}`;
988
- log(out.failed);
989
- return out;
990
- }
991
- }
992
- return out;
993
- }
994
-
995
833
  const placeOf = (ctx: WaveContext): HookPlace => ({ name: ctx.spec.name, op: ctx.spec.op, waveName: ctx.wave.name, k: ctx.k, cwd: ctx.cwd, exec: ctx.exec, log: ctx.log });
996
834
 
997
835
  const hookRuns = (spec: OpWavesSpec, k: number, runs: readonly OpWaveRun[], members?: ReadonlyMap<string, OpWaveMember>): OpWaveHookRun[] =>
@@ -1076,52 +914,6 @@ async function runHooks(
1076
914
  return back;
1077
915
  }
1078
916
 
1079
- /** The options of {@link runStageHooks} (#4288). */
1080
- export interface RunStageHooksOptions {
1081
- /** What the hooks belong to: `CHANT_OP_WAVES`, and the `{op}` of a hook that runs once. */
1082
- name: string;
1083
- op: string;
1084
- /** `{wave}` and `CHANT_WAVE`. */
1085
- wave: string;
1086
- /** `CHANT_WAVE_NUMBER`. Default 0. */
1087
- k?: number;
1088
- /** The runs a `perRun` hook runs for and `targets` match against. */
1089
- runs: readonly OpWaveHookRun[];
1090
- /** `{runs}` and `CHANT_RUNS`, relative to `cwd`; the caller writes it. */
1091
- runsFile: string;
1092
- /** `CHANT_HOLD`, relative to `cwd`. */
1093
- holdFile: string;
1094
- /** Added to every hook's environment. */
1095
- env?: Record<string, string>;
1096
- cwd?: string;
1097
- exec?: OpWaveExec;
1098
- log?: (line: string) => void;
1099
- }
1100
-
1101
- /** What {@link runStageHooks} gave. */
1102
- export interface StageHooksRun {
1103
- outcomes: OpWaveHookOutcome[];
1104
- /** Set when a hook with `onFailure: fail` failed; the hooks after it did not run. */
1105
- failed?: string;
1106
- /** The `onFailure: approve` hooks that failed. A hold on another gate counts as one here. */
1107
- holds: string[];
1108
- }
1109
-
1110
- /**
1111
- * Run one moment's hooks outside an Op waves job (#4288): the pull-request
1112
- * jobs, or a product's own stage, with the same `targets`, `perRun`,
1113
- * `onFailure` and argv filling a wave job uses. A hook that holds on another
1114
- * gate (`CHANT_HOLD`) is counted as a plain hold; settling that gate is the
1115
- * caller's.
1116
- */
1117
- export function runStageHooks(moment: OpWaveHookMoment, hooks: readonly OpWaveHook[] | undefined, options: RunStageHooksOptions): StageHooksRun {
1118
- const cwd = options.cwd ?? process.cwd();
1119
- const log = options.log ?? ((line: string) => console.error(line));
1120
- const place: HookPlace = { name: options.name, op: options.op, waveName: options.wave, k: options.k ?? 0, cwd, exec: options.exec ?? spawnExec, log, holdPath: options.holdFile };
1121
- const out = runHookList(place, moment, hooks, options.runs, options.runsFile, options.env);
1122
- return { outcomes: out.outcomes, ...(out.failed ? { failed: out.failed } : {}), holds: [...out.holds, ...out.otherGates.map((g) => `${g.label} (on ${g.request.op ?? options.name} / ${g.request.gate})`)] };
1123
- }
1124
-
1125
917
  /**
1126
918
  * The hooks that run before a job plans (#4288): `before-observe`, then
1127
919
  * `before-plan`. A failure or a hold on another gate in the first ends it