@intentius/chant 0.118.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.
@@ -19,7 +19,7 @@ export interface LifecycleDiffArgs {
19
19
  export interface LifecycleDiffResult {
20
20
  /** Combined stdout + stderr from the chant command. */
21
21
  output: string;
22
- /** Process exit code (0 = success). */
22
+ /** Process exit code: always 0, since a diff that exits non-zero throws (#3712). */
23
23
  exitCode: number;
24
24
  /**
25
25
  * True when the diff output contains any drift indicators
@@ -73,6 +73,11 @@ export declare function lifecycleDiffDigest(args: LifecycleDiffArgs, output: str
73
73
  * MISSING / ORPHAN / DRIFTED / DISAPPEARED / PROPERTY DRIFT section headers documented in
74
74
  * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
75
75
  * on a WatchOp activity step to surface drift as the run's `Drift` outcome.
76
+ *
77
+ * A diff that exits non-zero (#3712) fails the step and yields no digest.
78
+ * Its output is an error, not a change set: every failed run printed the
79
+ * same lines, so a digest over them was one an approval matched whatever
80
+ * the declarations had become since.
76
81
  */
77
82
  export declare function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult>;
78
83
  //# sourceMappingURL=lifecycle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/op/activities/lifecycle.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAIxG;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;OASG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM/D;AAmBD,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAMnF;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAa/G"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/op/activities/lifecycle.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAIxG;AAED,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;;;;;OASG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAM/D;AAmBD,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAMnF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiB/G"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.118.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",
@@ -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
  }