@intentius/chant 0.117.0 → 0.118.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/build.d.ts +1 -1
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/discovery/files.d.ts +8 -0
  9. package/dist/discovery/files.d.ts.map +1 -1
  10. package/dist/lexicon.d.ts +10 -1
  11. package/dist/lexicon.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-ledger.d.ts +11 -0
  13. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  14. package/dist/op/activities/lifecycle.d.ts +6 -1
  15. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  16. package/dist/op/gate-review.d.ts +113 -0
  17. package/dist/op/gate-review.d.ts.map +1 -0
  18. package/dist/op/op-waves-run.d.ts +47 -1
  19. package/dist/op/op-waves-run.d.ts.map +1 -1
  20. package/dist/op/op-waves.d.ts +20 -0
  21. package/dist/op/op-waves.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/build.ts +13 -2
  24. package/src/cli/commands/build.ts +2 -1
  25. package/src/cli/handlers/run-wave.ts +13 -1
  26. package/src/cli/main.ts +7 -1
  27. package/src/cli/plugins.ts +1 -1
  28. package/src/cli/registry.ts +2 -0
  29. package/src/discovery/files.ts +18 -0
  30. package/src/lexicon.ts +10 -1
  31. package/src/lifecycle/gate-ledger.ts +7 -0
  32. package/src/op/activities/lifecycle-gate.test.ts +147 -0
  33. package/src/op/activities/lifecycle.ts +15 -6
  34. package/src/op/gate-review.test.ts +225 -0
  35. package/src/op/gate-review.ts +339 -0
  36. package/src/op/op-waves-run.ts +190 -12
  37. package/src/op/op-waves.ts +25 -0
@@ -27,6 +27,12 @@
27
27
  * the same digest a component fan-out's wave binds). Whether it waits at all
28
28
  * is the wave's gate policy, read from the base commit's copy of the spec
29
29
  * file, so a change cannot loosen the rule that gates its own merge.
30
+ *
31
+ * So is which approvals count (#3684), the wave's `approval`: `ledger` (any
32
+ * `chant approve` of the digest), `sealed` (only one whose seal verifies
33
+ * against the signers file at base) or `pr-review` (also the merged pull
34
+ * request's review of its head, by a writer other than its author, when the
35
+ * head planned the same digest). See `./gate-review.ts`.
30
36
  */
31
37
  import type { OpEnvironment, OpSetupStep } from "../lexicon.js";
32
38
  /** When a wave waits for an approval. */
@@ -38,6 +44,15 @@ export type OpWaveGatePolicy =
38
44
  /** Never: only review and branch protection stand in front of the wave. */
39
45
  | "never";
40
46
  export declare const OP_WAVE_GATE_POLICIES: readonly OpWaveGatePolicy[];
47
+ /** Which approvals of a gate's digest count (#3684). See `./gate-review.ts`. */
48
+ export type GateApprovalSource =
49
+ /** Any resolution on `chant/lifecycle` that names the digest. */
50
+ "ledger"
51
+ /** As `ledger`, and the merged pull request's review of its head, by a writer other than its author. */
52
+ | "pr-review"
53
+ /** Only a resolution sealed by a key the signers file at base lists for its approver. */
54
+ | "sealed";
55
+ export declare const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[];
41
56
  /** One Op run in a wave: an Op applied to one target (an environment, a database, a tenant). */
