@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
@@ -8,6 +8,69 @@ import { getRuntime } from "../runtime-adapter";
8
8
 
9
9
  const STATE_BRANCH = "chant/lifecycle";
10
10
 
11
+ /**
12
+ * The identity chant's ledger commits fall back to when the checkout has none
13
+ * of its own (#2301).
14
+ *
15
+ * A CI checkout is the ordinary case here, not an exotic one: GitLab CI,
16
+ * GitHub Actions and Forgejo Actions all clone without `user.email` or
17
+ * `user.name`, and `git commit-tree` refuses outright rather than inventing
18
+ * one — `fatal: unable to auto-detect email address`. Every write in this
19
+ * module goes through `commit-tree`, so before #2301 a gate's pending fact,
20
+ * a run record, a snapshot and a release record all died the same way the
21
+ * moment they ran anywhere but a developer's laptop.
22
+ *
23
+ * This is a *fallback*, applied per-invocation via `git -c` and field by
24
+ * field: only the half the checkout cannot supply is filled in. A configured
25
+ * `user.name` or `user.email` — a developer's, or a CI job that sets one
26
+ * deliberately — is left exactly as it is and still authors these commits, and
27
+ * nothing is written to the repository's config either way. Override the
28
+ * fallback with `CHANT_LIFECYCLE_COMMITTER_NAME` / `_EMAIL` when a project
29
+ * wants its ledger commits attributed to a bot account it controls.
30
+ */
31
+ const LEDGER_COMMITTER_NAME = "chant";
32
+ const LEDGER_COMMITTER_EMAIL = "chant@localhost";
33
+
34
+ /**
35
+ * `git -c` arguments that give {@link writeBlobToPath}'s `commit-tree` a
36
+ * committer when — and only when — the checkout cannot supply one itself.
37
+ * Empty for any checkout that has an identity, which keeps every existing
38
+ * caller's commits byte-identical to what they were before #2301.
39
+ */
40
+ async function ledgerCommitIdentityArgs(cwd?: string): Promise<string[]> {
41
+ const rt = getRuntime();
42
+ const probe = await rt.spawn(["git", "var", "GIT_COMMITTER_IDENT"], { cwd, env: C_LOCALE_ENV });
43
+ if (probe.exitCode === 0) return [];
44
+
45
+ // Per-field, not all-or-nothing (#2309 review). `git var` fails if *either*
46
+ // half is missing, so filling in both would overwrite a configured
47
+ // `user.name` in the checkout that has a name but no email — which is not
48
+ // what "a configured identity is left exactly as it is" promises.
49
+ const args: string[] = [];
50
+ for (const [key, envVar, fallback] of [
51
+ ["user.name", "CHANT_LIFECYCLE_COMMITTER_NAME", LEDGER_COMMITTER_NAME],
52
+ ["user.email", "CHANT_LIFECYCLE_COMMITTER_EMAIL", LEDGER_COMMITTER_EMAIL],
53
+ ] as const) {
54
+ const configured = await rt.spawn(["git", "config", "--get", key], { cwd, env: C_LOCALE_ENV });
55
+ if (configured.exitCode === 0 && configured.stdout.trim() !== "") continue;
56
+ args.push("-c", `${key}=${process.env[envVar]?.trim() || fallback}`);
57
+ }
58
+ return args;
59
+ }
60
+
61
+ /**
62
+ * Prefix every failure out of the ledger write path with what was being
63
+ * written and where (#2301 deliverable 2). `git commit-tree failed: <stderr>`
64
+ * on its own does not say that the thing that failed was chant recording a
65
+ * fact on its own branch, and the caller that swallows it is one stack frame
66
+ * away from printing nothing at all.
67
+ */
68
+ function ledgerWriteError(stage: string, path: string, stderr: string): Error {
69
+ return new Error(
70
+ `cannot write ${path} on the ${STATE_BRANCH} branch: ${stage} failed: ${(stderr ?? "").trim()}`,
71
+ );
72
+ }
73
+
11
74
  /**
12
75
  * Write a blob to an arbitrary `<environment>/<filename>` path on the orphan
13
76
  * branch, preserving every other env/file entry already on the branch.
@@ -56,13 +119,42 @@ export async function writeBlobToPath(
56
119
  // annotation) and no shell-quoting dance around embedded single quotes.
57
120
  // Content-addressed and idempotent, so this stays outside the retry loop
58
121
  // below — nothing about a CAS conflict on the ref ever invalidates it.
122
+ //
123
+ // `path` is resolved first only so every failure below can name it (#2301).
124
+ const path = `${environment}/${filename}`;
125
+
59
126
  const blobResult = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd, stdin: content });
60
127
  if (blobResult.exitCode !== 0) {
61
- throw new Error(`git hash-object failed: ${blobResult.stderr}`);
128
+ throw ledgerWriteError("git hash-object", path, blobResult.stderr);
62
129
  }
63
130
  const blobSha = blobResult.stdout.trim();
64
131
 
65
- const path = `${environment}/${filename}`;
132
+ // Resolved once, outside the retry loop: whether the checkout can name a
133
+ // committer does not change between attempts (#2301).
134
+ const identityArgs = await ledgerCommitIdentityArgs(cwd);
135
+
136
+ // Creating the ledger branch is a different act from extending it, and only
137
+ // one of them is safe to do blind (#2309 review). With no local
138
+ // `chant/lifecycle`, everything below builds a tree from an empty read and
139
+ // commits it with no parent — a brand-new root commit that *replaces* the
140
+ // branch rather than appending to it, which `updateRefCAS` cannot catch
141
+ // because "the ref does not exist" is perfectly true locally.
142
+ //
143
+ // Until #2301 this was mostly hidden: `commit-tree` died for want of a
144
+ // committer before it could happen. Supplying an identity turned that loud
145
+ // failure into a silent branch fabrication on every writer that reaches
146
+ // here without a ledger — a run record, a snapshot, a release, a build
147
+ // manifest — and a fabricated branch is precisely the fork the guard above
148
+ // exists to refuse. So the guard runs here, at the one seam they all pass
149
+ // through, rather than at each of their call sites.
150
+ //
151
+ // Only when the branch is absent: an extend costs no fetch, and the check
152
+ // that matters is whether the history this is about to discard exists
153
+ // somewhere. `requireLifecycleLedger` allows `no-remote` and `absent`, so a
154
+ // genuinely first write still creates the branch.
155
+ if ((await getStateBranchTip(cwd)) === null) {
156
+ await requireLifecycleLedger({ ...(cwd ? { cwd } : {}) });
157
+ }
66
158
 
67
159
  // 2-5. Read tree, build the commit, and CAS-update the branch ref — retried
68
160
  // on a conflict (#1959 finding 1). `writeBlobToPath` had no retry of its
@@ -127,7 +219,7 @@ export async function writeBlobToPath(
127
219
 
128
220
  const envTreeResult = await rt.spawn(["git", "mktree"], { cwd, stdin: `${envEntries}\n` });
129
221
  if (envTreeResult.exitCode !== 0) {
130
- throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
222
+ throw ledgerWriteError("git mktree (env)", path, envTreeResult.stderr);
131
223
  }
132
224
  const envTreeSha = envTreeResult.stdout.trim();
133
225
 
@@ -150,7 +242,7 @@ export async function writeBlobToPath(
150
242
  stdin: `${rootEntries.join("\n")}\n`,
151
243
  });
152
244
  if (rootTreeResult.exitCode !== 0) {
153
- throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
245
+ throw ledgerWriteError("git mktree (root)", path, rootTreeResult.stderr);
154
246
  }
155
247
  const rootTreeSha = rootTreeResult.stdout.trim();
156
248
 
@@ -160,11 +252,11 @@ export async function writeBlobToPath(
160
252
  const parentRef = tip;
161
253
  const parentArgs = parentRef ? ["-p", parentRef] : [];
162
254
  const commitResult = await rt.spawn(
163
- ["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
255
+ ["git", ...identityArgs, "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
164
256
  { cwd },
165
257
  );
166
258
  if (commitResult.exitCode !== 0) {
167
- throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
259
+ throw ledgerWriteError("git commit-tree", path, commitResult.stderr);
168
260
  }
169
261
  const commitSha = commitResult.stdout.trim();
170
262
 
@@ -534,16 +626,225 @@ export async function pushLifecycle(opts?: { cwd?: string }): Promise<boolean> {
534
626
  * Fetch the state branch from remote.
535
627
  */
