@intentius/chant 0.61.0 → 0.63.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 (122) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -18
  2. package/dist/cli/handlers/operator.d.ts.map +1 -1
  3. package/dist/cli/handlers/run.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/components/cli-support.d.ts +3 -0
  8. package/dist/components/cli-support.d.ts.map +1 -1
  9. package/dist/components/driver-output.d.ts.map +1 -1
  10. package/dist/components/driver.d.ts +12 -0
  11. package/dist/components/driver.d.ts.map +1 -1
  12. package/dist/fold/fold.d.ts.map +1 -1
  13. package/dist/fold/subset.d.ts +10 -0
  14. package/dist/fold/subset.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +51 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/git.d.ts +117 -0
  20. package/dist/lifecycle/git.d.ts.map +1 -1
  21. package/dist/lifecycle/index.d.ts +1 -0
  22. package/dist/lifecycle/index.d.ts.map +1 -1
  23. package/dist/lifecycle/plan-digest.d.ts +33 -0
  24. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/op/activities/lexicon-upgrade.d.ts +33 -3
  27. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  28. package/dist/op/activities/lifecycle.d.ts +27 -0
  29. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  30. package/dist/op/activities/reconcile.d.ts +394 -14
  31. package/dist/op/activities/reconcile.d.ts.map +1 -1
  32. package/dist/op/builders.d.ts +6 -0
  33. package/dist/op/builders.d.ts.map +1 -1
  34. package/dist/op/composites/apply-op.d.ts +6 -0
  35. package/dist/op/composites/apply-op.d.ts.map +1 -1
  36. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  37. package/dist/op/gate-summary.d.ts +16 -0
  38. package/dist/op/gate-summary.d.ts.map +1 -1
  39. package/dist/op/gate.d.ts +104 -13
  40. package/dist/op/gate.d.ts.map +1 -1
  41. package/dist/op/index.d.ts +3 -2
  42. package/dist/op/index.d.ts.map +1 -1
  43. package/dist/op/local-executor.d.ts +34 -2
  44. package/dist/op/local-executor.d.ts.map +1 -1
  45. package/dist/op/local-output.d.ts.map +1 -1
  46. package/dist/op/op-ir.d.ts +8 -1
  47. package/dist/op/op-ir.d.ts.map +1 -1
  48. package/dist/op/operator.d.ts.map +1 -1
  49. package/dist/op/runtime.d.ts +2 -0
  50. package/dist/op/runtime.d.ts.map +1 -1
  51. package/dist/op/runtimes/local.d.ts.map +1 -1
  52. package/dist/op/types.d.ts +19 -0
  53. package/dist/op/types.d.ts.map +1 -1
  54. package/dist/runtime-adapter.d.ts +8 -0
  55. package/dist/runtime-adapter.d.ts.map +1 -1
  56. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  57. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  58. package/dist/terraform/graph.d.ts +18 -2
  59. package/dist/terraform/graph.d.ts.map +1 -1
  60. package/dist/terraform/parse.d.ts.map +1 -1
  61. package/dist/terraform/types.d.ts +7 -0
  62. package/dist/terraform/types.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli/handlers/operator.test.ts +232 -1
  65. package/src/cli/handlers/operator.ts +130 -6
  66. package/src/cli/handlers/run.ts +19 -0
  67. package/src/cli/main.ts +2 -0
  68. package/src/cli/registry.ts +2 -0
  69. package/src/components/cli-support.ts +29 -4
  70. package/src/components/driver-output.ts +10 -0
  71. package/src/components/driver.test.ts +31 -0
  72. package/src/components/driver.ts +54 -8
  73. package/src/discovery/fold-import.test.ts +55 -0
  74. package/src/fold/fold.test.ts +152 -0
  75. package/src/fold/fold.ts +102 -2
  76. package/src/fold/subset-doc-parity.test.ts +35 -1
  77. package/src/fold/subset.ts +10 -0
  78. package/src/lexicon.ts +51 -0
  79. package/src/lifecycle/gate-ledger.test.ts +133 -1
  80. package/src/lifecycle/gate-ledger.ts +108 -0
  81. package/src/lifecycle/git.test.ts +49 -5
  82. package/src/lifecycle/git.ts +312 -11
  83. package/src/lifecycle/index.ts +1 -0
  84. package/src/lifecycle/plan-digest.test.ts +49 -0
  85. package/src/lifecycle/plan-digest.ts +86 -0
  86. package/src/lifecycle/run-ledger.ts +1 -0
  87. package/src/op/activities/lexicon-upgrade.test.ts +134 -39
  88. package/src/op/activities/lexicon-upgrade.ts +74 -12
  89. package/src/op/activities/lifecycle.ts +51 -2
  90. package/src/op/activities/reconcile.test.ts +1013 -1
  91. package/src/op/activities/reconcile.ts +721 -25
  92. package/src/op/builders.ts +7 -1
  93. package/src/op/composites/apply-op.ts +16 -0
  94. package/src/op/composites/composites.test.ts +15 -2
  95. package/src/op/composites/reconcile-op.test.ts +18 -0
  96. package/src/op/composites/reconcile-op.ts +7 -1
  97. package/src/op/gate-summary.test.ts +33 -0
  98. package/src/op/gate-summary.ts +31 -0
  99. package/src/op/gate.test.ts +614 -0
  100. package/src/op/gate.ts +190 -24
  101. package/src/op/index.ts +5 -2
  102. package/src/op/local-executor.test.ts +340 -2
  103. package/src/op/local-executor.ts +190 -32
  104. package/src/op/local-output.test.ts +38 -0
  105. package/src/op/local-output.ts +24 -1
  106. package/src/op/op-ir.test.ts +22 -0
  107. package/src/op/op-ir.ts +9 -0
  108. package/src/op/operator.test.ts +20 -0
  109. package/src/op/operator.ts +39 -1
  110. package/src/op/runtime.ts +2 -0
  111. package/src/op/runtimes/local.ts +11 -0
  112. package/src/op/types.ts +19 -0
  113. package/src/runtime-adapter.ts +17 -3
  114. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  115. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  116. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  117. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  118. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  119. package/src/terraform/graph.test.ts +148 -1
  120. package/src/terraform/graph.ts +144 -6
  121. package/src/terraform/parse.ts +4 -1
  122. package/src/terraform/types.ts +7 -0
