@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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Resource targeting (#4300): `--target <component>:<address>` narrows a
3
+ * component's steps, each capability accepts or refuses by name, and the
4
+ * targets join the plan digest an approval binds.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { CapabilityRegistry, parseComponentTargets, targetRefusal, type CapabilityPlan, type DeployContext } from "./capability";
9
+ import { deployContext, runComponentDeploy, type DriverComponent } from "./driver";
10
+ import { bindTargets, planWaveComponent, targetedPlanDigest } from "./wave-plan";
11
+ import { fanOutGateDigest, runFanOut } from "./fan-out-run";
12
+ import { planFanOut } from "./fan-out";
13
+ import { memoryGateLedgerPort } from "../op/gate";
14
+
15
+ const component = (name: string, ...kinds: string[]): DriverComponent => ({
16
+ name,
17
+ deploy: [{ phase: "Deploy", steps: kinds.map((kind) => ({ kind })) }],
18
+ });
19
+
20
+ /** A registry with a verb that accepts targets, one that refuses, and one that says nothing. */
21
+ function registry(seen: DeployContext[] = []): CapabilityRegistry {
22
+ const r = new CapabilityRegistry();
23
+ r.register({
24
+ kind: "narrow",
25
+ targets: "accept",
26
+ async plan(ctx: DeployContext): Promise<CapabilityPlan> {
27
+ seen.push(ctx);
28
+ return { member: ctx.component, planDigest: "sha256:base", ...(ctx.targets ? { targets: ctx.targets } : {}) };
29
+ },
30
+ async run(ctx: DeployContext) {
31
+ seen.push(ctx);
32
+ return { ok: true };
33
+ },
34
+ } as never);
35
+ r.register({
36
+ kind: "whole",
37
+ targets: { refuse: "whole applies everything" },
38
+ async plan(ctx: DeployContext): Promise<CapabilityPlan> {
39
+ return { member: ctx.component, planDigest: "sha256:whole" };
40
+ },
41
+ async run() {
42
+ return { ok: true };
43
+ },
44
+ } as never);
45
+ r.register({
46
+ kind: "silent",
47
+ async run() {
48
+ return { ok: true };
49
+ },
50
+ } as never);
51
+ return r;
52
+ }
53
+
54
+ describe("parseComponentTargets", () => {
55
+ test("groups by component, sorted and unique, splitting at the first colon", () => {
56
+ expect(parseComponentTargets(["net:aws_vpc.main", "net:aws_subnet.a", "net:aws_vpc.main", "app:Deployment/prod/web", "net:x[\"a:b\"]"])).toEqual({
57
+ app: ["Deployment/prod/web"],
58
+ net: ["aws_subnet.a", "aws_vpc.main", 'x["a:b"]'],
59
+ });
60
+ });
61
+
62
+ test("refuses a value with no component or no address, and an unknown component", () => {
63
+ expect(() => parseComponentTargets([":aws_vpc.main"])).toThrow(/--target <component>:<address>/);
64
+ expect(() => parseComponentTargets(["net:"])).toThrow(/--target <component>:<address>/);
65
+ expect(() => parseComponentTargets(["nope:a"], new Set(["net"]))).toThrow(/no component is named nope/);
66
+ });
67
+ });
68
+
69
+ describe("targetRefusal", () => {
70
+ test("accept, refuse with its reason, and unset refusing by name", () => {
71
+ expect(targetRefusal({ kind: "a", targets: "accept" })).toBeUndefined();
72
+ expect(targetRefusal({ kind: "b", targets: { refuse: "no" } })).toBe("no");
73
+ expect(targetRefusal({ kind: "c" })).toMatch(/^c does not say/);
74
+ });
75
+ });
76
+
77
+ describe("deployContext", () => {
78
+ test("gives a component its own targets, sorted, and none to another", () => {
79
+ const options = { env: "dev", targets: { net: ["b", "a", "a"] } };
80
+ expect(deployContext(options, "net").targets).toEqual(["a", "b"]);
81
+ expect(deployContext(options, "app").targets).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe("planning a targeted component", () => {
86
+ test("binds the targets into the member's digest and its change-set part", async () => {
87
+ const seen: DeployContext[] = [];
88
+ const ctx = deployContext({ env: "dev", targets: { net: ["aws_vpc.main"] } }, "net");
89
+ const planned = await planWaveComponent(component("net", "narrow"), registry(seen), ctx, {});
90
+ expect("error" in planned).toBe(false);
91
+ if ("error" in planned) return;
92
+ expect(seen[0]!.targets).toEqual(["aws_vpc.main"]);
93
+ const digest = targetedPlanDigest("sha256:base", ["aws_vpc.main"]);
94
+ expect(planned.members).toEqual([{ member: "net", planDigest: digest }]);
95
+ expect(planned.parts[0]!.member.planDigest).toBe(digest);
96
+ });
97
+
98
+ test("another set of targets is another digest, and none is the plan's own", async () => {
99
+ const a = targetedPlanDigest("sha256:base", ["x"]);
100
+ const b = targetedPlanDigest("sha256:base", ["y"]);
101
+ expect(a).not.toBe(b);
102
+ expect(targetedPlanDigest("sha256:base", ["y", "x"])).toBe(targetedPlanDigest("sha256:base", ["x", "y"]));
103
+ const untargeted = await planWaveComponent(component("net", "narrow"), registry(), deployContext({ env: "dev" }, "net"), {});
104
+ expect("error" in untargeted ? undefined : untargeted.members[0]!.planDigest).toBe("sha256:base");
105
+ });
106
+
107
+ test("a verb that refuses names itself, and so does one that says nothing", async () => {
108
+ const ctx = deployContext({ env: "dev", targets: { net: ["a"] } }, "net");
109
+ const refused = await planWaveComponent(component("net", "narrow", "whole"), registry(), ctx, {});
110
+ expect(refused).toEqual({ component: "net", error: 'component "net": whole refuses targets: whole applies everything' });
111
+ const silent = await planWaveComponent(component("net", "silent"), registry(), ctx, {});
112
+ expect("error" in silent && silent.error).toMatch(/silent refuses targets: silent does not say/);
113
+ });
114
+
115
+ test("a plan that does not give the targets back refuses", () => {
116
+ const ctx: DeployContext = { env: "dev", component: "net", targets: ["a", "b"] };
117
+ expect(() => bindTargets("narrow", ctx, { member: "net", planDigest: "d" })).toThrow(/names no targets/);
118
+ expect(() => bindTargets("narrow", ctx, { member: "net", planDigest: "d", targets: ["a"] })).toThrow(/names a$/);
119
+ });
120
+ });
121
+
122
+ describe("running a targeted component", () => {
123
+ test("refuses before any step runs when one verb can't narrow", async () => {
124
+ const seen: DeployContext[] = [];
125
+ const ctx = deployContext({ env: "dev", targets: { net: ["a"] } }, "net");
126
+ const result = await runComponentDeploy(component("net", "narrow", "whole"), ctx, registry(seen), {}, undefined, { port: memoryGateLedgerPort() });
127
+ expect(result.status).toBe("fail");
128
+ expect(result.records[0]).toMatchObject({ phase: "Targets", kind: "whole", status: "fail" });
129
+ expect(seen).toEqual([]);
130
+ });
131
+
132
+ test("hands the targets to a verb that accepts them", async () => {
133
+ const seen: DeployContext[] = [];
134
+ const ctx = deployContext({ env: "dev", targets: { net: ["a"] } }, "net");
135
+ const result = await runComponentDeploy(component("net", "narrow"), ctx, registry(seen), {}, undefined, { port: memoryGateLedgerPort() });
136
+ expect(result.status).toBe("ok");
137
+ expect(seen[0]!.targets).toEqual(["a"]);
138
+ });
139
+ });
140
+
141
+ describe("a gate over the whole set", () => {
142
+ test("binds the targets, so the untargeted approval does not cover a targeted run", async () => {
143
+ expect(fanOutGateDigest("sha256:set", undefined)).toBe("sha256:set");
144
+ expect(fanOutGateDigest("sha256:set", { net: [] })).toBe("sha256:set");
145
+ const narrowed = fanOutGateDigest("sha256:set", { net: ["a"] });
146
+ expect(narrowed).not.toBe("sha256:set");
147
+ expect(fanOutGateDigest("sha256:set", { net: ["b"] })).not.toBe(narrowed);
148
+
149
+ const estate = [component("net", "narrow")];
150
+ const plan = planFanOut({ components: estate, changed: ["net"] });
151
+ const gates = memoryGateLedgerPort();
152
+ const result = await runFanOut(plan, estate, registry(), {
153
+ env: "dev",
154
+ gates,
155
+ now: "2026-01-01T00:00:00Z",
156
+ gate: { op: "fan-out", gate: "set" },
157
+ targets: { net: ["a"] },
158
+ });
159
+ expect(result.status).toBe("gated");
160
+ expect(result.gate?.planDigest).toBe(fanOutGateDigest(plan.digest, { net: ["a"] }));
161
+ });
162
+ });
@@ -72,6 +72,14 @@ export interface DeployContext {
72
72
  * against missing values.
73
73
  */
74
74
  unresolvedInputs?: string[];
75
+ /**
76
+ * The addresses this component's steps narrow to (#4300), from
77
+ * `--target <component>:<address>`, sorted. A step's `plan` plans only
78
+ * these, and its `run` applies only these. Every capability step of the
79
+ * component must accept targets ({@link Capability.targets}), or the
80
+ * component refuses before anything plans or runs. Absent: the whole step.
81
+ */
82
+ targets?: string[];
75
83
  }
76
84
 
77
85
  /**
@@ -101,6 +109,14 @@ export interface CapabilityPlan {
101
109
  * applies.
102
110
  */
103
111
  outputs?: PlannedOutputs;
112
+ /**
113
+ * The targets this plan narrowed to (#4300), sorted. A plan made with
114
+ * `DeployContext.targets` gives them back, which says it planned only
115
+ * those. Planning binds them into the member's digest
116
+ * ({@link targetedPlanDigest} in ./wave-plan.ts), so an approval of a
117
+ * targeted plan covers that plan and no other.
118
+ */
119
+ targets?: string[];
104
120
  }
105
121
 
106
122
  /**
@@ -176,11 +192,44 @@ export interface Capability<In = unknown, Out = unknown> {
176
192
  * compensation gap. See ../lint/rules/comp/comp003-mutating-no-rollback.ts.
177
193
  */
178
194
  readonly rollbackPolicy?: RollbackPolicy;
195
+ /**
196
+ * Whether this verb narrows to `DeployContext.targets` (#4300). `"accept"`:
197
+ * its `plan` and `run` act on the targets alone, and its plan gives them
198
+ * back in {@link CapabilityPlan.targets}. `{ refuse }`: it can't, and says
199
+ * why. Unset refuses too, with a reason that names the verb.
200
+ */
201
+ readonly targets?: TargetSupport;
179
202
  }
180
203
 
181
204
  /** A capability's relationship to rollback — see `Capability.rollbackPolicy`. */
182
205
  export type RollbackPolicy = "native" | "none-by-design" | "needs-opt-out";
183
206
 
207
+ /** Whether a capability accepts `DeployContext.targets` (#4300). See `Capability.targets`. */
208
+ export type TargetSupport = "accept" | { refuse: string };
209
+
210
+ /**
211
+ * A component was asked to narrow to targets (#4300) and one of its steps'
212
+ * capabilities refuses them. Nothing in the component plans or runs.
213
+ */
214
+ export class TargetsRefusedError extends Error {
215
+ constructor(
216
+ readonly component: string,
217
+ readonly kind: string,
218
+ readonly reason: string,
219
+ ) {
220
+ super(`component "${component}": ${kind} refuses targets: ${reason}`);
221
+ this.name = "TargetsRefusedError";
222
+ }
223
+ }
224
+
225
+ /** Why `capability` refuses targets, or `undefined` when it accepts them (#4300). */
226
+ export function targetRefusal(capability: Pick<Capability<never, unknown>, "kind" | "targets">): string | undefined {
227
+ const t = capability.targets;
228
+ if (t === "accept") return undefined;
229
+ if (t && typeof t === "object" && typeof t.refuse === "string") return t.refuse;
230
+ return `${capability.kind} does not say it can narrow to targets`;
231
+ }
232
+
184
233
  /** Extract a capability's `In` type. */
185
234
  export type CapabilityInput<C> = C extends Capability<infer In, unknown> ? In : never;
186
235
  /** Extract a capability's `Out` type. */
@@ -257,3 +306,25 @@ export class CapabilityRegistry {
257
306
  return [...this.capabilities.keys()].sort();
258
307
  }
259
308
  }
309
+
310
+ /**
311
+ * `--target <component>:<address>` values (#4300), grouped by component with
312
+ * each component's addresses sorted and unique. The address is everything
313
+ * after the first `:`, so it may hold colons of its own. Throws for a value
314
+ * with no component or no address, and, given `known`, for a component not
315
+ * among them.
316
+ */
317
+ export function parseComponentTargets(values: readonly string[], known?: ReadonlySet<string>): Record<string, string[]> {
318
+ const out: Record<string, Set<string>> = {};
319
+ for (const value of values) {
320
+ const at = value.indexOf(":");
321
+ const component = at > 0 ? value.slice(0, at).trim() : "";
322
+ const address = at > 0 ? value.slice(at + 1).trim() : "";
323
+ if (component === "" || address === "") throw new Error(`--target needs a component and an address: --target <component>:<address>, got "${value}"`);
324
+ if (known && !known.has(component)) {
325
+ throw new Error(`--target ${value}: no component is named ${component} (components: ${[...known].sort().join(", ") || "none"})`);
326
+ }
327
+ (out[component] ??= new Set()).add(address);
328
+ }
329
+ return Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)).map(([c, s]) => [c, [...s].sort()]));
330
+ }
@@ -390,6 +390,12 @@ export function findComponentGate(component: DriverComponent): { gate: string }
390
390
  export interface RunComponentsOptions {
391
391
  /** Target environment name, threaded into every capability's `DeployContext.env` (default: "local"). */
392
392
  env?: string;
393
+ /**
394
+ * The addresses each component's steps narrow to (#4300), by component
395
+ * (`--target <component>:<address>`). A name the run does not run refuses
396
+ * the run before anything starts.
397
+ */
398
+ targets?: Readonly<Record<string, readonly string[]>>;
393
399
  /**
394
400
  * chant #1051 — opt-in: discover `*.component.ts` files in a sandboxed
395
401
  * child process (`chant run --components <name|all> --sandbox`) instead of
@@ -664,6 +670,12 @@ export async function runComponents(
664
670
  return { success: false, selected: [], error: resolved.error };
665
671
  }
666
672
  const targets = resolved.targets;
673
+ const narrowed = options.targets ?? {};
674
+ const outside = Object.keys(narrowed).filter((name) => !targets.some((c) => c.name === name));
675
+ if (outside.length > 0) {
676
+ return { success: false, selected: [], error: `--target names ${outside.join(", ")}, which this run does not run` };
677
+ }
678
+ const targeted = Object.keys(narrowed).length > 0 ? { targets: narrowed } : {};
667
679
 
668
680
  // (#629) Resolve `chant.config.ts`'s `sbom`/`signing`/`vulnPolicy` sections
669
681
  // and fill their defaults into every recognized step (`generate-sbom`,
@@ -712,6 +724,7 @@ export async function runComponents(
712
724
  const run = await runInterpretDriver(resolvedTargets, registry, {
713
725
  env,
714
726
  releaseIdentity,
727
+ ...targeted,
715
728
  componentOutputs: seedOutputs,
716
729
  ...(emit ? { onRunEvent: emit } : {}),
717
730
  ...(gates ? { gates } : {}),
@@ -755,7 +768,7 @@ export async function runComponents(
755
768
  emit?.({ type: "component-start", wave: 1, component: componentName });
756
769
  const componentResult = await runComponentDeploy(
757
770
  resolvedTargets[0],
758
- deployContext({ env, releaseIdentity }, componentName),
771
+ deployContext({ env, releaseIdentity, ...targeted }, componentName),
759
772
  registry,
760
773
  componentOutputs,
761
774
  undefined,
@@ -55,7 +55,7 @@ import { componentPlanDigest } from "./gate-plan";
55
55
  import { componentDeployInputs } from "./deploy-inputs";
56
56
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
57
57
  import type { CapabilityRegistry, DeployContext } from "./capability";
58
- import { isPlanMovedError, PLAN_MOVED_EXIT_CODE } from "./capability";
58
+ import { isPlanMovedError, PLAN_MOVED_EXIT_CODE, TargetsRefusedError, targetRefusal } from "./capability";
59
59
  import { isPromotedArtifact } from "./auto-release";
60
60
  import type { ReleaseIdentity } from "../telemetry-attribution";
61
61
  import type { RunProgressEvent } from "./run-progress";
@@ -889,6 +889,18 @@ export async function runComponentDeploy(
889
889
  onRunEvent?: RunEventSink,
890
890
  ): Promise<DriverComponentResult> {
891
891
  const emit = joinRunEventSinks(onRunEvent, progressSinkOf(onProgress));
892
+ // #4300: a component narrowed to targets runs only when every step can narrow.
893
+ try {
894
+ assertTargetsAccepted(component, registry, ctx);
895
+ } catch (err) {
896
+ if (!(err instanceof TargetsRefusedError)) throw err;
897
+ return {
898
+ component: component.name,
899
+ ok: false,
900
+ status: "fail",
901
+ records: [{ component: component.name, phase: "Targets", kind: err.kind, status: "fail", durationMs: 0, error: err.message }],
902
+ };
903
+ }
892
904
  // Each component tracks its own release: a digest one component promoted never stamps another's deploy.
893
905
  if (ctx.release) ctx = { ...ctx, release: { ...ctx.release } };
894
906
  const phaseOutputs: Record<string, Record<string, unknown>> = {};
@@ -908,6 +920,7 @@ export async function runComponentDeploy(
908
920
  component,
909
921
  ...(ctx.vars ? { vars: ctx.vars } : {}),
910
922
  ...(release !== undefined ? { release } : {}),
923
+ ...(ctx.targets ? { targets: ctx.targets } : {}),
911
924
  // #4155: the files the steps deploy from, read where the steps read
912
925
  // them. Only a gate reads the digest, so a run with none skips the hashing.
913
926
  ...(hasGateStep(component) ? { inputs: componentDeployInputs(component.deploy, process.cwd()).inputs } : {}),
@@ -1121,6 +1134,8 @@ export interface InterpretRunOptions {
1121
1134
  * step promotes an artifact. Absent: no deploy step sees a release.
1122
1135
  */
1123
1136
  releaseIdentity?: (component: string) => ReleaseIdentity | undefined;
1137
+ /** The addresses each component narrows to (#4300), by component: its `DeployContext.targets`. */
1138
+ targets?: Readonly<Record<string, readonly string[]>>;
1124
1139
  }