42
57
  export interface OpWaveRun {
43
58
  /** What the run applies to. Unique within its wave; it names the run in the set digest. */
@@ -55,6 +70,11 @@ export interface OpWave {
55
70
  * spec file at the base commit, never from the change being applied.
56
71
  */
57
72
  gate?: OpWaveGatePolicy;
73
+ /**
74
+ * Which approvals of the wave's digest count (#3684). Default `"ledger"`.
75
+ * Like `gate`, the runner reads it from the spec file at the base commit.
76
+ */
77
+ approval?: GateApprovalSource;
58
78
  /**
59
79
  * Split the wave into a deciding job and this many share jobs, each
60
80
  * applying a slice of the runs. Default 1: one job plans, decides and
@@ -1 +1 @@
1
- {"version":3,"file":"op-waves.d.ts","sourceRoot":"","sources":["../../src/op/op-waves.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"op-waves.d.ts","sourceRoot":"","sources":["../../src/op/op-waves.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.117.0",
3
+ "version": "0.118.1",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.ts CHANGED
@@ -19,6 +19,7 @@ import { discover, type DiscoveryResult, type FoldDecision } from "./discovery/i
19
19
  import { decodeEntitySet, type DiscoveredEntitiesJson } from "./discovery/entity-wire";
20
20
  import { buildDependencyGraph } from "./discovery/graph";
21
21
  import { topologicalSort } from "./sort";
22
+ import { statSync } from "node:fs";
22
23
  import { resolve } from "node:path";
23
24
 
24
25
  /**
@@ -641,6 +642,15 @@ function generateManifest(
641
642
  };
642
643
  }
643
644
 
645
+ /** The directory a build discovered from, for build-root contributors; undefined when the build's path is not one (an entity-set JSON's label). */
646
+ function sourceDirOf(path: string): string | undefined {
647
+ try {
648
+ return statSync(path).isDirectory() ? resolve(path) : undefined;
649
+ } catch {
650
+ return undefined;
651
+ }
652
+ }
653
+
644
654
  /** What merging build-root contributions produced: non-fatal render notes and
645
655
  * fatal messages (a failed contributor, a name collision). */
646
656
  export interface BuildRootMergeResult {
@@ -667,6 +677,7 @@ export interface BuildRootMergeResult {
667
677
  export async function mergeBuildRootEntities(
668
678
  entities: Map<string, Declarable>,
669
679
  contributors: ReadonlyArray<BuildRootContributor>,
680
+ sourceDir?: string,
670
681
  ): Promise<BuildRootMergeResult> {
671
682
  const warnings: string[] = [];
672
683
  const errors: string[] = [];
@@ -677,7 +688,7 @@ export async function mergeBuildRootEntities(
677
688
  // ciphertext file to resolve). Contributors run in order, so this also
678
689
  // carries what earlier contributors added; the merge below is still the
679
690
  // only writer, which is what keeps the collision refusal meaningful.
680
- const contribution = await contribute({ entities });
691
+ const contribution = await contribute({ entities, ...(sourceDir !== undefined ? { sourceDir } : {}) });
681
692
  warnings.push(...(contribution.warnings ?? []));
682
693
  for (const [name, entity] of contribution.entities) {
683
694
  if (entities.has(name)) {
@@ -809,7 +820,7 @@ async function buildFromDiscoveryResult(
809
820
  // object, and re-running the contributors there would duplicate every
810
821
  // contributed entity once per child.
811
822
  if (!parentBuildStack && options?.buildRoots) {
812
- const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots);
823
+ const merged = await mergeBuildRootEntities(discoveryResult.entities, options.buildRoots, sourceDirOf(resolvedPathForChildStack));
813
824
  warnings.push(...merged.warnings);
814
825
  for (const message of merged.errors) {
815
826
  errors.push(new BuildErrorClass("", message));
@@ -922,7 +922,8 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
922
922
  const resourceCount = result.entities.size;
923
923
  const fileCount = result.sourceFileCount;
924
924
 
925
- if (fileCount === 0 && errors.length === 0) {
925
+ // A build root (a lexicon's own source files, a rendered overlay) can declare everything without one .ts file.
926
+ if (fileCount === 0 && resourceCount === 0 && errors.length === 0) {
926
927
  console.error(formatInfo("No source files found — create .ts files in the target directory"));
927
928
  }
928
929
 
@@ -5,13 +5,25 @@
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
- import { parseOpWavesSpec, runOpWave } from "../../op/op-waves-run";
8
+ import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
9
9
  import { formatError } from "../format";
10
10
  import type { CommandContext } from "../registry";
11
11
 
12
12
  export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
13
13
  const { args } = ctx;
14
14
  const specFile = args.opsSpec;
15
+ if (specFile && args.recordPlans) {
16
+ try {
17
+ const spec = parseOpWavesSpec(readFileSync(resolve(specFile), "utf-8"), `--spec ${specFile}`);
18
+ const plans = await recordOpWaveHeadPlans({ spec });
19
+ if (args.json) console.log(JSON.stringify(plans, null, 2));
20
+ else for (const w of plans.waves) console.error(`wave ${w.wave} (${w.name}) at ${plans.head.slice(0, 12)}: ${w.digest}`);
21
+ return 0;
22
+ } catch (err) {
23
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
24
+ return 1;
25
+ }
26
+ }
15
27
  if (!specFile || args.wave === undefined) {
16
28
  console.error(formatError({
17
29
  message: "`chant run wave` needs --spec <file> and --wave <k>",
package/src/cli/main.ts CHANGED
@@ -108,6 +108,7 @@ const BOOLEAN_FLAGS = new Set([
108
108
  "--root-only",
109
109
  "--generated",
110
110
  "--decide",
111
+ "--record-plans",
111
112
  ]);
112
113
 
113
114
  /**
@@ -359,6 +360,8 @@ export function parseArgs(args: string[]): ParsedArgs {
359
360
  result.wave = wave;
360
361
  } else if (arg === "--decide") {
361
362
  result.decide = true;
363
+ } else if (arg === "--record-plans") {
364
+ result.recordPlans = true;
362
365
  } else if (arg === "--share") {
363
366
  const raw = args[++i];
364
367
  const share = Number(raw);
@@ -925,7 +928,10 @@ Ops:
925
928
  run wave --spec <file> --wave <k> Plan, gate and apply wave k of an Op
926
929
  waves spec (#3679): the job a generated waves
927
930
  pipeline runs. --decide plans and decides only;
928
- --share <i> applies one share of a decided wave
931
+ --share <i> applies one share of a decided wave.
932
+ --record-plans (no --wave), in a pull request's
933
+ pipeline: plan every wave at its head and record the
934
+ digests a pr-review approval mode compares
929
935
  run resume [--op <name>] Start again each CI job that waits at a gate an
930
936
  approval now answers, through the forge's API
931
937
  (#3683); approves nothing. --dry-run lists them
@@ -211,7 +211,7 @@ export function collectBuildRootContributors(
211
211
  .filter((plugin) => typeof plugin.buildRoots === "function")
212
212
  // `entities` is not bindable here — discovery has not run — so the merge
213
213
  // hands it in when it calls the closure (#1828 / SOPS provenance).
214
- .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities }));
214
+ .map((plugin): import("../lexicon").BuildRootContributor => (ctx) => plugin.buildRoots!({ projectRoot, config, entities: ctx?.entities, ...(ctx?.sourceDir !== undefined ? { sourceDir: ctx.sourceDir } : {}) }));
215
215
  return [...codegen, ...roots];
216
216
  }
217
217
 
@@ -252,6 +252,8 @@ export interface ParsedArgs {
252
252
  wave?: number;
253
253
  /** `chant run wave --decide` (#3679) — plan a wide wave and decide its gate, applying nothing. */
254
254
  decide?: boolean;
255
+ /** `chant run wave --record-plans` (#3684) — plan every wave at a pull request's head and record the digests for a `pr-review` gate. */
256
+ recordPlans?: boolean;
255
257
  /** `chant run wave --share <i>` (#3679) — apply share `i` of a decided wide wave. */
256
258
  share?: number;
257
259
  /** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
@@ -116,3 +116,21 @@ export async function findInfraFiles(path: string, options?: FindInfraFilesOptio
116
116
  accept: (name, full) => isSourceFileName(name) && !hasDiscoveryMarkerSync(full),
117
117
  });
118
118
  }
119
+
120
+ /**
121
+ * Find the files under `path` that `accept` wants, through the same walk and
122
+ * the same project globs as {@link findInfraFiles}: a lexicon that reads
123
+ * source of its own beside the TypeScript (the sql lexicon's `.sql` files)
124
+ * sees exactly the tree source discovery sees, so `dist`, git-ignored paths,
125
+ * child projects and the project's `exclude` globs are left out for it too.
126
+ */
127
+ export async function findSourceFiles(path: string, accept: (name: string, full: string) => boolean, options?: FindInfraFilesOptions): Promise<string[]> {
128
+ const globs = options?.globs === null ? undefined : (options?.globs ?? (await resolveDiscoveryGlobs(path)));
129
+ return walkDiscovery({
130
+ walker: "source",
131
+ root: path,
132
+ globs,
133
+ excludeDirs: await workspaceMemberDirs(path),
134
+ accept,
135
+ });
136
+ }
package/src/lexicon.ts CHANGED
@@ -984,6 +984,15 @@ export interface BuildRootContext {
984
984
  * treat an absent map as an empty one.
985
985
  */
986
986
  entities?: ReadonlyMap<string, Declarable>;
987
+ /**
988
+ * The directory this build discovered its source in (the path handed to
989
+ * `build()`, which `--src` or `sourceDir` chose), absolute. A contributor
990
+ * that reads files beside the typed source (the sql lexicon's `.sql`
991
+ * files) reads them here, so it sees the tree discovery saw. Optional: a
992
+ * caller that is not a build (the graph paths) omits it, and a hook then
993
+ * falls back to `sourceDir` in `config`, resolved against `projectRoot`.
994
+ */
995
+ sourceDir?: string;
987
996
  }
988
997
 
989
998
  /**
@@ -1003,7 +1012,7 @@ export interface BuildRootContribution {
1003
1012
  * the argument is still assignable, which is what every pre-#1828 hook does.
1004
1013
  */
1005
1014
  export type BuildRootContributor = (
1006
- ctx: Pick<BuildRootContext, "entities">,
1015
+ ctx: Pick<BuildRootContext, "entities" | "sourceDir">,
1007
1016
  ) => Promise<BuildRootContribution>;
1008
1017
 
1009
1018
  /**
@@ -222,6 +222,13 @@ export interface GateResolutionRecord {
222
222
  * verifies. Absent on an unsigned approval.
223
223
  */
224
224
  seal?: GateApprovalSeal;
225
+ /**
226
+ * The forge review this approval came from (#3684): a wave whose approval
227
+ * mode is `pr-review` writes it when the merged pull request's head was
228
+ * approved by a writer other than its author and planned this digest.
229
+ * `resolvedBy` is the first of `approvers`.
230
+ */
231
+ review?: { pr: number; head: string; approvers: string[] };
225
232
  /**
226
233
  * The answer record that passed the gate, on a gate that asks a decision
227
234
  * point (#3170). The run writes this resolution itself when the point's
@@ -0,0 +1,147 @@
1
+ /**
2
+ * #3712: a `chant lifecycle diff` that fails yields no digest, so an approval
3
+ * cannot pass a gate for a plan nobody read.
4
+ *
5
+ * Before the fix the activity caught the diff's non-zero exit and digested
6
+ * its error output. Every run whose build failed printed the same lines, so
7
+ * an approval of one such run's digest matched the next, whatever the
8
+ * declarations had become, and the Apply phase (which builds on its own)
9
+ * applied the change.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, test, vi } from "vitest";
13
+
14
+ /** What the next `chant lifecycle diff` prints and how it exits. */
15
+ const next = { stdout: "", stderr: "", code: 0 };
16
+
17
+ vi.mock(import("node:child_process"), async (importOriginal) => ({
18
+ ...(await importOriginal()),
19
+ exec: ((_command: string, _options: unknown, callback: (err: unknown, out?: { stdout: string; stderr: string }) => void) => {
20
+ if (next.code === 0) callback(null, { stdout: next.stdout, stderr: next.stderr });
21
+ else callback(Object.assign(new Error("exit"), { code: next.code, stdout: next.stdout, stderr: next.stderr }));
22
+ }) as never,
23
+ }));
24
+
25
+ const { lifecycleDiff, lifecycleDiffDigest } = await import("./lifecycle");
26
+ const { OpRunFailure, runOpLocally } = await import("../local-executor");
27
+ const { stepOutput } = await import("../step-output-ref");
28
+ type ActivityFn = import("../activity-registry").ActivityFn;
29
+ type ActivityProfile = import("../activity-registry").ActivityProfile;
30
+ type GateLedgerPort = import("../gate").GateLedgerPort;
31
+ type GateResolutionRecord = import("../../lifecycle/gate-ledger").GateResolutionRecord;
32
+ type PendingGateRecord = import("../../lifecycle/gate-ledger").PendingGateRecord;
33
+ type OpConfig = import("../types").OpConfig;
34
+
35
+ const PROFILES: Record<string, ActivityProfile> = {
36
+ fastIdempotent: { timeout: "5m", retry: { maximumAttempts: 1 } },
37
+ };
38
+
39
+ const BUILD_FAILED = "error: Build failed for project — fix errors before diffing";
40
+ const ARGS = { env: "dev", live: true };
41
+
42
+ function failDiff(): void {
43
+ Object.assign(next, { stdout: "", stderr: BUILD_FAILED, code: 1 });
44
+ }
45
+
46
+ function diffPrints(render: string): void {
47
+ Object.assign(next, { stdout: render, stderr: "", code: 0 });
48
+ }
49
+
50
+ beforeEach(() => {
51
+ diffPrints("");
52
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
53
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
54
+ });
55
+
56
+ describe("lifecycleDiff on a failed diff (#3712)", () => {
57
+ test("a build error rejects, naming the command and the error, with no digest", async () => {
58
+ failDiff();
59
+ await expect(lifecycleDiff(ARGS)).rejects.toThrow(/chant lifecycle diff dev --live failed \(exit 1\); no plan to digest:\n.*Build failed for project/);
60
+ });
61
+
62
+ test("a diff that succeeds still returns its digest", async () => {
63
+ diffPrints("MISSING (declared, provider reports not in cloud):\n - events");
64
+ const result = await lifecycleDiff(ARGS);
65
+ expect(result.exitCode).toBe(0);
66
+ expect(result.drifted).toBe(true);
67
+ expect(result.planDigest).toBe(lifecycleDiffDigest(ARGS, "MISSING (declared, provider reports not in cloud):\n - events"));
68
+ });
69
+ });
70
+
71
+ describe("ApplyOp's gate over a diff whose build fails (#3712)", () => {
72
+ function ledger(): GateLedgerPort & { approve(planDigest: string): void } {
73
+ const resolutions: GateResolutionRecord[] = [];
74
+ const pending: PendingGateRecord[] = [];
75
+ return {
76
+ approve(planDigest) {
77
+ resolutions.push({ version: 1, op: "apply-dev", gate: "approve-apply-dev", resolvedBy: "alex", timestamp: "2026-10-10T12:05:00.000Z", planDigest });
78
+ },
79
+ async read() {
80
+ return { resolutions: [...resolutions], pending: [...pending] };
81
+ },
82
+ async appendPending(input) {
83
+ const record: PendingGateRecord = { version: 1, kind: "pending", ...input };
84
+ pending.push(record);
85
+ return { record, pushed: true };
86
+ },
87
+ };
88
+ }
89
+
90
+ const statuses = (failure: unknown) =>
91
+ (failure as InstanceType<typeof OpRunFailure>).result.records.map((r) => [r.fn, r.status]);
92
+
93
+ /** ApplyOp's Plan, Approve and Apply phases, with the real `lifecycleDiff` and an apply that records what it applied. */
94
+ function harness() {
95
+ const declared = { schema: "A" };
96
+ const applied: string[] = [];
97
+ const activities = new Map<string, ActivityFn>([
98
+ ["lifecycleDiff", (args, signal) => lifecycleDiff(args as { env: string; live?: boolean }, signal)],
99
+ ["nativeApply", async () => {
100
+ applied.push(declared.schema);
101
+ }],
102
+ ]);
103
+ const config: OpConfig = {
104
+ name: "apply-dev",
105
+ overview: "",
106
+ phases: [
107
+ { name: "Plan", steps: [{ kind: "activity", fn: "lifecycleDiff", id: "plan", args: ARGS }] },
108
+ { name: "Approve", steps: [{ kind: "gate", gate: "approve-apply-dev", plan: stepOutput("plan", "planDigest") }] },
109
+ { name: "Apply", steps: [{ kind: "activity", fn: "nativeApply" }] },
110
+ ],
111
+ };
112
+ return { declared, applied, activities, config };
113
+ }
114
+
115
+ test("an approval of plan A does not pass for plan B when the diff's build fails both times", async () => {
116
+ const { declared, applied, activities, config } = harness();
117
+ const gates = ledger();
118
+
119
+ // Plan A: the diff's build fails. The run stops at Plan: nothing reaches the gate.
120
+ failDiff();
121
+ const first = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" }).catch((e: unknown) => e);
122
+ expect(first).toBeInstanceOf(OpRunFailure);
123
+ expect(statuses(first)).toEqual([["lifecycleDiff", "fail"]]);
124
+ expect(applied).toEqual([]);
125
+
126
+ // The digest the diff's error output used to give: someone approves it.
127
+ gates.approve(lifecycleDiffDigest(ARGS, BUILD_FAILED));
128
+
129
+ // Plan B: the declarations change, and the diff's build still fails.
130
+ declared.schema = "B";
131
+ const second = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:10:00.000Z" }).catch((e: unknown) => e);
132
+ expect(second).toBeInstanceOf(OpRunFailure);
133
+ expect(statuses(second)).toEqual([["lifecycleDiff", "fail"]]);
134
+ expect(applied).toEqual([]);
135
+ });
136
+
137
+ test("with the build fixed, the run stops at the gate for the plan it read", async () => {
138
+ const { applied, activities, config } = harness();
139
+ const gates = ledger();
140
+ const render = "MISSING (declared, provider reports not in cloud):\n - events";
141
+ diffPrints(render);
142
+ const run = await runOpLocally(config, activities, PROFILES, undefined, { gates, now: "2026-10-10T12:00:00.000Z" });
143
+ expect(run.status).toBe("gated");
144
+ expect(run.gate?.planDigest).toBe(lifecycleDiffDigest(ARGS, render));
145
+ expect(applied).toEqual([]);
146
+ });
147
+ });
@@ -32,7 +32,7 @@ export interface LifecycleDiffArgs {
32
32
  export interface LifecycleDiffResult {
33
33
  /** Combined stdout + stderr from the chant command. */
34
34
  output: string;
35
- /** Process exit code (0 = success). */
35
+ /** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
36
36
  exitCode: number;
37
37
  /**
38
38
  * True when the diff output contains any drift indicators
@@ -121,18 +121,27 @@ export function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): st
121
121
  * MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
122
122
  * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
123
123
  * on a WatchOp activity step to surface drift as the run's `Drift` outcome.
124
+ *
125
+ * A diff that exits non-zero (#3712) fails the step and yields no digest.
126
+ * Its output is an error, not a change set: every failed run printed the
127
+ * same lines, so a digest over them was one an approval matched whatever
128
+ * the declarations had become since.
124
129
  */
125
130
  export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult> {
126
131
  const liveFlag = args.live ? " --live" : "";
132
+ const command = `chant lifecycle diff ${args.env}${liveFlag}`;
133
+ let stdout: string;
134
+ let stderr: string;
127
135
  try {
128
- const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
129
- const output = `${stdout}${stderr}`.trim();
130
- if (output) console.log(output);
131
- return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
136
+ ({ stdout, stderr } = await execAsync(command, { signal }));
132
137
  } catch (err) {
133
138
  const e = err as { code?: number; stdout?: string; stderr?: string };
134
139
  const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
135
140
  if (output) console.error(output);
136
- return { output, exitCode: e.code ?? 1, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
141
+ const message = `${command} failed (exit ${String(e.code ?? 1)}); no plan to digest${output ? `:\n${output}` : ""}`;
142
+ throw new Error(message);
137
143
  }
144
+ const output = `${stdout}${stderr}`.trim();
145
+ if (output) console.log(output);
146
+ return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
138
147
  }