@@ -0,0 +1,614 @@
1
+ /**
2
+ * The gate against a real `chant/lifecycle` branch, in the two checkout
3
+ * shapes CI actually produces (#2301, #2303).
4
+ *
5
+ * Everything else that exercises `evaluateGate` does it through
6
+ * `memoryGateLedgerPort`, which is the right tool for the decision rules and
7
+ * the wrong one for these two bugs: both are properties of the git port —
8
+ * what a commit needs in order to be written at all, and what a checkout can
9
+ * see of a branch it never fetched. Both were found by the first real
10
+ * execution of a generated Op pipeline (INTENTIUS/choudoufu#1026); neither is
11
+ * reachable without a repository on disk.
12
+ */
13
+ import { describe, test, expect, afterEach } from "vitest";
14
+ import { spawnSync } from "node:child_process";
15
+ import { writeFileSync } from "node:fs";
16
+ import { mkdir, rm } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+ import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "./gate";
20
+ import { appendGateResolution, readGateLedger } from "../lifecycle/gate-ledger";
21
+ import {
22
+ requireLifecycleLedger,
23
+ fetchLifecycleStatus,
24
+ writeBlobToPath,
25
+ LifecycleLedgerUnreadableError,
26
+ } from "../lifecycle/git";
27
+ import { appendRunRecord, buildRunRecord } from "../lifecycle/run-ledger";
28
+
29
+ function git(args: string[], cwd: string): { stdout: string; stderr: string; exitCode: number } {
30
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
31
+ return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.status ?? -1 };
32
+ }
33
+
34
+ const dirs: string[] = [];
35
+ function tmp(tag: string): string {
36
+ const p = join(tmpdir(), `chant-gate-${tag}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
37
+ dirs.push(p);
38
+ return p;
39
+ }
40
+
41
+ afterEach(async () => {
42
+ for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
43
+ });
44
+
45
+ /**
46
+ * A bare remote plus one clone that has already pushed `main`. The pair is
47
+ * what makes the #2303 cases expressible at all: the bug is a difference
48
+ * between what a remote holds and what a checkout can see of it.
49
+ */
50
+ async function clonePair(): Promise<{ remote: string; author: string }> {
51
+ const remote = tmp("remote");
52
+ const author = tmp("author");
53
+ await mkdir(remote, { recursive: true });
54
+ git(["init", "-q", "--bare", "-b", "main"], remote);
55
+ git(["clone", "-q", remote, author], tmpdir());
56
+ git(["config", "user.email", "test@chant.dev"], author);
57
+ git(["config", "user.name", "Test"], author);
58
+ writeFileSync(join(author, "README.md"), "fixture\n");
59
+ git(["add", "README.md"], author);
60
+ git(["commit", "-q", "-m", "init"], author);
61
+ git(["push", "-q", "origin", "main"], author);
62
+ return { remote, author };
63
+ }
64
+
65
+ /**
66
+ * A checkout in the state a GitLab CI job is in: `main` and nothing else.
67
+ * `--single-branch` is the closest local equivalent of GitLab's refspec
68
+ * fetch — it leaves the clone with no `chant/lifecycle`, not even a
69
+ * remote-tracking one, which is the whole of finding 1. (`GIT_DEPTH: 0` does
70
+ * not change this on GitLab either: it fetches refspecs, not all branches.)
71
+ */
72
+ function ciCheckout(remote: string): string {
73
+ const dir = tmp("ci");
74
+ git(["clone", "-q", "--single-branch", "--branch", "main", remote, dir], tmpdir());
75
+ git(["config", "user.email", "ci@chant.dev"], dir);
76
+ git(["config", "user.name", "CI"], dir);
77
+ return dir;
78
+ }
79
+
80
+ function lifecycleCommitCount(dir: string, ref = "chant/lifecycle"): number {
81
+ const r = git(["rev-list", "--count", ref], dir);
82
+ return r.exitCode === 0 ? Number(r.stdout.trim()) : 0;
83
+ }
84
+
85
+ describe("op/gate — a CI checkout with no git identity (#2301)", () => {
86
+ /**
87
+ * Deliverable 1. Strips the checkout of any identity the way a CI clone
88
+ * arrives: no `user.name`, no `user.email`, and `user.useConfigOnly` so
89
+ * git refuses to invent one from `$USER@$HOSTNAME` rather than succeeding
90
+ * on some machines and failing on others.
91
+ */
92
+ function stripIdentity(dir: string): void {
93
+ git(["config", "--unset-all", "user.email"], dir);
94
+ git(["config", "--unset-all", "user.name"], dir);
95
+ git(["config", "user.useConfigOnly", "true"], dir);
96
+ }
97
+
98
+ /** Blank out the global/system config so the developer's own identity cannot leak in. */
99
+ async function withoutAmbientIdentity(fn: () => Promise<void>): Promise<void> {
100
+ const saved = { g: process.env.GIT_CONFIG_GLOBAL, s: process.env.GIT_CONFIG_SYSTEM };
101
+ process.env.GIT_CONFIG_GLOBAL = "/dev/null";
102
+ process.env.GIT_CONFIG_SYSTEM = "/dev/null";
103
+ try {
104
+ await fn();
105
+ } finally {
106
+ if (saved.g === undefined) delete process.env.GIT_CONFIG_GLOBAL;
107
+ else process.env.GIT_CONFIG_GLOBAL = saved.g;
108
+ if (saved.s === undefined) delete process.env.GIT_CONFIG_SYSTEM;
109
+ else process.env.GIT_CONFIG_SYSTEM = saved.s;
110
+ }
111
+ }
112
+
113
+ test("records the pending fact anyway, under chant's own committer", async () => {
114
+ const { author } = await clonePair();
115
+ await withoutAmbientIdentity(async () => {
116
+ stripIdentity(author);
117
+
118
+ // The bare `git commit-tree` this goes through exits 128 with "no email
119
+ // was given" in this checkout; before #2301 that is where the gate died.
120
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
121
+ op: "live-apply",
122
+ gate: "approve-live-apply",
123
+ now: "2026-09-09T05:00:00.000Z",
124
+ });
125
+
126
+ expect(check.satisfied).toBe(false);
127
+ if (check.satisfied) return;
128
+ expect(check.recorded).toBe(true);
129
+
130
+ const { pending } = await readGateLedger("live-apply", { cwd: author });
131
+ expect(pending.map((p) => p.gate)).toEqual(["approve-live-apply"]);
132
+
133
+ // The fallback is chant's, applied per-invocation — nothing was written
134
+ // into the checkout's own config.
135
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
136
+ expect(committer.stdout.trim()).toBe("chant <chant@localhost>");
137
+ expect(git(["config", "--get", "user.email"], author).exitCode).not.toBe(0);
138
+ });
139
+ });
140
+
141
+ /**
142
+ * #2309 review, finding 5. `git var GIT_COMMITTER_IDENT` fails if *either*
143
+ * half is missing, so an all-or-nothing fallback would replace a configured
144
+ * `user.name` too — which is not what "a configured identity is left exactly
145
+ * as it is" promises, in this file's own doc comment or in ops.mdx.
146
+ */
147
+ test("a half-configured identity keeps the half it has", async () => {
148
+ const { author } = await clonePair();
149
+ await withoutAmbientIdentity(async () => {
150
+ stripIdentity(author);
151
+ git(["config", "user.name", "Release Bot"], author);
152
+
153
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
154
+ op: "live-apply",
155
+ gate: "approve-live-apply",
156
+ now: "2026-09-09T05:00:00.000Z",
157
+ });
158
+
159
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
160
+ expect(committer.stdout.trim()).toBe("Release Bot <chant@localhost>");
161
+ });
162
+ });
163
+
164
+ test("a configured identity still authors the ledger commit", async () => {
165
+ const { author } = await clonePair();
166
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
167
+ op: "live-apply",
168
+ gate: "approve-live-apply",
169
+ now: "2026-09-09T05:00:00.000Z",
170
+ });
171
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
172
+ expect(committer.stdout.trim()).toBe("Test <test@chant.dev>");
173
+ });
174
+
175
+ test("the committer fallback is overridable per project", async () => {
176
+ const { author } = await clonePair();
177
+ const saved = process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL;
178
+ process.env.CHANT_LIFECYCLE_COMMITTER_NAME = "estate-bot";
179
+ process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL = "estate-bot@example.com";
180
+ try {
181
+ await withoutAmbientIdentity(async () => {
182
+ stripIdentity(author);
183
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
184
+ op: "live-apply",
185
+ gate: "approve-live-apply",
186
+ now: "2026-09-09T05:00:00.000Z",
187
+ });
188
+ const committer = git(["log", "-1", "--format=%cn <%ce>", "chant/lifecycle"], author);
189
+ expect(committer.stdout.trim()).toBe("estate-bot <estate-bot@example.com>");
190
+ });
191
+ } finally {
192
+ delete process.env.CHANT_LIFECYCLE_COMMITTER_NAME;
193
+ if (saved === undefined) delete process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL;
194
+ else process.env.CHANT_LIFECYCLE_COMMITTER_EMAIL = saved;
195
+ }
196
+ });
197
+
198
+ /**
199
+ * The identity fallback removes the one cause #2301 was reported for; it
200
+ * does not make the write infallible, and the issue is explicit that the
201
+ * message matters more than the cause. Whatever else goes wrong in there
202
+ * has to say that what failed was chant writing a named file on its own
203
+ * branch — `git hash-object failed: ...` on its own does not.
204
+ */
205
+ test("a ledger write that cannot happen names the branch and the path", async () => {
206
+ const { author } = await clonePair();
207
+ // Establish the branch first, so this exercises the *write* rather than
208
+ // the absent-ledger guard that runs ahead of it.
209
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
210
+ op: "live-apply",
211
+ gate: "approve-live-apply",
212
+ now: "2026-09-09T05:00:00.000Z",
213
+ });
214
+ // A newline in the entry name is `git mktree`'s one deterministic input
215
+ // error, so the failure lands mid-write with the branch already present.
216
+ await expect(
217
+ writeBlobToPath("_gates", "live\napply.jsonl", "{}", "msg", { cwd: author }),
218
+ ).rejects.toThrow(/cannot write _gates\/live\napply\.jsonl on the chant\/lifecycle branch: git mktree/);
219
+ });
220
+ });
221
+
222
+ describe("op/gate — a CI checkout that never fetched the ledger (#2303)", () => {
223
+ test("finding 1: approve then re-run closes the gate, without a second pending fact", async () => {
224
+ const { remote, author } = await clonePair();
225
+
226
+ // Run 1, in a checkout that has the branch: gates and records the fact.
227
+ const first = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
228
+ op: "live-apply",
229
+ gate: "approve-live-apply",
230
+ now: "2026-09-09T05:00:00.000Z",
231
+ });
232
+ expect(first.satisfied).toBe(false);
233
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
234
+
235
+ // The operator approves.
236
+ await appendGateResolution(
237
+ {
238
+ op: "live-apply",
239
+ gate: "approve-live-apply",
240
+ resolvedBy: "e2e-operator",
241
+ timestamp: "2026-09-09T05:15:45.515Z",
242
+ },
243
+ { cwd: author },
244
+ );
245
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
246
+
247
+ // Run 2 is the retried CI job: same commit, a checkout with no
248
+ // `chant/lifecycle` at all. Before #2303 it read an empty ledger, saw no
249
+ // resolution and no standing fact, and recorded a *second* pending one.
250
+ const ci = ciCheckout(remote);
251
+ expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], ci).exitCode).not.toBe(0);
252
+
253
+ const second = await evaluateGate(gitGateLedgerPort({ cwd: ci }), {
254
+ op: "live-apply",
255
+ gate: "approve-live-apply",
256
+ now: "2026-09-09T05:20:00.000Z",
257
+ });
258
+
259
+ expect(second.satisfied).toBe(true);
260
+ if (!second.satisfied) return;
261
+ expect(second.resolution.resolvedBy).toBe("e2e-operator");
262
+
263
+ const { pending } = await readGateLedger("live-apply", { cwd: ci });
264
+ expect(pending).toHaveLength(1);
265
+ });
266
+
267
+ test("finding 1: an unreachable ledger is refused by name, not read as empty", async () => {
268
+ const { remote, author } = await clonePair();
269
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
270
+ op: "live-apply",
271
+ gate: "approve-live-apply",
272
+ now: "2026-09-09T05:00:00.000Z",
273
+ });
274
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
275
+
276
+ const ci = ciCheckout(remote);
277
+ // The remote goes away mid-flight — a lost network, a revoked token, a
278
+ // runner that cannot reach the forge. The ledger's contents are now
279
+ // unknown, which is precisely what must not be reported as "nothing has
280
+ // been approved".
281
+ await rm(remote, { recursive: true, force: true });
282
+
283
+ await expect(
284
+ evaluateGate(gitGateLedgerPort({ cwd: ci }), {
285
+ op: "live-apply",
286
+ gate: "approve-live-apply",
287
+ now: "2026-09-09T05:20:00.000Z",
288
+ }),
289
+ ).rejects.toThrow(/chant\/lifecycle ledger branch is not in this checkout and could not be fetched/);
290
+ });
291
+
292
+ test("finding 1: a project whose ledger was never written still gates normally", async () => {
293
+ // The other half of the refusal: "absent from the remote" is a real
294
+ // empty ledger, and a first gate must not be refused.
295
+ const { author } = await clonePair();
296
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: author }), {
297
+ op: "live-apply",
298
+ gate: "approve-live-apply",
299
+ now: "2026-09-09T05:00:00.000Z",
300
+ });
301
+ expect(check.satisfied).toBe(false);
302
+ if (check.satisfied) return;
303
+ expect(check.recorded).toBe(true);
304
+ });
305
+
306
+ test("finding 2: approving from an unfetched clone appends, and the pending fact survives", async () => {
307
+ const { remote, author } = await clonePair();
308
+
309
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
310
+ op: "live-apply",
311
+ gate: "approve-live-apply",
312
+ now: "2026-09-09T05:00:00.000Z",
313
+ });
314
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
315
+ const before = lifecycleCommitCount(author);
316
+ expect(before).toBe(1);
317
+
318
+ // The obvious operator move: clone, approve, push. This clone has never
319
+ // seen `chant/lifecycle`, so before #2303 the append built its tree from
320
+ // an empty read and committed with no parent — the branch went from one
321
+ // commit holding the pending record to one commit holding only the
322
+ // resolution.
323
+ const approver = ciCheckout(remote);
324
+ await requireLifecycleLedger({ cwd: approver });
325
+ await appendGateResolution(
326
+ {
327
+ op: "live-apply",
328
+ gate: "approve-live-apply",
329
+ resolvedBy: "e2e-operator",
330
+ timestamp: "2026-09-09T05:15:45.515Z",
331
+ },
332
+ { cwd: approver },
333
+ );
334
+
335
+ const { pending, resolutions } = await readGateLedger("live-apply", { cwd: approver });
336
+ expect(pending).toHaveLength(1);
337
+ expect(resolutions.map((r) => r.resolvedBy)).toEqual(["e2e-operator"]);
338
+ // Appended to the history, not written over it.
339
+ expect(lifecycleCommitCount(approver)).toBe(before + 1);
340
+ });
341
+
342
+ /**
343
+ * Build the state #2309's review found destroys approvals: a remote holding
344
+ * the real ledger, and a *full* clone whose local `chant/lifecycle` is an
345
+ * unrelated root commit. The clone must be full, not `--single-branch` —
346
+ * that is what gives the failed non-fast-forward fetch a default refspec to
347
+ * opportunistically refresh `refs/remotes/origin/chant/lifecycle` through,
348
+ * which is what made `pushLifecycle`'s `--force-with-lease` match.
349
+ */
350
+ async function forkedCheckout(remote: string): Promise<string> {
351
+ const dir = tmp("forked");
352
+ git(["clone", "-q", remote, dir], tmpdir());
353
+ git(["config", "user.email", "fork@chant.dev"], dir);
354
+ git(["config", "user.name", "Fork"], dir);
355
+ git(["branch", "-q", "-D", "chant/lifecycle"], dir);
356
+ git(["update-ref", "-d", "refs/remotes/origin/chant/lifecycle"], dir);
357
+ // An orphan commit on the same branch name, built without ever reading
358
+ // the remote's — the shape a pre-#2303 unfetched append produced, and the
359
+ // shape a fabricated run-ledger branch still would.
360
+ const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { cwd: dir, input: "forked\n", encoding: "utf-8" }).stdout.trim();
361
+ const tree = spawnSync("git", ["mktree"], { cwd: dir, input: `100644 blob ${blob}\tf\n`, encoding: "utf-8" }).stdout.trim();
362
+ const commit = git(["commit-tree", "-m", "forked local ledger", tree], dir).stdout.trim();
363
+ git(["update-ref", "refs/heads/chant/lifecycle", commit], dir);
364
+ return dir;
365
+ }
366
+
367
+ test("finding 1 (review): the gate refuses a forked local ledger instead of reading it as authoritative", async () => {
368
+ const { remote, author } = await clonePair();
369
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
370
+ op: "live-apply",
371
+ gate: "approve-live-apply",
372
+ now: "2026-09-09T05:00:00.000Z",
373
+ });
374
+ await appendGateResolution(
375
+ { op: "live-apply", gate: "approve-live-apply", resolvedBy: "e2e-operator", timestamp: "2026-09-09T05:15:45.515Z" },
376
+ { cwd: author },
377
+ );
378
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
379
+ const remoteTipBefore = git(["rev-parse", "refs/heads/chant/lifecycle"], remote).stdout.trim();
380
+
381
+ const forked = await forkedCheckout(remote);
382
+
383
+ // The read the gate actually performs. Before the review this returned
384
+ // normally — the guard's `if (hadLocal) return` swallowed divergence — so
385
+ // the gate read a ledger with no approval in it, recorded a second
386
+ // pending fact, and pushed it over the top of the real one.
387
+ await expect(
388
+ evaluateGate(gitGateLedgerPort({ cwd: forked }), {
389
+ op: "live-apply",
390
+ gate: "approve-live-apply",
391
+ now: "2026-09-09T05:20:00.000Z",
392
+ }),
393
+ ).rejects.toBeInstanceOf(LifecycleLedgerUnreadableError);
394
+
395
+ // Nothing was written, and the approval on the remote is untouched.
396
+ expect(git(["rev-parse", "refs/heads/chant/lifecycle"], remote).stdout.trim()).toBe(remoteTipBefore);
397
+ const { resolutions } = await readGateLedger("live-apply", { cwd: author });
398
+ expect(resolutions.map((r) => r.resolvedBy)).toEqual(["e2e-operator"]);
399
+ });
400
+
401
+ test("finding 2: a local ledger that diverged from the remote refuses rather than appending across the fork", async () => {
402
+ const { remote, author } = await clonePair();
403
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
404
+ op: "live-apply",
405
+ gate: "approve-live-apply",
406
+ now: "2026-09-09T05:00:00.000Z",
407
+ });
408
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
409
+
410
+ const forked = await forkedCheckout(remote);
411
+ expect(lifecycleCommitCount(forked)).toBe(1);
412
+
413
+ await expect(requireLifecycleLedger({ cwd: forked })).rejects.toBeInstanceOf(
414
+ LifecycleLedgerUnreadableError,
415
+ );
416
+ await expect(requireLifecycleLedger({ cwd: forked })).rejects.toThrow(
417
+ /has diverged from the copy on "origin"/,
418
+ );
419
+ });
420
+
421
+ test("a local ledger merely ahead of the remote is not a fork, and still appends", async () => {
422
+ // The other side of the divergence check: git reports "non-fast-forward"
423
+ // for a local branch that is ahead too — the ordinary state right after an
424
+ // append whose push has not landed. Refusing there would break the retry
425
+ // that is meant to repair it.
426
+ const { author } = await clonePair();
427
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
428
+ op: "live-apply",
429
+ gate: "approve-live-apply",
430
+ now: "2026-09-09T05:00:00.000Z",
431
+ });
432
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
433
+
434
+ // One more local commit that the remote has not seen.
435
+ await appendGateResolution(
436
+ { op: "live-apply", gate: "approve-live-apply", resolvedBy: "e2e-operator", timestamp: "2026-09-09T05:15:45.515Z" },
437
+ { cwd: author },
438
+ );
439
+ expect(lifecycleCommitCount(author)).toBe(2);
440
+
441
+ expect(await fetchLifecycleStatus({ cwd: author })).toMatchObject({ status: "ahead" });
442
+ await expect(requireLifecycleLedger({ cwd: author })).resolves.toBeUndefined();
443
+ });
444
+
445
+ /**
446
+ * #2309 review, finding 2. Every ledger writer reaches `writeBlobToPath`,
447
+ * and on a checkout with no local `chant/lifecycle` that function used to
448
+ * build a root commit unconditionally. Until #2301 the damage was capped by
449
+ * accident: `commit-tree` died for want of a committer, so no branch was
450
+ * created. Supplying an identity would have turned that loud failure into a
451
+ * silent branch fabrication — a run record, on an Op with no gate at all,
452
+ * creating a `chant/lifecycle` that forks the remote's. Since GitLab
453
+ * runners reuse `/builds/<project>` across jobs, the next job would then
454
+ * carry that fork into the gate.
455
+ */
456
+ test("a run-ledger append does not fabricate a ledger branch over the remote's", async () => {
457
+ const { remote, author } = await clonePair();
458
+ await evaluateGate(gitGateLedgerPort({ cwd: author }), {
459
+ op: "live-apply",
460
+ gate: "approve-live-apply",
461
+ now: "2026-09-09T05:00:00.000Z",
462
+ });
463
+ git(["push", "-q", "origin", "chant/lifecycle:chant/lifecycle"], author);
464
+
465
+ // A CI checkout with no ledger branch, running an Op that never reaches a
466
+ // gate — so nothing on its path has fetched anything.
467
+ const ci = ciCheckout(remote);
468
+ expect(git(["rev-parse", "--verify", "refs/heads/chant/lifecycle"], ci).exitCode).not.toBe(0);
469
+
470
+ await appendRunRecord(
471
+ buildRunRecord({ name: "live-check" }, [], {
472
+ started: "2026-09-09T05:30:00.000Z",
473
+ ended: "2026-09-09T05:30:10.000Z",
474
+ status: "ok",
475
+ }),
476
+ { cwd: ci },
477
+ );
478
+
479
+ // The branch it produced descends from the remote's, rather than replacing
480
+ // it: the pending fact written above is still readable here.
481
+ const { pending } = await readGateLedger("live-apply", { cwd: ci });
482
+ expect(pending.map((p) => p.gate)).toEqual(["approve-live-apply"]);
483
+ expect(await fetchLifecycleStatus({ cwd: ci })).toMatchObject({ status: "ahead" });
484
+ });
485
+
486
+ test("a project with no remote is local-only and is never refused", async () => {
487
+ const dir = tmp("solo");
488
+ await mkdir(dir, { recursive: true });
489
+ git(["init", "-q", "-b", "main"], dir);
490
+ git(["config", "user.email", "test@chant.dev"], dir);
491
+ git(["config", "user.name", "Test"], dir);
492
+ writeFileSync(join(dir, "README.md"), "fixture\n");
493
+ git(["add", "README.md"], dir);
494
+ git(["commit", "-q", "-m", "init"], dir);
495
+
496
+ const check = await evaluateGate(gitGateLedgerPort({ cwd: dir }), {
497
+ op: "live-apply",
498
+ gate: "approve-live-apply",
499
+ now: "2026-09-09T05:00:00.000Z",
500
+ });
501
+ expect(check.satisfied).toBe(false);
502
+ await expect(requireLifecycleLedger({ cwd: dir })).resolves.toBeUndefined();
503
+ // #2310: no remote to have failed against, but still worth saying —
504
+ // nothing this run just recorded left the checkout.
505
+ if (check.satisfied) throw new Error("unreachable");
506
+ expect(check.recorded).toBe(true);
507
+ expect(check.pushed).toBe(false);
508
+ expect(check.pushWarning).toMatch(/no remote/i);
509
+ });
510
+ });
511
+
512
+ /**
513
+ * #2310 — `recordGateApproval`'s push (`../cli/handlers/operator.ts`) was
514
+ * already reported by #2309's review; `evaluateGate`'s own `appendPending`
515
+ * push, right below it in the same file, still swallowed the rejection into
516
+ * `undefined` and let a gated run print as if the pending fact had reached
517
+ * everyone. It had not: an operator working from a clone of the remote
518
+ * cannot see a pending fact that never got there, and the run gave no hint
519
+ * that this was why their `chant approve` found nothing to resolve.
520
+ *
521
+ * Before this fix, `gitGateLedgerPort({ cwd }).appendPending(...)`'s
522
+ * resolved value was the bare `PendingGateRecord` — there was no `pushed`
523
+ * field to assert on at all, so the rejection above was genuinely invisible
524
+ * to any caller. That is the "red" state: this suite fails to typecheck
525
+ * against the old `Promise<PendingGateRecord>` return type, and a rejected
526
+ * push at `gate.ts:71` produced no failure text anywhere.
527
+ */
528
+ describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)", () => {
529
+ test("gitGateLedgerPort's appendPending reports pushed:false and the rejection reason", async () => {
530
+ const { remote, author } = await clonePair();
531
+ const other = tmp("other");
532
+ git(["clone", "-q", remote, other], tmpdir());
533
+ git(["config", "user.email", "other@chant.dev"], other);
534
+ git(["config", "user.name", "Other"], other);
535
+
536
+ const port = gitGateLedgerPort({ cwd: author });
537
+
538
+ // `author` records and pushes the branch's first pending fact — this
539
+ // both creates `chant/lifecycle` on the remote and pins `author`'s own
540
+ // remote-tracking ref to the commit it just pushed.
541
+ const first = await port.appendPending({
542
+ op: "live-apply",
543
+ gate: "approve-live-apply",
544
+ timestamp: "2026-09-09T05:00:00.000Z",
545
+ expiresAt: "2026-09-10T05:00:00.000Z",
546
+ });
547
+ expect(first.pushed).toBe(true);
548
+ expect(first.pushWarning).toBeUndefined();
549
+
550
+ // `other` fetches that commit, appends a second pending fact on top of
551
+ // it, and pushes — moving the remote on behind `author`'s back. `author`
552
+ // never re-fetches, so its remote-tracking ref still names the first
553
+ // commit alone.
554
+ git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], other);
555
+ const otherPort = gitGateLedgerPort({ cwd: other });
556
+ const second = await otherPort.appendPending({
557
+ op: "live-apply",
558
+ gate: "approve-other-gate",
559
+ timestamp: "2026-09-09T05:05:00.000Z",
560
+ expiresAt: "2026-09-10T05:05:00.000Z",
561
+ });
562
+ expect(second.pushed).toBe(true);
563
+
564
+ // `author` appends its own third fact on the tip it still knows (the
565
+ // first commit) and pushes against a lease the remote has since moved
566
+ // past — the same `StaleLifecycleBranchError` `chant approve` could hit
567
+ // (#2310's issue).
568
+ const third = await port.appendPending({
569
+ op: "live-apply",
570
+ gate: "approve-third-gate",
571
+ timestamp: "2026-09-09T05:10:00.000Z",
572
+ expiresAt: "2026-09-10T05:10:00.000Z",
573
+ });
574
+ expect(third.pushed).toBe(false);
575
+ expect(third.pushWarning).toBeTruthy();
576
+ expect(third.pushWarning).toMatch(/chant\/lifecycle remote branch has moved|stale|rejected|non-fast-forward/i);
577
+ // The append itself still landed locally — a correct local answer, per
578
+ // the #2309 author's reasoning; only the report was missing.
579
+ expect(third.record.gate).toBe("approve-third-gate");
580
+ });
581
+
582
+ test("evaluateGate carries the rejection onto the gate check it returns, recorded but not pushed", async () => {
583
+ // A minimal port whose read side is inert and whose write side reports
584
+ // the exact rejection a stale `chant/lifecycle` lease produces — the
585
+ // shape `evaluateGate` has to propagate regardless of which port
586
+ // implementation is behind it.
587
+ const rejectingPort = {
588
+ async read() {
589
+ return { resolutions: [], pending: [] };
590
+ },
591
+ async appendPending(input: Parameters<GateLedgerPort["appendPending"]>[0]) {
592
+ return {
593
+ record: { version: 1 as const, kind: "pending" as const, ...input },
594
+ pushed: false,
595
+ pushWarning:
596
+ "chant/lifecycle remote branch has moved since this run started — another snapshot was pushed concurrently.",
597
+ };
598
+ },
599
+ };
600
+
601
+ const check = await evaluateGate(rejectingPort, {
602
+ op: "live-apply",
603
+ gate: "approve-live-apply",
604
+ now: "2026-09-09T05:00:00.000Z",
605
+ });
606
+ if (check.satisfied) throw new Error("unreachable — nothing resolved this gate");
607
+ expect(check.recorded).toBe(true);
608
+ expect(check.pushed).toBe(false);
609
+ expect(check.pushWarning).toContain("another snapshot was pushed concurrently");
610
+ // The pending fact is still the one that was recorded — gating is still
611
+ // correct, only silent about reaching the remote.
612
+ expect(check.pending.gate).toBe("approve-live-apply");
613
+ });
614
+ });