@intentius/chant 0.70.1 → 0.71.0

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 (83) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +36 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/fold/subset.d.ts +22 -0
  26. package/dist/fold/subset.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/lifecycle/affected.d.ts +26 -0
  30. package/dist/lifecycle/affected.d.ts.map +1 -1
  31. package/dist/op/activities/activity-contracts.d.ts +16 -1
  32. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +1 -1
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/shell.d.ts +31 -2
  36. package/dist/op/activities/shell.d.ts.map +1 -1
  37. package/dist/op/activity-contract.d.ts +1 -1
  38. package/dist/op/activity-contract.d.ts.map +1 -1
  39. package/dist/op/activity-profiles.d.ts +19 -0
  40. package/dist/op/activity-profiles.d.ts.map +1 -1
  41. package/dist/op/builders.d.ts +21 -3
  42. package/dist/op/builders.d.ts.map +1 -1
  43. package/dist/op/gate-name.d.ts +10 -0
  44. package/dist/op/gate-name.d.ts.map +1 -1
  45. package/dist/op/step-output-ref.d.ts +25 -8
  46. package/dist/op/step-output-ref.d.ts.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/handlers/fan-out.test.ts +394 -0
  49. package/src/cli/handlers/fan-out.ts +336 -0
  50. package/src/cli/handlers/lifecycle.test.ts +74 -1
  51. package/src/cli/handlers/lifecycle.ts +29 -1
  52. package/src/cli/handlers/operator.test.ts +22 -0
  53. package/src/cli/handlers/operator.ts +11 -3
  54. package/src/cli/handlers/run.ts +7 -1
  55. package/src/cli/main.ts +25 -3
  56. package/src/cli/registry.ts +23 -0
  57. package/src/components/deploy-units.ts +14 -4
  58. package/src/components/fan-out-output.test.ts +216 -0
  59. package/src/components/fan-out-output.ts +162 -0
  60. package/src/components/fan-out-run.test.ts +194 -0
  61. package/src/components/fan-out-run.ts +221 -0
  62. package/src/components/fan-out-support.test.ts +125 -0
  63. package/src/components/fan-out-support.ts +95 -0
  64. package/src/components/fan-out.test.ts +284 -0
  65. package/src/components/fan-out.ts +421 -0
  66. package/src/components/index.ts +33 -0
  67. package/src/discovery/fold-import.ts +81 -3
  68. package/src/fold/subset-public-export.test.ts +31 -0
  69. package/src/fold/subset.ts +23 -0
  70. package/src/index.ts +6 -0
  71. package/src/lifecycle/affected.test.ts +118 -0
  72. package/src/lifecycle/affected.ts +118 -14
  73. package/src/op/activities/activity-contracts.ts +17 -1
  74. package/src/op/activities/index.ts +1 -1
  75. package/src/op/activities/shell.test.ts +156 -0
  76. package/src/op/activities/shell.ts +84 -9
  77. package/src/op/activity-profiles.test.ts +16 -2
  78. package/src/op/activity-profiles.ts +18 -0
  79. package/src/op/builders.ts +22 -4
  80. package/src/op/gate-name.ts +11 -0
  81. package/src/op/op-ir.test.ts +4 -1
  82. package/src/op/op.test.ts +7 -2
  83. package/src/op/step-output-ref.ts +29 -8