1125
1140
 
1126
1141
  /**
@@ -1144,11 +1159,47 @@ export interface InterpretRunOptions {
1144
1159
  */
1145
1160
  /** A component's `DeployContext` for a run: its env, vars and, when the caller knows it, its release. */
1146
1161
  export function deployContext(
1147
- options: { env: string; vars?: Record<string, unknown>; releaseIdentity?: (component: string) => ReleaseIdentity | undefined },
1162
+ options: {
1163
+ env: string;
1164
+ vars?: Record<string, unknown>;
1165
+ releaseIdentity?: (component: string) => ReleaseIdentity | undefined;
1166
+ targets?: Readonly<Record<string, readonly string[]>>;
1167
+ },
1148
1168
  component: string,
1149
1169
  ): DeployContext {
1150
1170
  const release = options.releaseIdentity?.(component);
1151
- return { env: options.env, component, ...(options.vars ? { vars: options.vars } : {}), ...(release ? { release: { ...release } } : {}) };
1171
+ const targets = options.targets?.[component];
1172
+ return {
1173
+ env: options.env,
1174
+ component,
1175
+ ...(options.vars ? { vars: options.vars } : {}),
1176
+ ...(release ? { release: { ...release } } : {}),
1177
+ ...(targets && targets.length > 0 ? { targets: [...new Set(targets)].sort() } : {}),
1178
+ };
1179
+ }
1180
+
1181
+ /**
1182
+ * Refuse a component asked to narrow to `ctx.targets` (#4300) when one of its
1183
+ * capability steps can't: throws {@link TargetsRefusedError} naming the first
1184
+ * such verb, in composition order. A step whose kind is not registered
1185
+ * refuses too. Does nothing when `ctx.targets` is absent.
1186
+ */
1187
+ export function assertTargetsAccepted(component: DriverComponent, registry: CapabilityRegistry, ctx: DeployContext): void {
1188
+ if (!ctx.targets || ctx.targets.length === 0) return;
1189
+ const walk = (steps: DriverPhase["steps"]): void => {
1190
+ for (const step of steps) {
1191
+ if (typeof (step as DriverPhase).phase === "string" && Array.isArray((step as DriverPhase).steps)) {
1192
+ walk((step as DriverPhase).steps);
1193
+ continue;
1194
+ }
1195
+ const kind = (step as { kind?: unknown }).kind;
1196
+ if (kind === "gate" || typeof kind !== "string") continue;
1197
+ if (!registry.has(kind)) throw new TargetsRefusedError(component.name, kind, `no capability is registered for ${kind}`);
1198
+ const reason = targetRefusal(registry.resolve(kind));
1199
+ if (reason !== undefined) throw new TargetsRefusedError(component.name, kind, reason);
1200
+ }
1201
+ };
1202
+ for (const phase of component.deploy) walk(phase.steps);
1152
1203
  }