536
628
  export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean> {
629
+ return (await fetchLifecycleStatus(opts)).status === "fetched";
630
+ }
631
+
632
+ /**
633
+ * Why a fetch of the ledger branch did or did not produce local history
634
+ * (#2303).
635
+ *
636
+ * `fetchLifecycle`'s boolean cannot answer the question the gate actually
637
+ * needs answered. "No local `chant/lifecycle`" has two causes that a reader
638
+ * of an empty ledger cannot tell apart, and they call for opposite
639
+ * behaviour: on a project whose ledger has never been written, an empty
640
+ * ledger is the truth; in a CI checkout that fetched only the pipeline's own
641
+ * ref, an empty ledger is history the checkout simply cannot see. Reading the
642
+ * second as the first is what makes a second pending fact look correct, so
643
+ * these are separate outcomes:
644
+ *
645
+ * - `fetched` — the branch is now in this checkout (or already was, and is
646
+ * up to date). Whatever the ledger says is the whole truth.
647
+ * - `no-remote` — nothing to fetch from. A single-machine project; local is
648
+ * authoritative by construction, the same assumption `chant operator`'s
649
+ * lease already documents.
650
+ * - `absent` — the remote answered and has no `chant/lifecycle`. The ledger
651
+ * is genuinely empty; this is a project's first gate.
652
+ * - `diverged` — both sides have the branch and neither contains the other.
653
+ * Appending here would drop one side's facts.
654
+ * - `unreachable` — a remote is configured and did not answer (no network,
655
+ * no credentials, a shallow-clone refspec that cannot resolve). Whether
656
+ * anything is on the branch is unknown, which is exactly the state that
657
+ * must not be read as "nothing has been approved".
658
+ */
659
+ export type LifecycleFetchStatus =
660
+ | { status: "fetched" }
661
+ | { status: "no-remote" }
662
+ | { status: "absent"; remote: string }
663
+ | { status: "ahead"; remote: string }
664
+ | { status: "diverged"; remote: string; stderr: string }
665
+ | { status: "unreachable"; remote: string; stderr: string };
666
+
667
+ /**
668
+ * git's wording when the remote answered and simply does not carry the ref.
669
+ *
670
+ * Matched against output forced to `LC_ALL=C` ({@link C_LOCALE_ENV}) — both of
671
+ * these strings are in git's translation catalogs, so on a distro git built
672
+ * with NLS and a non-C `LANG` an unpinned locale would fail to match and send
673
+ * a project's genuine first gate down the `unreachable` path, refusing it.
674
+ */
675
+ const NO_SUCH_REMOTE_REF_RE = /couldn't find remote ref|no such ref was fetched/i;
676
+ /** git's wording when the refspec would not fast-forward the local branch. Same locale caveat. */
677
+ const NON_FAST_FORWARD_RE = /non-fast-forward|rejected/i;
678
+
679
+ /**
680
+ * `git` invocations here are parsed by their output, so they are pinned to the
681
+ * C locale. `LC_ALL` alone is enough — it outranks `LC_MESSAGES` and `LANG` —
682
+ * but `LANGUAGE` is GNU gettext's own override and outranks all of them, so it
683
+ * is cleared too.
684
+ */
685
+ const C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
686
+
687
+ /**
688
+ * The remote the ledger lives on.
689
+ *
690
+ * `origin` when it exists, rather than whichever name sorts first — a repo
691
+ * with a `backup` remote alongside `origin` would otherwise fetch the ledger
692
+ * from `backup`. A non-zero exit is *not* read as "no remote": a git that
693
+ * failed to answer is not evidence that a project is single-machine, and
694
+ * treating it as one is what lets an unreadable ledger look empty.
695
+ */
696
+ async function ledgerRemote(cwd?: string): Promise<{ remote: string } | { failed: true; stderr: string } | null> {
537
697
  const rt = getRuntime();
538
- const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
539
- if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
698
+ const result = await rt.spawn(["git", "remote"], { cwd, env: C_LOCALE_ENV });
699
+ if (result.exitCode !== 0) return { failed: true, stderr: (result.stderr ?? "").trim() };
700
+ const names = result.stdout.trim().split("\n").map((n) => n.trim()).filter(Boolean);
701
+ if (names.length === 0) return null;
702
+ return { remote: names.includes("origin") ? "origin" : names[0] };
703
+ }
704
+
705
+ /** A scratch ref this module owns, used to resolve the remote tip without touching the ledger branch. */
706
+ const PROBE_REF = "refs/chant/lifecycle-probe";
707
+
708
+ /**
709
+ * Fetch `chant/lifecycle` and say what happened, in the terms
710
+ * {@link LifecycleFetchStatus} defines.
711
+ *
712
+ * The refspec is deliberately un-forced (`chant/lifecycle:chant/lifecycle`,
713
+ * no leading `+`), so a local branch holding facts the remote does not have is
714
+ * never rewound by a read.
715
+ *
716
+ * git reports "non-fast-forward" for two states that could not be more
717
+ * different, which is why the rejection is not taken at face value (#2309
718
+ * review): a local branch *ahead* of the remote — the ordinary state right
719
+ * after an append whose push has not landed yet — and a local branch that has
720
+ * genuinely *forked*. Resolving the remote tip into a scratch ref and asking
721
+ * whether it is an ancestor of the local tip separates them. Reading a fork as
722
+ * the ledger is what destroys approvals; refusing an append that is merely
723
+ * ahead would break the retry that is supposed to repair a failed push.
724
+ */
725
+ export async function fetchLifecycleStatus(opts?: { cwd?: string }): Promise<LifecycleFetchStatus> {
726
+ const rt = getRuntime();
727
+ const cwd = opts?.cwd;
728
+ const found = await ledgerRemote(cwd);
729
+ if (found === null) return { status: "no-remote" };
730
+ if ("failed" in found) {
731
+ return { status: "unreachable", remote: "(unknown)", stderr: found.stderr };
732
+ }
733
+ const { remote } = found;
540
734
 
541
- const remote = remoteResult.stdout.trim().split("\n")[0];
542
735
  const fetchResult = await rt.spawn(
543
736
  ["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
544
- { cwd: opts?.cwd },
737
+ { cwd, env: C_LOCALE_ENV },
545
738
  );
546
- return fetchResult.exitCode === 0;
739
+ if (fetchResult.exitCode === 0) return { status: "fetched" };
740
+
741
+ const stderr = (fetchResult.stderr ?? "").trim();
742
+ if (NO_SUCH_REMOTE_REF_RE.test(stderr)) return { status: "absent", remote };
743
+ if (!NON_FAST_FORWARD_RE.test(stderr)) return { status: "unreachable", remote, stderr };
744
+
745
+ // Rejected. Resolve the remote tip into our own scratch ref (forced: the ref
746
+ // is this module's, and nothing reads it but the next two lines) and ask
747
+ // whether the local branch already contains it.
748
+ const probe = await rt.spawn(
749
+ ["git", "fetch", "--force", remote, `${STATE_BRANCH}:${PROBE_REF}`],
750
+ { cwd, env: C_LOCALE_ENV },
751
+ );
752
+ if (probe.exitCode !== 0) {
753
+ return { status: "unreachable", remote, stderr: (probe.stderr ?? "").trim() || stderr };
754
+ }
755
+ const contains = await rt.spawn(
756
+ ["git", "merge-base", "--is-ancestor", PROBE_REF, `refs/heads/${STATE_BRANCH}`],
757
+ { cwd, env: C_LOCALE_ENV },
758
+ );
759
+ await rt.spawn(["git", "update-ref", "-d", PROBE_REF], { cwd, env: C_LOCALE_ENV });
760
+ if (contains.exitCode === 0) return { status: "ahead", remote };
761
+ return { status: "diverged", remote, stderr };
762
+ }
763
+
764
+ /**
765
+ * Thrown when the ledger branch cannot be read, rather than being reported as
766
+ * empty (#2303).
767
+ */
768
+ export class LifecycleLedgerUnreadableError extends Error {
769
+ constructor(
770
+ public readonly reason: "unreachable" | "diverged",
771
+ public readonly remote: string,
772
+ public readonly stderr: string,
773
+ detail: string,
774
+ ) {
775
+ super(
776
+ `the ${STATE_BRANCH} ledger branch ${detail}, so what is on it cannot be read. ` +
777
+ `Refusing rather than treating an unreadable ledger as an empty one.` +
778
+ (reason === "diverged"
779
+ ? `\n Two histories claim to be the ledger; appending to either one drops the other's facts. ` +
780
+ `Inspect both before doing anything else:\n` +
781
+ ` git fetch ${remote} ${STATE_BRANCH}:refs/chant/remote-ledger\n` +
782
+ ` git log --oneline ${STATE_BRANCH} refs/chant/remote-ledger`
783
+ : `\n Fetch it and retry: git fetch ${remote} ${STATE_BRANCH}:${STATE_BRANCH}`) +
784
+ (stderr ? `\n git said: ${stderr.split("\n")[0]}` : ""),
785
+ );
786
+ this.name = "LifecycleLedgerUnreadableError";
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Bring the ledger branch into this checkout before reading or appending to
792
+ * it, and refuse when it cannot be (#2303).
793
+ *
794
+ * The gate's read is the one that has to be right: `evaluateGate` decides
795
+ * whether to walk through or to record a *new* pending fact purely from what
796
+ * the ledger says, so a read that reports history it cannot see as an empty
797
+ * ledger makes the second run of an already-approved commit record a second
798
+ * pending fact and gate again. That is what a GitLab CI retry did on
799
+ * INTENTIUS/choudoufu#1026 — two pending records for one commit, with
800
+ * different expiries — because a GitLab checkout fetches the pipeline's own
801
+ * ref at depth 20 and nothing else. `GIT_DEPTH: 0` does not help: GitLab
802
+ * fetches refspecs, not all branches.
803
+ *
804
+ * There was a separate, laxer guard for the read until #2309's review, on the
805
+ * reasoning that a checkout which already has the branch "has real history to
806
+ * read". That reasoning is only sound when the local branch *is* the remote's
807
+ * history, and it let the worst case through: on a fork, the gate read a
808
+ * ledger missing every remote approval, recorded a fresh pending fact, and
809
+ * pushed. The push was not protected either — a rejected non-fast-forward
810
+ * fetch still updates `refs/remotes/<remote>/chant/lifecycle` opportunistically
811
+ * in a full clone, so `pushLifecycle`'s `--force-with-lease` matched the tip
812
+ * it had just learned and forced. Verified on git 2.50.1: a remote holding a
813
+ * pending fact and an approval ended up holding neither.
814
+ *
815
+ * So there is one rule now, for readers and writers alike:
816
+ *
817
+ * - `diverged` always refuses. A fork is not the ledger, and nothing good
818
+ * comes of reading one as if it were.
819
+ * - `unreachable` refuses only when the checkout has no local history at all,
820
+ * which is the case that would otherwise read as an empty ledger. With
821
+ * local history and no answer from the remote, the read proceeds and the
822
+ * push's lease fails safely on its own — refusing there would strand every
823
+ * offline run.
824
+ * - `fetched`, `no-remote`, `absent` and `ahead` all proceed. `ahead` is the
825
+ * ordinary state right after an append whose push has not landed, and is
826
+ * exactly what a retry needs to be able to repair.
827
+ */
828
+ export async function requireLifecycleLedger(opts?: { cwd?: string }): Promise<void> {
829
+ const hadLocal = (await getStateBranchTip(opts?.cwd)) !== null;
830
+ const result = await fetchLifecycleStatus(opts);
831
+
832
+ if (result.status === "diverged") {
833
+ throw new LifecycleLedgerUnreadableError(
834
+ "diverged",
835
+ result.remote,
836
+ result.stderr,
837
+ `has diverged from the copy on "${result.remote}" — neither contains the other`,
838
+ );
839
+ }
840
+ if (result.status === "unreachable" && !hadLocal) {
841
+ throw new LifecycleLedgerUnreadableError(
842
+ "unreachable",
843
+ result.remote,
844
+ result.stderr,
845
+ `is not in this checkout and could not be fetched from "${result.remote}"`,
846
+ );
847
+ }
547
848
  }
548
849
 
549
850
  // ── Generic ref CAS (#1485) ──────────────────────────────────────────────────
@@ -23,3 +23,4 @@ export * from "./converge-ledger";
23
23
  export * from "./run-ledger";
24
24
  export * from "./scenario";
25
25
  export * from "./scenario-eval";
26
+ export * from "./plan-digest";
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { computePlanDigest, isPlanDigest, describePlanDigest } from "./plan-digest";
3
+
4
+ describe("computePlanDigest", () => {
5
+ test("the same change set digests the same, whatever order its keys arrived in", () => {
6
+ const a = computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a", actions: ["create"] });
7
+ const b = computePlanDigest("terraform-plan", { actions: ["create"], address: "aws_s3_bucket.a" });
8
+ expect(a).toBe(b);
9
+ });
10
+
11
+ test("a changed address changes it", () => {
12
+ expect(computePlanDigest("terraform-plan", { address: "aws_s3_bucket.a" })).not.toBe(
13
+ computePlanDigest("terraform-plan", { address: "aws_s3_bucket.b" }),
14
+ );
15
+ });
16
+
17
+ // The kind is hashed alongside the subject so a gate bound to a terraform
18
+ // plan cannot be satisfied by another kind of plan that happened to
19
+ // serialize identically.
20
+ test("two kinds of plan over identical data do not collide", () => {
21
+ expect(computePlanDigest("terraform-plan", { x: 1 })).not.toBe(
22
+ computePlanDigest("lifecycle-diff", { x: 1 }),
23
+ );
24
+ });
25
+
26
+ test("it is a sha256 digest, in the shape isPlanDigest accepts", () => {
27
+ const digest = computePlanDigest("terraform-plan", {});
28
+ expect(digest).toMatch(/^sha256:[0-9a-f]{64}$/);
29
+ expect(isPlanDigest(digest)).toBe(true);
30
+ });
31
+ });
32
+
33
+ describe("isPlanDigest", () => {
34
+ test("refuses everything a copy-paste or a path could be", () => {
35
+ expect(isPlanDigest("chant.tfplan")).toBe(false);
36
+ expect(isPlanDigest(`sha256:${"a".repeat(63)}`)).toBe(false);
37
+ expect(isPlanDigest("a".repeat(64))).toBe(false);
38
+ expect(isPlanDigest(`sha256:${"A".repeat(64)}`)).toBe(false);
39
+ expect(isPlanDigest(undefined)).toBe(false);
40
+ expect(isPlanDigest(12)).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("describePlanDigest", () => {
45
+ test("an absent digest reads as the pre-#2300 record it is, never as undefined", () => {
46
+ expect(describePlanDigest(undefined)).toBe("(none — recorded before plan-bound gates)");
47
+ expect(describePlanDigest("sha256:abc")).toBe("sha256:abc");
48
+ });
49
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Plan identity for a gate (#2300, measured on INTENTIUS/choudoufu#1026).
3
+ *
4
+ * A gate resolution used to carry the op, the gate, the approver and a
5
+ * timestamp, and nothing about what was approved. Approve, edit the root,
6
+ * re-run, and the second run re-planned and applied: the resolution had
7
+ * authorised the *next run* of that op rather than the plan the approver
8
+ * read. This module is the missing half — one string that identifies a plan,
9
+ * written onto the pending fact the run records, onto the resolution `chant
10
+ * approve` appends, and compared by {@link latestResolutionForPlan} when a
11
+ * later run decides the gate.
12
+ *
13
+ * ## What a digest covers
14
+ *
15
+ * The change set, and only the change set: what a run proposes to create,
16
+ * update, replace or destroy, at which addresses, with which values. Two
17
+ * plans share a digest exactly when applying either one would do the same
18
+ * thing to the estate.
19
+ *
20
+ * ## What it deliberately does not cover
21
+ *
22
+ * - **When the plan was taken.** A plan file's own `timestamp`, and the
23
+ * resolution's. Re-planning an unchanged root a minute later must produce
24
+ * the same digest, or every approval would expire on the clock rather than
25
+ * on the content.
26
+ * - **Which run took it.** `runId`, the CI job number, the workflow attempt.
27
+ * Approving a plan and re-running the workflow is the whole loop; binding
28
+ * the run id would make the approval unusable by the run that consumes it.
29
+ * - **The tool that produced it.** The terraform/choudoufu version, the plan
30
+ * file's binary bytes and its path on disk. The digest is taken over the
31
+ * `show -json` rendering rather than the file, so a plan-format bump does
32
+ * not read as a changed plan.
33
+ * - **Who approved it, or where.** `resolvedBy`, `note`, `url` — those
34
+ * describe the approval, not the plan.
35
+ *
36
+ * The identity is therefore a claim about consequence, not about provenance.
37
+ * That is the claim an approver is actually making.
38
+ */
39
+ import { sortedJsonReplacer } from "../utils";
40
+ import { getRuntime } from "../runtime-adapter";
41
+
42
+ /** The hash a plan digest is taken with, and the prefix every digest carries. */
43
+ export const PLAN_DIGEST_ALGORITHM = "sha256";
44
+
45
+ /** Shape of a well-formed digest: `sha256:` and 64 lowercase hex characters. */
46
+ const PLAN_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
47
+
48
+ /**
49
+ * Hash a plan's change set into a stable identity.
50
+ *
51
+ * `kind` names the shape `subject` is in (`"terraform-plan"`,
52
+ * `"lifecycle-diff"`), and is hashed alongside it so two different kinds of
53
+ * plan can never collide into the same digest by coincidence — a gate bound
54
+ * to a terraform plan must not be satisfiable by a lifecycle diff that
55
+ * happened to serialize identically.
56
+ *
57
+ * `subject` is canonicalised by {@link sortedJsonReplacer}, so object key
58
+ * order — which neither terraform's JSON writer nor `JSON.parse` guarantees
59
+ * across versions — does not change the answer. It is the caller's job to
60
+ * hand in a projection that already excludes the volatile fields this
61
+ * module's doc comment lists.
62
+ */
63
+ export function computePlanDigest(kind: string, subject: unknown): string {
64
+ const canonical = JSON.stringify({ kind, subject }, sortedJsonReplacer);
65
+ return `${PLAN_DIGEST_ALGORITHM}:${getRuntime().hash(canonical)}`;
66
+ }
67
+
68
+ /**
69
+ * Whether `raw` is a digest this code produced. Used at the `chant approve
70
+ * --plan` boundary, so a typo, a truncated copy-paste or a plan *file* path
71
+ * is refused before it is written into an immutable resolution that would
72
+ * then never match anything.
73
+ */
74
+ export function isPlanDigest(raw: unknown): raw is string {
75
+ return typeof raw === "string" && PLAN_DIGEST_PATTERN.test(raw);
76
+ }
77
+
78
+ /**
79
+ * A digest as it reads in a message, and the one place that decides how an
80
+ * absent one reads. Records written before #2300 carry no digest at all, and
81
+ * "(none — recorded before plan-bound gates)" is what a refusal has to say
82
+ * about them instead of printing `undefined`.
83
+ */
84
+ export function describePlanDigest(digest: string | undefined): string {
85
+ return digest ?? "(none — recorded before plan-bound gates)";
86
+ }
@@ -105,6 +105,7 @@ export function buildRunRecord(
105
105
  ...(record.outcome ? { outcome: record.outcome } : {}),
106
106
  ...(record.approval ? { approval: record.approval } : {}),
107
107
  ...(record.error !== undefined ? { error: record.error } : {}),
108
+ ...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
108
109
  });
109
110
  if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
110
111
  }