@@ -0,0 +1,336 @@
1
+ /**
2
+ * `chant components fan-out` (#2420) — run a change out across the components
3
+ * downstream of it, in an order chant derives from the source.
4
+ *
5
+ * #2417 built the four pieces this command joins (`componentsForUnits`,
6
+ * `planFanOut`, `remainingFanOut`, `runFanOut`, all in ../../components/) and
7
+ * left them reachable only from their own tests. `chant lifecycle affected`
8
+ * produces the stack-level change signal at one end; `runFanOut` consumes a
9
+ * plan at the other; this is the line between them.
10
+ *
11
+ * ## Why a sibling command rather than a third `--components` selector
12
+ *
13
+ * `chant run --components all` stops the whole run at the first failed
14
+ * component, and it should: there the user asked for everything, so a failure
15
+ * means the estate is in a state nobody described. A fan-out deliberately does
16
+ * the opposite, skipping the failure's own subtree and letting independent
17
+ * branches finish (#2419 added a second runner for exactly that reason). Two
18
+ * opposite failure semantics behind one flag's third value would be a trap. The
19
+ * flags differ too: a fan-out is parameterised by a change signal, which
20
+ * `chant run` has no business growing a `--base` for.
21
+ *
22
+ * ## The loop
23
+ *
24
+ * ```
25
+ * chant components fan-out --base main --env prod --dry-run
26
+ * chant components fan-out --base main --env prod --gate release --resume .chant/fan-out.json
27
+ * chant approve fan-out release --plan sha256:...
28
+ * chant components fan-out --base main --env prod --gate release --resume .chant/fan-out.json
29
+ * ```
30
+ *
31
+ * The last two lines are the same command twice, which is the point: an attempt
32
+ * that stopped is finished by repeating it, not by hand-picking what is left.
33
+ * `--resume` carries the earlier attempt's progress, `remainingFanOut` narrows
34
+ * the plan before the gate is decided, and the digest is carried rather than
35
+ * recomputed, so the approval still stands.
36
+ */
37
+
38
+ import { resolve, dirname } from "node:path";
39
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
40
+ import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
41
+ import { affectedStacks } from "../../lifecycle/affected";
42
+ import { deriveFanOut, fanOutRegistry } from "../../components/fan-out-support";
43
+ import { runFanOut } from "../../components/fan-out-run";
44
+ import { renderFanOutHuman, renderFanOutJson, renderFanOutPlan } from "../../components/fan-out-output";
45
+ import { ndjsonProgressSink } from "../../components/run-progress";
46
+ import { writeGatedRunSummary } from "../../op/gate-summary";
47
+ import { remainingFanOut, type ChangedUnits, type FanOutProgress } from "../../components/fan-out";
48
+ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
49
+ import { formatError, formatInfo, formatWarning } from "../format";
50
+ import { FAN_OUT_GATE_OP } from "../../op/gate-name";
51
+ import { resolveBuildRoot } from "./lifecycle";
52
+ import { GATED_EXIT_CODE, recordAutoReleasesForRun } from "./run";
53
+ import type { CommandContext } from "../registry";
54
+
55
+
56
+
57
+ /**
58
+ * What an attempt left behind, and what the next one reads (`--resume`).
59
+ *
60
+ * The digest is stored alongside the progress because progress is only
61
+ * meaningful for the plan it was made against. A fan-out derived from different
62
+ * source is a different fan-out, and carrying "cluster-a already applied" into
63
+ * it would be a claim about work nobody did.
64
+ */
65
+ interface FanOutAttempt {
66
+ digest: string;
67
+ completed: string[];
68
+ failed: string[];
69
+ }
70
+
71
+ function readAttempt(path: string): FanOutAttempt | undefined {
72
+ if (!existsSync(path)) return undefined;
73
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<FanOutAttempt>;
74
+ return {
75
+ digest: typeof parsed.digest === "string" ? parsed.digest : "",
76
+ completed: Array.isArray(parsed.completed) ? parsed.completed.filter((n): n is string => typeof n === "string") : [],
77
+ failed: Array.isArray(parsed.failed) ? parsed.failed.filter((n): n is string => typeof n === "string") : [],
78
+ };
79
+ }
80
+
81
+ function writeAttempt(path: string, attempt: FanOutAttempt): void {
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ writeFileSync(path, JSON.stringify(attempt, null, 2) + "\n");
84
+ }
85
+
86
+ /**
87
+ * The stack-level change signal, from whichever end the invocation supplied.
88
+ *
89
+ * `--base` re-derives it here, which is the one-command shape a developer
90
+ * wants. `--from-affected` reads what `chant lifecycle affected --json` already
91
+ * wrote, which is the shape a CI job wants when an earlier step has run the
92
+ * diff and there is no reason to build twice. Exactly one, because defaulting
93
+ * either way would let a stale file silently beat a fresh `--base` or the other
94
+ * way round.
95
+ *
96
+ * `dependents` is folded into `changed` when it is present. It is only present
97
+ * when somebody asked for it (`--include-dependents`), and a stack that
98
+ * consumes a changed export is affected at stack granularity in the same way a
99
+ * changed component is at component granularity.
100
+ */
101
+ async function resolveChangedUnits(
102
+ ctx: CommandContext,
103
+ config: ChantConfig,
104
+ ): Promise<{ units: ChangedUnits } | { error: string; hint?: string }> {
105
+ const { args } = ctx;
106
+ const fromFile = args.fromAffected;
107
+ if (args.base && fromFile) {
108
+ return {
109
+ error: "--base and --from-affected both name a change signal",
110
+ hint: "Pass --base <ref> to derive it here, or --from-affected <file> to read one `chant lifecycle affected --json` already wrote.",
111
+ };
112
+ }
113
+
114
+ if (fromFile) {
115
+ let parsed: { changed?: unknown; dependents?: unknown; indeterminate?: unknown };
116
+ try {
117
+ parsed = JSON.parse(readFileSync(resolve(fromFile), "utf8"));
118
+ } catch (err) {
119
+ return { error: `--from-affected: could not read "${fromFile}": ${err instanceof Error ? err.message : String(err)}` };
120
+ }
121
+ const names = (value: unknown): string[] =>
122
+ Array.isArray(value) ? value.filter((n): n is string => typeof n === "string") : [];
123
+ if (!Array.isArray(parsed.changed)) {
124
+ return {
125
+ error: `--from-affected: "${fromFile}" has no "changed" array`,
126
+ hint: "It should be the output of `chant lifecycle affected --base <ref> --json`.",
127
+ };
128
+ }
129
+ return {
130
+ units: {
131
+ changed: [...new Set([...names(parsed.changed), ...names(parsed.dependents)])].sort(),
132
+ indeterminate: names(parsed.indeterminate),
133
+ },
134
+ };
135
+ }
136
+
137
+ if (!args.base) {
138
+ return {
139
+ error: "A change signal is required: chant components fan-out --base <ref> [--head <ref>]",
140
+ hint: "Or pass --from-affected <file> with the JSON `chant lifecycle affected --base <ref> --json` wrote.",
141
+ };
142
+ }
143
+
144
+ try {
145
+ const result = await affectedStacks({
146
+ // Components are discovered from the current directory, the convention
147
+ // every component command follows. In a multi-stack project the diff
148
+ // reads the same root, because `stacks[].src` is written relative to it.
149
+ projectPath: config.stacks?.length ? resolve(".") : resolveBuildRoot(args, config),
150
+ serializers: ctx.plugins.map((p) => p.serializer),
151
+ baseRef: args.base,
152
+ headRef: args.head,
153
+ includeDependents: args.includeDependents,
154
+ // A project that declares its stacks gets an answer keyed by stack name,
155
+ // which is the name a component's deploy step uses. Without this the diff
156
+ // answers by lexicon partition and the join below claims none of it.
157
+ ...(config.stacks ? { stacks: config.stacks } : {}),
158
+ });
159
+ return {
160
+ units: {
161
+ changed: [...new Set([...result.changed, ...result.dependents])].sort(),
162
+ indeterminate: result.indeterminate,
163
+ },
164
+ };
165
+ } catch (err) {
166
+ return { error: err instanceof Error ? err.message : String(err) };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * chant components fan-out --base <ref> | --from-affected <file>
172
+ * [--head <ref>] [--include-dependents] [--env <env>] [--gate <name>]
173
+ * [--dry-run] [--json] [--resume <file>] [--seed-outputs <file>]
174
+ * [--dump-outputs <file>] [--progress-json]
175
+ */
176
+ export async function runComponentsFanOut(ctx: CommandContext): Promise<number> {
177
+ const { args } = ctx;
178
+ const projectPath = resolve(".");
179
+ const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
180
+
181
+ const paramsResolution = resolveCliBuildParams(config.buildParams, {
182
+ cli: parseParamFlags(args.param),
183
+ paramsFile: args.paramsFile,
184
+ verbose: args.verbose,
185
+ });
186
+ if (!paramsResolution.success) {
187
+ for (const message of paramsResolution.errors) console.error(message);
188
+ return 1;
189
+ }
190
+
191
+ const signal = await resolveChangedUnits(ctx, config);
192
+ if ("error" in signal) {
193
+ console.error(formatError({ message: signal.error, ...(signal.hint ? { hint: signal.hint } : {}) }));
194
+ return 1;
195
+ }
196
+
197
+ let derived;
198
+ try {
199
+ derived = await deriveFanOut({
200
+ path: projectPath,
201
+ units: signal.units,
202
+ sandbox: args.sandbox,
203
+ buildParams: paramsResolution.provenance,
204
+ config,
205
+ });
206
+ } catch (err) {
207
+ // A cycle or an unknown `dependsOn` refuses here, over the whole graph and
208
+ // before anything is selected (#2417). The message names the members.
209
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
210
+ return 1;
211
+ }
212
+ if (!derived.success) {
213
+ console.error(formatError({ message: derived.error ?? "Could not discover components" }));
214
+ return 1;
215
+ }
216
+
217
+ // A changed stack no component deploys is a hole in this fan-out's coverage.
218
+ // Reporting it is the whole reason `componentsForUnits` returns it.
219
+ if (derived.signal.unclaimed.length > 0) {
220
+ console.error(formatWarning({
221
+ message: `no component deploys these changed unit(s): ${derived.signal.unclaimed.join(", ")}`,
222
+ hint: "They are outside this fan-out. Deploy them another way, or give a component a step that names them.",
223
+ }));
224
+ }
225
+
226
+ const gate = args.gate ? { op: FAN_OUT_GATE_OP, gate: args.gate } : undefined;
227
+
228
+ // `--resume`: what an earlier attempt at this same plan finished. Read before
229
+ // the dry-run branch so a plan-only invocation shows what is actually left
230
+ // rather than what the fan-out looked like the first time. Narrowing for the
231
+ // real run happens inside `runFanOut`, before the gate is decided, so an
232
+ // approval survives the resume rather than being re-asked for.
233
+ const resumePath = args.resume ? resolve(args.resume) : undefined;
234
+ let priorCompleted: string[] = [];
235
+ let progress: FanOutProgress | undefined;
236
+ if (resumePath) {
237
+ let attempt: FanOutAttempt | undefined;
238
+ try {
239
+ attempt = readAttempt(resumePath);
240
+ } catch (err) {
241
+ console.error(formatError({ message: `--resume: could not read "${args.resume}": ${err instanceof Error ? err.message : String(err)}` }));
242
+ return 1;
243
+ }
244
+ if (attempt && attempt.digest !== derived.plan.digest) {
245
+ console.error(formatWarning({
246
+ message: `--resume: "${args.resume}" records a different fan-out (${attempt.digest || "no digest"}), so its progress does not apply here`,
247
+ hint: "The derivation changed, which makes this a different change. Everything selected will run.",
248
+ }));
249
+ } else if (attempt) {
250
+ // Only `completed` is carried forward. The earlier attempt's failures are
251
+ // recorded in the file for whoever has to read it, but feeding them back
252
+ // as progress would block their subtrees again on the very run that
253
+ // exists to retry them — the operator repeated the command precisely
254
+ // because the thing that failed is now expected to work.
255
+ priorCompleted = attempt.completed;
256
+ progress = { completed: attempt.completed };
257
+ }
258
+ }
259
+
260
+ if (args.dryRun) {
261
+ // `remainingFanOut` carries the digest rather than minting a new one, so
262
+ // the digest printed here is the same string that approves the real run.
263
+ const plan = progress ? remainingFanOut(derived.plan, derived.components, progress) : derived.plan;
264
+ if (args.json) renderFanOutJson(plan);
265
+ else renderFanOutPlan(plan, { ...(gate ? { gate } : {}) });
266
+ return 0;
267
+ }
268
+
269
+ const seededOutputs: Record<string, Record<string, unknown>> = {};
270
+ for (const file of args.seedOutputs ?? []) {
271
+ try {
272
+ Object.assign(seededOutputs, JSON.parse(readFileSync(resolve(file), "utf8")));
273
+ } catch (err) {
274
+ console.error(formatError({ message: `--seed-outputs: could not read "${file}": ${err instanceof Error ? err.message : String(err)}` }));
275
+ return 1;
276
+ }
277
+ }
278
+
279
+ const registry = await fanOutRegistry(projectPath, config);
280
+ const result = await runFanOut(derived.plan, derived.components, registry, {
281
+ env: args.env ?? "local",
282
+ componentOutputs: seededOutputs,
283
+ ...(gate ? { gate } : {}),
284
+ ...(progress ? { progress } : {}),
285
+ ...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
286
+ });
287
+
288
+ if (args.dumpOutputs) {
289
+ const dumpPath = resolve(args.dumpOutputs);
290
+ mkdirSync(dirname(dumpPath), { recursive: true });
291
+ writeFileSync(dumpPath, JSON.stringify(result.componentOutputs, null, 2));
292
+ }
293
+
294
+ // Written even on a failure and even on a gate: the next attempt needs to
295
+ // know what this one got through, and a gated attempt got through nothing,
296
+ // which is itself worth recording against this plan's digest.
297
+ if (resumePath) {
298
+ writeAttempt(resumePath, {
299
+ digest: result.plan.digest,
300
+ completed: [...new Set([...priorCompleted, ...result.completed])].sort(),
301
+ failed: result.failed,
302
+ });
303
+ }
304
+
305
+ if (args.json) renderFanOutJson(result);
306
+ else renderFanOutHuman(result, { ...(gate ? { gate } : {}) });
307
+
308
+ // The same durable trace `chant run --components` leaves (#597), for the
309
+ // components this fan-out actually applied. A partial fan-out records the
310
+ // branches that finished and nothing else, which is the whole reason the
311
+ // runner reports `completed` separately from `failed`.
312
+ if (result.status !== "gated") {
313
+ await recordAutoReleasesForRun(
314
+ result.results,
315
+ args.env ?? "local",
316
+ `fan-out-${Date.now()}`,
317
+ resolveAutoReleaseDisabled(config, args.noReleaseRecord),
318
+ );
319
+ }
320
+
321
+ if (result.status === "gated" && result.gate) {
322
+ const pending = result.gate;
323
+ console.error(formatInfo(`approve : chant approve ${pending.op} ${pending.gate} --plan ${result.plan.digest}`));
324
+ writeGatedRunSummary({
325
+ op: pending.op,
326
+ gate: pending.gate,
327
+ ...(pending.description ? { description: pending.description } : {}),
328
+ expiresAt: pending.expiresAt,
329
+ ...(pending.url ? { url: pending.url } : {}),
330
+ ...(pending.planDigest ? { planDigest: pending.planDigest } : {}),
331
+ });
332
+ return GATED_EXIT_CODE;
333
+ }
334
+
335
+ return result.status === "ok" ? 0 : 1;
336
+ }
@@ -29,6 +29,7 @@ const loadChantConfigMock = vi.fn();
29
29
  const pushLifecycleMock = vi.fn();
30
30
  const readBlobFromPathMock = vi.fn();
31
31
  const writeBlobToPathMock = vi.fn();
32
+ const affectedStacksMock = vi.fn();
32
33
 
33
34
  vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
34
35
  vi.mock("../../lifecycle/git", () => ({
@@ -54,7 +55,11 @@ vi.mock("../../config", async () => {
54
55
  };
55
56
  });
56
57
 
57
- const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } = await import("./lifecycle");
58
+ vi.mock("../../lifecycle/affected", () => ({
59
+ affectedStacks: (...args: unknown[]) => affectedStacksMock(...args),
60
+ }));
61
+
62
+ const { runLifecycleDiff, runLifecyclePlan, runLifecycleSnapshot, runLifecycleShow, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown, runLifecycleAffected } = await import("./lifecycle");
58
63
 
59
64
  function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
60
65
  return {
@@ -2107,3 +2112,71 @@ describe("runLifecycleWhoami (#1982)", () => {
2107
2112
  }
2108
2113
  });
2109
2114
  });
2115
+
2116
+ describe("runLifecycleAffected (#2420)", () => {
2117
+ let stdoutBuf: string[];
2118
+ let stderrBuf: string[];
2119
+
2120
+ beforeEach(() => {
2121
+ stdoutBuf = [];
2122
+ stderrBuf = [];
2123
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
2124
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
2125
+ loadChantConfigMock.mockReset();
2126
+ affectedStacksMock.mockReset();
2127
+ affectedStacksMock.mockResolvedValue({ changed: ["api-stack"], dependents: [], indeterminate: [] });
2128
+ });
2129
+
2130
+ afterEach(() => vi.restoreAllMocks());
2131
+
2132
+ const multiStack = [
2133
+ { name: "api-stack", src: "stacks/api" },
2134
+ { name: "worker-stack", src: "stacks/worker" },
2135
+ ];
2136
+
2137
+ function affectedCtx(overrides: Partial<ParsedArgs> = {}) {
2138
+ return {
2139
+ args: makeArgs({ command: "lifecycle", path: "affected", base: "main", ...overrides }),
2140
+ plugins: [], serializers: [],
2141
+ };
2142
+ }
2143
+
2144
+ test("passes config.stacks through, so the answer is keyed by stack name", async () => {
2145
+ loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
2146
+ expect(await runLifecycleAffected(affectedCtx())).toBe(0);
2147
+ expect(affectedStacksMock).toHaveBeenCalledWith(expect.objectContaining({ stacks: multiStack }));
2148
+ expect(stdoutBuf.join("\n")).toContain("api-stack");
2149
+ });
2150
+
2151
+ test("a project with no stacks declared passes none — same single-root build", async () => {
2152
+ loadChantConfigMock.mockResolvedValue({ config: {} });
2153
+ expect(await runLifecycleAffected(affectedCtx())).toBe(0);
2154
+ expect(affectedStacksMock.mock.calls[0][0].stacks).toBeUndefined();
2155
+ });
2156
+
2157
+ test("multi-stack + --include-dependents says where the downstream relation lives", async () => {
2158
+ loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
2159
+ expect(await runLifecycleAffected(affectedCtx({ includeDependents: true }))).toBe(0);
2160
+ const stderr = stderrBuf.join("\n");
2161
+ expect(stderr).toContain("dependsOn");
2162
+ expect(stderr).toContain("chant components fan-out");
2163
+ });
2164
+
2165
+ test("no note for a single-root project, even with --include-dependents", async () => {
2166
+ loadChantConfigMock.mockResolvedValue({ config: {} });
2167
+ expect(await runLifecycleAffected(affectedCtx({ includeDependents: true }))).toBe(0);
2168
+ expect(stderrBuf.join("\n")).not.toContain("chant components fan-out");
2169
+ });
2170
+
2171
+ test("no note for a multi-stack project when dependents were not asked for", async () => {
2172
+ loadChantConfigMock.mockResolvedValue({ config: { stacks: multiStack } });
2173
+ expect(await runLifecycleAffected(affectedCtx())).toBe(0);
2174
+ expect(stderrBuf.join("\n")).not.toContain("chant components fan-out");
2175
+ });
2176
+
2177
+ test("--base is required", async () => {
2178
+ loadChantConfigMock.mockResolvedValue({ config: {} });
2179
+ expect(await runLifecycleAffected(affectedCtx({ base: undefined }))).toBe(1);
2180
+ expect(affectedStacksMock).not.toHaveBeenCalled();
2181
+ });
2182
+ });
@@ -1871,6 +1871,19 @@ function printSnapshotTable(snapshot: LifecycleSnapshot): void {
1871
1871
  * Read-only: report which stacks a change affects (directly-changed via artifact
1872
1872
  * diff, dependents via the cross-stack graph, external-input as indeterminate).
1873
1873
  * Returns the set; fanning plan/apply over it is an Op the user composes.
1874
+ *
1875
+ * `config.stacks` is passed through, so on a multi-stack project the answer is
1876
+ * keyed by deployed **stack name** — the name a component's `cfn-deploy` step
1877
+ * uses — instead of by lexicon partition. That is what lets `chant components
1878
+ * fan-out` join a change set to the components that deploy it; an aws-only
1879
+ * estate of fifteen stacks used to answer "aws" no matter which one moved. A
1880
+ * project with no `stacks` declared is untouched: one build of the source root,
1881
+ * one lexicon-keyed answer.
1882
+ *
1883
+ * The cost of per-stack mode is that `dependents` comes back empty (the build
1884
+ * carries cross-lexicon edges, not stack ones), so `--include-dependents` prints
1885
+ * a note saying where the downstream relation actually lives rather than showing
1886
+ * an empty list as if it meant "nothing downstream".
1874
1887
  */
1875
1888
  export async function runLifecycleAffected(ctx: CommandContext): Promise<number> {
1876
1889
  const { args, plugins } = ctx;
@@ -1882,13 +1895,17 @@ export async function runLifecycleAffected(ctx: CommandContext): Promise<number>
1882
1895
  }
1883
1896
 
1884
1897
  const { config } = await loadChantConfig(resolve("."));
1885
- const projectPath = resolveBuildRoot(args, config);
1898
+ // A multi-stack project's `stacks[].src` entries are written relative to the
1899
+ // project root, and they are themselves the scoping, so `--src`/`sourceDir`
1900
+ // does not narrow the root a second time (see `AffectedStacksOptions.stacks`).
1901
+ const projectPath = config.stacks?.length ? resolve(".") : resolveBuildRoot(args, config);
1886
1902
 
1887
1903
  let result;
1888
1904
  try {
1889
1905
  result = await affectedStacks({
1890
1906
  projectPath,
1891
1907
  serializers: plugins.map((p) => p.serializer),
1908
+ stacks: config.stacks,
1892
1909
  baseRef: args.base,
1893
1910
  headRef: args.head,
1894
1911
  includeDependents: args.includeDependents,
@@ -1898,6 +1915,17 @@ export async function runLifecycleAffected(ctx: CommandContext): Promise<number>
1898
1915
  return 1;
1899
1916
  }
1900
1917
 
1918
+ // Per-stack mode has no stack graph to walk, so an empty `dependents` here
1919
+ // means "not computed", not "nothing downstream". Say so on stderr — silently
1920
+ // handing back an empty list is the failure this command exists to avoid.
1921
+ const multiStack = (config.stacks?.length ?? 0) > 0;
1922
+ if (multiStack && args.includeDependents) {
1923
+ console.error(formatWarning({
1924
+ message: "Dependents are not computed for a multi-stack project: the downstream relation between deployed stacks is stated by the components' dependsOn, not by the build.",
1925
+ hint: "Run chant components fan-out to walk it.",
1926
+ }));
1927
+ }
1928
+
1901
1929
  if (args.json) {
1902
1930
  console.log(JSON.stringify(result, null, 2));
1903
1931
  return 0;
@@ -513,6 +513,28 @@ describe("runApprove", () => {
513
513
  errSpy.mockRestore();
514
514
  });
515
515
 
516
+ test("the fan-out op is not reported as a missing *.op.ts, and the hint names the right command (#2420)", async () => {
517
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
518
+ seedPending("fan-out", "release", PLAN_A);
519
+ appendGateResolutionMock.mockResolvedValue({
520
+ commit: "sha",
521
+ record: { version: 1, op: "fan-out", gate: "release", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
522
+ });
523
+ const lines: string[] = [];
524
+ const errSpy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
525
+ lines.push(a.map(String).join(" "));
526
+ });
527
+
528
+ const code = await runApprove(ctx({ path: "fan-out", extraPositional: "release", actor: "alex" }));
529
+
530
+ expect(code).toBe(0);
531
+ const out = lines.join("\n");
532
+ expect(out).not.toContain("was not found among discovered");
533
+ expect(out).toContain("Repeat the `chant components fan-out` command");
534
+ expect(out).not.toContain("chant run fan-out");
535
+ errSpy.mockRestore();
536
+ });
537
+
516
538
  test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
517
539
  discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
518
540
  seedPending("unknown-op", "g", PLAN_A);
@@ -35,6 +35,7 @@ import {
35
35
  resolveApprovalUrl, isApprovalUrl,
36
36
  } from "../../lifecycle/gate-ledger";
37
37
  import { isPlanDigest } from "../../lifecycle/plan-digest";
38
+ import { FAN_OUT_GATE_OP } from "../../op/gate-name";
38
39
  import { pushLifecycle, requireLifecycleLedger } from "../../lifecycle/git";
39
40
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
40
41
  import type { CommandContext } from "../registry";
@@ -677,7 +678,9 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
677
678
  (pushed ? "" : " (local only — the push did not land)"),
678
679
  ));
679
680
  console.error(formatInfo(
680
- `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
681
+ opName === FAN_OUT_GATE_OP
682
+ ? `The next \`chant components fan-out\` decides this gate from scratch and records a fresh pending fact.`
683
+ : `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
681
684
  ));
682
685
  return 0;
683
686
  }
@@ -696,7 +699,9 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
696
699
 
697
700
  console.error(formatInfo(
698
701
  `This records the resolution as a fact; it does not itself re-run anything. ` +
699
- `Run \`chant run ${opName}\` and it walks through gate "${gate}".`,
702
+ (opName === FAN_OUT_GATE_OP
703
+ ? `Repeat the \`chant components fan-out\` command and it walks through gate "${gate}".`
704
+ : `Run \`chant run ${opName}\` and it walks through gate "${gate}".`),
700
705
  ));
701
706
  return 0;
702
707
  }
@@ -742,7 +747,10 @@ export async function recordGateApproval(
742
747
  opts: GateApprovalOptions,
743
748
  ): Promise<GateApprovalOutcome> {
744
749
  const { ops } = await discoverOps();
745
- if (!ops.has(opName)) {
750
+ // `fan-out` is the op name every `chant components fan-out` gate is recorded
751
+ // under (../handlers/fan-out.ts). It is a command rather than a declaration,
752
+ // so there is no `*.op.ts` to find and nothing is wrong when none is there.
753
+ if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP) {
746
754
  console.error(formatWarning({
747
755
  message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
748
756
  }));
@@ -570,8 +570,14 @@ export async function runOp(ctx: CommandContext): Promise<number> {
570
570
  * failure (`reason: "error"`) is surfaced, as a warning — never a nonzero
571
571
  * exit, since the deploy itself already succeeded and a ledger-write hiccup
572
572
  * must not retroactively fail it.
573
+ *
574
+ * Exported for `chant components fan-out` (#2420), which applies components
575
+ * through a different runner and would otherwise leave no trace in the release
576
+ * ledger for work `chant run --components` records. Each result is filtered on
577
+ * its own `ok`, so a fan-out that partly succeeded records exactly the
578
+ * components that did.
573
579
  */
574
- async function recordAutoReleasesForRun(
580
+ export async function recordAutoReleasesForRun(
575
581
  results: DriverComponentResult[],
576
582
  env: string,
577
583
  runId: string,
package/src/cli/main.ts CHANGED
@@ -26,6 +26,7 @@ import { runCarveApply } from "./handlers/carve-apply";
26
26
  import { runCarveStatus } from "./handlers/carve-status";
27
27
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleWhoami, runLifecycleUnknown } from "./handlers/lifecycle";
28
28
  import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
29
+ import { runComponentsFanOut } from "./handlers/fan-out";
29
30
  import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
30
31
  import { runGraph } from "./handlers/graph";
31
32
  import { runExplain } from "./handlers/explain";
@@ -301,6 +302,12 @@ export function parseArgs(args: string[]): ParsedArgs {
301
302
  result.head = args[++i];
302
303
  } else if (arg === "--include-dependents") {
303
304
  result.includeDependents = true;
305
+ } else if (arg === "--from-affected") {
306
+ result.fromAffected = args[++i];
307
+ } else if (arg === "--gate") {
308
+ result.gate = args[++i];
309
+ } else if (arg === "--resume") {
310
+ result.resume = args[++i];
304
311
  } else if (arg === "--local") {
305
312
  result.local = true;
306
313
  } else if (arg === "--json") {
@@ -621,6 +628,13 @@ Component release ledger + status:
621
628
  --json: stable machine-readable contract;
622
629
  --compare-to <env>: cross-check the same
623
630
  component's recorded digest against another env)
631
+ components fan-out Run a change out across the components downstream
632
+ of it, in an order derived from the source
633
+ (--base <ref> [--head <ref>] [--include-dependents],
634
+ or --from-affected <file>; --dry-run prints the
635
+ derivation and dispatches nothing; --gate <name>
636
+ puts one approval over the whole set; --resume
637
+ <file> finishes an attempt that stopped)
624
638
  components release <env> Append one immutable release record
625
639
  (--component <name> --digest <sha256:...>
626
640
  [--git-sha <sha>] [--run-id <id>] [--actor <name>])
@@ -736,7 +750,8 @@ Options:
736
750
  project source; network egress is NOT blocked (see
737
751
  docs). Default: off (also settable via
738
752
  chant.config.ts's build.sandbox: true; #1045)
739
- --param <name=value> (build, graph, run --components) Bind a declared
753
+ --param <name=value> (build, graph, run --components, components fan-out)
754
+ Bind a declared
740
755
  build-time parameter (chant.config.ts's buildParams)
741
756
  to a value, for source to read as params.<name>
742
757
  (#1064) instead of process.env — repeatable.
@@ -744,7 +759,8 @@ Options:
744
759
  Parameter(): this resolves before synthesis, so it
745
760
  can change which resources are produced at all.
746
761
  Highest precedence.
747
- --params-file <path> (build, graph, run --components) JSON file of
762
+ --params-file <path> (build, graph, run --components, components fan-out)
763
+ JSON file of
748
764
  { "name": value } build-time parameter values
749
765
  (#1064). Second precedence, after --param.
750
766
 
@@ -967,6 +983,7 @@ export const commandRegistry: CommandDef[] = [
967
983
  { name: "scenario check", requiresPlugins: true, handler: runScenarioCheck },
968
984
 
969
985
  // Component release ledger + status surface (#568, epic #551)
986
+ { name: "components fan-out", requiresPlugins: true, handler: runComponentsFanOut },
970
987
  { name: "components status", requiresPlugins: true, handler: runComponentsStatus },
971
988
  { name: "components release", handler: runComponentsReleaseRecord },
972
989
  { name: "components export", handler: runComponentsExport },
@@ -1102,12 +1119,17 @@ async function main(): Promise<void> {
1102
1119
  // missing plugins there is "no live evidence" (a warning), not a hard exit.
1103
1120
  const isGenerateComponents = match.def.name === "build" && args.components && !!args.generate;
1104
1121
  const isComponentsStatus = match.def.name === "components status";
1122
+ // `components fan-out` (#2420) needs serializers only when it derives the
1123
+ // change signal itself (`--base` builds both refs). Reading one somebody
1124
+ // else already produced (`--from-affected`) touches no lexicon at all, so a
1125
+ // components-only project is not turned away for a step it will not run.
1126
+ const isFanOutFromFile = match.def.name === "components fan-out" && !args.base;
1105
1127
  // `emulator` (#920) is a property of the *configured* lexicons, not of any infra
1106
1128
  // file — a fresh/local project with no declarables still boots Floci. Load from
1107
1129
  // chant.config best-effort, like components status, rather than detectLexicon.
1108
1130
  const isEmulator = match.def.name === "emulator" || match.def.name.startsWith("emulator ");
1109
1131
  const plugins = match.def.requiresPlugins
1110
- ? isGenerateComponents || isComponentsStatus || isEmulator
1132
+ ? isGenerateComponents || isComponentsStatus || isEmulator || isFanOutFromFile
1111
1133
  ? await loadPlugins(await resolveProjectLexicons(resolve(projectPath)).catch(() => [])).catch(() => [])
1112
1134
  : await loadPluginsOrExit(projectPath)
1113
1135
  : [];