1153
1204
 
1154
1205
  export async function runInterpretDriver(
@@ -54,6 +54,7 @@ import type { CapabilityRegistry } from "./capability";
54
54
  import type { PendingGateRecord } from "../lifecycle/gate-ledger";
55
55
  import type { DeployContext } from "./capability";
56
56
  import { planWaveComponent, type WaveComponentPlan } from "./wave-plan";
57
+ import { computePlanDigest } from "../lifecycle/plan-digest";
57
58
  import { decideWaveGate, runWaveLoop, waveGateName, waveSetDigest, type WaveMember, type WaveRecord } from "../wave-engine";
58
59
 
59
60
  /** The sink a fan-out emits to: run events, and the older stream through them. */
@@ -187,6 +188,8 @@ export interface FanOutGate {
187
188
  export interface FanOutRunOptions {
188
189
  env: string;
189
190
  vars?: Record<string, unknown>;
191
+ /** The addresses each component narrows to (#4300), by component: its steps' `DeployContext.targets`. */
192
+ targets?: Readonly<Record<string, readonly string[]>>;
190
193
  /** Outputs of components this run is not running: the ones the plan lists in `seeds`. */
191
194
  componentOutputs?: Record<string, Record<string, unknown>>;
192
195
  onProgress?: (event: RunProgressEvent) => void;
@@ -319,7 +322,7 @@ export async function runFanOut(
319
322
  evaluateGate(gates.port, {
320
323
  op: gate.op,
321
324
  gate: gate.gate,
322
- planDigest: active.digest,
325
+ planDigest: fanOutGateDigest(active.digest, options.targets),
323
326
  ...(gate.description ? { description: gate.description } : {}),
324
327
  ...(gate.timeout ? { timeout: gate.timeout } : {}),
325
328
  ...(options.now ? { now: options.now } : {}),
@@ -455,6 +458,21 @@ export async function runFanOut(
455
458
  };
456
459
  }
457
460
 
461
+ /**
462
+ * The digest a gate over the whole set binds: the derivation's digest, and,
463
+ * when the run narrows components to targets (#4300), the digest over it and
464
+ * every component's sorted targets. An approval of the whole set then covers
465
+ * no narrowed run, and one narrowed run's approval covers no other.
466
+ */
467
+ export function fanOutGateDigest(digest: string, targets: FanOutRunOptions["targets"]): string {
468
+ const narrowed = Object.entries(targets ?? {})
469
+ .filter(([, t]) => t.length > 0)
470
+ .map(([component, t]) => [component, [...new Set(t)].sort()] as const)
471
+ .sort(([a], [b]) => a.localeCompare(b));
472
+ if (narrowed.length === 0) return digest;
473
+ return computePlanDigest("fan-out-targets", { plan: digest, targets: Object.fromEntries(narrowed) });
474
+ }
475
+
458
476
  /** The run's `deployContext`, with the carried state and the carry callback (#3459) when the caller gave them. */
459
477
  function fanOutContext(options: FanOutRunOptions, component: string): DeployContext {
460
478
  return {
@@ -45,6 +45,8 @@ export interface ComponentGatePlan {
45
45
  release?: string;
46
46
  /** What the steps deploy from (`componentDeployInputs`), when the caller read it. */
47
47
  inputs?: readonly DeployInputDigest[];
48
+ /** The addresses the component narrows to (#4300), sorted. A gate's approval then covers that narrowing alone. */
49
+ targets?: readonly string[];
48
50
  }
49
51
 
50
52
  /** The plan digest a component gate records and approves. */
@@ -58,5 +60,6 @@ export function componentPlanDigest(plan: ComponentGatePlan): string {
58
60
  vars: plan.vars ?? null,
59
61
  release: plan.release ?? null,
60
62
  ...(plan.inputs && plan.inputs.length > 0 ? { inputs: plan.inputs } : {}),
63
+ ...(plan.targets && plan.targets.length > 0 ? { targets: [...plan.targets].sort() } : {}),
61
64
  });
62
65
  }
@@ -148,6 +148,16 @@ export interface PrLoopPipelineOptions {
148
148
  * false, so a merge deploys nothing. `waveGate` needs it.
149
149
  */
150
150
  applyOnMerge?: boolean;
151
+ /**
152
+ * Apply an open pull request before it merges (#4301, ws-106): the apply
153
+ * job runs `pr-apply --before-merge` at the pull request's head, started
154
+ * by a `/chant apply` comment on GitHub and Forgejo, or on GitLab by a
155
+ * pipeline of the target branch with `CHANT_APPLY_PR` set. A merge still
156
+ * records. `pr-apply` refuses unless the declaration at base sets
157
+ * `ci.workflow: "apply-before-merge"`. It takes neither `applyOnMerge`,
158
+ * `waveGate` nor `build`.
159
+ */
160
+ applyBeforeMerge?: boolean;
151
161
  /**
152
162
  * Stream each job's run events (#4007): `--events-json` on both commands,
153
163
  * and `CHANT_RUN_EVENTS_DIR` set to {@link PR_LOOP_EVENTS_DIR}. Default false.
@@ -259,6 +269,10 @@ export function prLoopBuildLines(env: string, options: PrLoopPipelineOptions = {
259
269
  */
260
270
  export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelineOptions = {}, member?: string): PrLoopJob[] {
261
271
  const gate = options.gate ?? PR_APPLY_GATE;
272
+ const early = options.applyBeforeMerge === true;
273
+ if (early && options.applyOnMerge) throw new Error("--apply-before-merge applies an open pull request and a merge records, so it takes no --apply-on-merge; pass one of them");
274
+ if (early && options.waveGate) throw new Error("--apply-before-merge applies the pull request's plan in one job, so it takes no --wave-gate");
275
+ if (early && (options.build || options.buildCommand)) throw new Error("--apply-before-merge runs no build before its requires checks, so it takes no --pr-build or --pr-build-command yet");
262
276
  const common = [
263
277
  "--env", env, "--gate", gate, "--output", PR_LOOP_REPORT_DIR, "--forge", forge,
264
278
  ...(member ? ["--member", member] : []),
@@ -275,7 +289,8 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
275
289
  // #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
276
290
  const ciRun = (job: PrLoopJob["jobName"]): string[] => [
277
291
  "chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
278
- ...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
292
+ ...(job === "apply" && !early ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" || (job === "apply" && early) ? ["--pr", '"$PR_NUMBER"'] : []),
293
+ ...(job === "apply" && early ? ["--apply-before-merge"] : []),
279
294
  "--env", env, "--gate", gate, "--forge", forge,
280
295
  ...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
281
296
  ...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
@@ -292,6 +307,37 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
292
307
  : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...withoutForgeTokens(jobEnv) };
293
308
  const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
294
309
  const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
310
+ // #4301: the apply before merge runs in a job of the target branch, whose commit is the base, at the pull request's head.
311
+ const earlyEnv = github
312
+ ? { BASE_SHA: "${{ github.sha }}", PR_NUMBER: "${{ github.event.issue.number }}", ...jobEnv }
313
+ : { BASE_SHA: "$CI_COMMIT_SHA", PR_NUMBER: `$${GITLAB_APPLY_PR_VAR}`, ...jobEnv };
314
+ const greenApply: PrLoopJob = {
315
+ // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
316
+ jobName: "apply",
317
+ command: [
318
+ "chant", "components", "pr-apply", "--green", ...common,
319
+ ...(options.requireReview === true ? ["--require-review"] : []),
320
+ ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
321
+ "--resume", PR_APPLY_RECORD,
322
+ ].join(" "),
323
+ env: jobEnv,
324
+ checkout: [greenCheckout(greenBranch)],
325
+ greenBranch,
326
+ ...build,
327
+ run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
328
+ };
329
+ const earlyApply: PrLoopJob = {
330
+ // #4301: the requires checks run inside pr-apply, before any of the head's code loads.
331
+ jobName: "apply",
332
+ command: [
333
+ "chant", "components", "pr-apply", "--before-merge", "--base", '"$BASE_SHA"', "--pr", '"$PR_NUMBER"', ...common,
334
+ ...(options.requireReview === true ? ["--require-review"] : []),
335
+ "--resume", PR_APPLY_RECORD,
336
+ ].join(" "),
337
+ env: earlyEnv,
338
+ checkout: [pullRequestCheckout(forge)],
339
+ run: { command: ciRun("apply").join(" "), env: runEnv(earlyEnv) },
340
+ };
295
341
  return [
296
342
  {
297
343
  jobName: "plan",
@@ -301,21 +347,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
301
347
  ...build,
302
348
  run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
303
349
  },
304
- {
305
- // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
306
- jobName: "apply",
307
- command: [
308
- "chant", "components", "pr-apply", "--green", ...common,
309
- ...(options.requireReview === true ? ["--require-review"] : []),
310
- ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
311
- "--resume", PR_APPLY_RECORD,
312
- ].join(" "),
313
- env: jobEnv,
314
- checkout: [greenCheckout(greenBranch)],
315
- greenBranch,
316
- ...build,
317
- run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
318
- },
350
+ early ? earlyApply : greenApply,
319
351
  {
320
352
  jobName: "record",
321
353
  command: ["chant", "components", "pr-record", "--base", '"$BASE_SHA"', ...common].join(" "),
@@ -460,6 +492,22 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
460
492
  );
461
493
  }
462
494
 
495
+ /** The GitLab variable a person sets on a pipeline of the target branch to apply a merge request before it merges (#4301). */
496
+ export const GITLAB_APPLY_PR_VAR = "CHANT_APPLY_PR";
497
+
498
+ /** The comment that starts the apply before merge on GitHub and Forgejo (#4301). */
499
+ export const APPLY_COMMENT = "/chant apply";
500
+
501
+ /**
502
+ * The line that checks out pull request `$PR_NUMBER`'s head (#4301) in a job
503
+ * of the target branch: the forge's `refs/pull/<n>/head`, or GitLab's
504
+ * `refs/merge-requests/<n>/head`. A number that is not one fails the job.
505
+ */
506
+ export function pullRequestCheckout(forge: ForgeKind): string {
507
+ const ref = forge === "gitlab" ? 'refs/merge-requests/$PR_NUMBER/head' : 'refs/pull/$PR_NUMBER/head';
508
+ return `echo "$PR_NUMBER" | grep -Eq '^[0-9]+$' && git fetch --quiet origin "+${ref}:refs/remotes/origin/chant-apply/$PR_NUMBER" && git checkout --quiet --detach "refs/remotes/origin/chant-apply/$PR_NUMBER"`;
509
+ }
510
+
463
511
  /**
464
512
  * The line that checks out the newest green commit of `branch` (#4168 step
465
513
  * 9), after fetching the branch and its tags, for the apply job. `chant ci
@@ -110,6 +110,8 @@ export interface PrPlanSetOptions {
110
110
  vars?: Record<string, unknown>;
111
111
  /** Outputs supplied by the caller (`--seed-outputs`). They win over outputs read here. */
112
112
  seededOutputs?: Record<string, Record<string, unknown>>;
113
+ /** The addresses each component narrows to (#4300), by component: its steps' `DeployContext.targets`. */
114
+ targets?: Readonly<Record<string, readonly string[]>>;
113
115
  /**
114
116
  * Where the planning's events go (#4041): a `plan` phase with no
115
117
  * component, and in it one `plan` step per component, started as its wave
@@ -384,6 +386,8 @@ export interface PrApplyOptions {
384
386
  vars?: Record<string, unknown>;
385
387
  /** The set {@link planPrSet} planned and the gate approved, or {@link resumePrSet}'s set. */
386
388
  set: PrPlanSet;
389
+ /** The addresses each component narrows to (#4300), by component: its steps' `DeployContext.targets`. */
390
+ targets?: Readonly<Record<string, readonly string[]>>;
387
391
  onProgress?: (event: RunProgressEvent) => void;
388
392
  /** Where the apply's events go (#4041): the fan-out's run events. */
389
393
  onRunEvent?: RunEventSink;
@@ -467,6 +471,7 @@ export async function applyPrSet(options: PrApplyOptions): Promise<PrApplyResult
467
471
  env: options.env,
468
472
  ...(options.vars ? { vars: options.vars } : {}),
469
473
  plans,
474
+ ...(options.targets ? { targets: options.targets } : {}),
470
475
  componentOutputs: outputs,
471
476
  hold: (name, now) => heldReason(byName.get(name)!, read, now, inChange),
472
477
  ...(options.onProgress ? { onProgress: options.onProgress } : {}),
@@ -491,6 +496,7 @@ export async function applyPrSet(options: PrApplyOptions): Promise<PrApplyResult
491
496
  env: options.env,
492
497
  ...(options.vars ? { vars: options.vars } : {}),
493
498
  seededOutputs: run.componentOutputs,
499
+ ...(options.targets ? { targets: options.targets } : {}),
494
500
  });
495
501
  for (const name of plan.order) roundOf.set(name, { round, set });
496
502
  const record = (status: PrRound["status"], approval: PrApproval, message?: string): void => {
@@ -626,6 +632,8 @@ export interface PrResumeOptions {
626
632
  env: string;
627
633
  vars?: Record<string, unknown>;
628
634
  seededOutputs?: Record<string, Record<string, unknown>>;
635
+ /** The addresses each component narrows to (#4300), by component: its steps' `DeployContext.targets`. */
636
+ targets?: Readonly<Record<string, readonly string[]>>;
629
637
  /** The approved set, from the attempt record. */
630
638
  record: PrApplyRecord;
631
639
  /** Components an earlier attempt applied. */
@@ -677,6 +685,7 @@ export async function resumePrSet(options: PrResumeOptions): Promise<PrResume> {
677
685
  env: options.env,
678
686
  ...(options.vars ? { vars: options.vars } : {}),
679
687
  seededOutputs: { ...(options.seededOutputs ?? {}), ...options.record.planOutputs },
688
+ ...(options.targets ? { targets: options.targets } : {}),
680
689
  });
681
690
  const reasons = fresh.doc.members
682
691
  .map((m) => planCoveredBy(options.record.changeSet, fresh.doc, m.member))