@intentius/chant 0.62.0 → 0.64.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 (115) hide show
  1. package/dist/cli/handlers/operator.d.ts +13 -0
  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/discovery/fold-import.d.ts +12 -0
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/fold/fold.d.ts +10 -0
  15. package/dist/fold/fold.d.ts.map +1 -1
  16. package/dist/fold/subset.d.ts +36 -2
  17. package/dist/fold/subset.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/lifecycle/gate-ledger.d.ts +61 -0
  21. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  22. package/dist/lifecycle/index.d.ts +1 -0
  23. package/dist/lifecycle/index.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-digest.d.ts +33 -0
  25. package/dist/lifecycle/plan-digest.d.ts.map +1 -0
  26. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  27. package/dist/op/activities/lexicon-upgrade.d.ts +14 -2
  28. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  29. package/dist/op/activities/lifecycle.d.ts +27 -0
  30. package/dist/op/activities/lifecycle.d.ts.map +1 -1
  31. package/dist/op/activities/reconcile.d.ts +196 -27
  32. package/dist/op/activities/reconcile.d.ts.map +1 -1
  33. package/dist/op/builders.d.ts +6 -0
  34. package/dist/op/builders.d.ts.map +1 -1
  35. package/dist/op/composites/apply-op.d.ts +6 -0
  36. package/dist/op/composites/apply-op.d.ts.map +1 -1
  37. package/dist/op/composites/reconcile-op.d.ts.map +1 -1
  38. package/dist/op/gate-summary.d.ts +16 -0
  39. package/dist/op/gate-summary.d.ts.map +1 -1
  40. package/dist/op/gate.d.ts +104 -13
  41. package/dist/op/gate.d.ts.map +1 -1
  42. package/dist/op/index.d.ts +3 -2
  43. package/dist/op/index.d.ts.map +1 -1
  44. package/dist/op/local-executor.d.ts +17 -0
  45. package/dist/op/local-executor.d.ts.map +1 -1
  46. package/dist/op/local-output.d.ts.map +1 -1
  47. package/dist/op/op-ir.d.ts +8 -1
  48. package/dist/op/op-ir.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/types.d.ts +19 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/terraform/__fixtures__/build-graph.d.ts +8 -0
  54. package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -1
  55. package/dist/terraform/graph.d.ts +18 -2
  56. package/dist/terraform/graph.d.ts.map +1 -1
  57. package/dist/terraform/parse.d.ts.map +1 -1
  58. package/dist/terraform/types.d.ts +7 -0
  59. package/dist/terraform/types.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/cli/handlers/operator.test.ts +130 -0
  62. package/src/cli/handlers/operator.ts +56 -2
  63. package/src/cli/handlers/run.ts +19 -0
  64. package/src/cli/main.ts +2 -0
  65. package/src/cli/registry.ts +2 -0
  66. package/src/components/cli-support.ts +29 -4
  67. package/src/components/driver-output.ts +10 -0
  68. package/src/components/driver.test.ts +31 -0
  69. package/src/components/driver.ts +54 -8
  70. package/src/discovery/fold-import.test.ts +55 -0
  71. package/src/discovery/fold-import.ts +12 -0
  72. package/src/fold/fold.test.ts +152 -0
  73. package/src/fold/fold.ts +112 -2
  74. package/src/fold/subset-doc-parity.test.ts +35 -1
  75. package/src/fold/subset-public-export.test.ts +27 -0
  76. package/src/fold/subset.ts +36 -2
  77. package/src/index.ts +5 -0
  78. package/src/lifecycle/gate-ledger.test.ts +133 -1
  79. package/src/lifecycle/gate-ledger.ts +108 -0
  80. package/src/lifecycle/index.ts +1 -0
  81. package/src/lifecycle/plan-digest.test.ts +49 -0
  82. package/src/lifecycle/plan-digest.ts +86 -0
  83. package/src/lifecycle/run-ledger.ts +1 -0
  84. package/src/op/activities/lexicon-upgrade.test.ts +24 -12
  85. package/src/op/activities/lexicon-upgrade.ts +19 -3
  86. package/src/op/activities/lifecycle.ts +51 -2
  87. package/src/op/activities/reconcile.test.ts +512 -26
  88. package/src/op/activities/reconcile.ts +307 -34
  89. package/src/op/builders.ts +7 -1
  90. package/src/op/composites/apply-op.ts +16 -0
  91. package/src/op/composites/composites.test.ts +15 -2
  92. package/src/op/composites/reconcile-op.test.ts +18 -0
  93. package/src/op/composites/reconcile-op.ts +7 -1
  94. package/src/op/gate-summary.test.ts +33 -0
  95. package/src/op/gate-summary.ts +31 -0
  96. package/src/op/gate.test.ts +111 -1
  97. package/src/op/gate.ts +181 -23
  98. package/src/op/index.ts +5 -2
  99. package/src/op/local-executor.test.ts +226 -3
  100. package/src/op/local-executor.ts +61 -12
  101. package/src/op/local-output.test.ts +38 -0
  102. package/src/op/local-output.ts +24 -1
  103. package/src/op/op-ir.test.ts +22 -0
  104. package/src/op/op-ir.ts +9 -0
  105. package/src/op/runtime.ts +2 -0
  106. package/src/op/types.ts +19 -0
  107. package/src/terraform/__fixtures__/build-graph.ts +42 -0
  108. package/src/terraform/__fixtures__/carve-locals-data.test.ts +138 -0
  109. package/src/terraform/__fixtures__/depth-estate/main.tf +141 -0
  110. package/src/terraform/__fixtures__/depth-estate/terraform.tfstate +17 -0
  111. package/src/terraform/__fixtures__/depth-estate.test.ts +162 -0
  112. package/src/terraform/graph.test.ts +148 -1
  113. package/src/terraform/graph.ts +144 -6
  114. package/src/terraform/parse.ts +4 -1
  115. package/src/terraform/types.ts +7 -0
@@ -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
  }
@@ -257,9 +257,9 @@ describe("lexiconUpgrade issue mode", () => {
257
257
 
258
258
  test("on a GitHub Actions job (GITHUB_REPOSITORY set), opens the sticky issue via gh api (#2297)", async () => {
259
259
  const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
260
- const calls: string[] = [];
261
- const gh = vi.fn(async (cmd: string) => {
262
- calls.push(cmd);
260
+ const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
261
+ const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
262
+ calls.push({ cmd, env: opts?.env });
263
263
  if (cmd.includes("--paginate")) return { stdout: "", stderr: "" }; // no owned issue yet
264
264
  if (cmd.includes("--method POST")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
265
265
  return { stdout: "", stderr: "" };
@@ -267,6 +267,8 @@ describe("lexiconUpgrade issue mode", () => {
267
267
 
268
268
  vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
269
269
  vi.stubEnv("GITHUB_API_URL", "");
270
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
271
+ vi.stubEnv("GH_TOKEN", "ghs-workflow");
270
272
  try {
271
273
  const r = await lexiconUpgrade({
272
274
  lexicon: "gcp",
@@ -276,10 +278,14 @@ describe("lexiconUpgrade issue mode", () => {
276
278
  });
277
279
 
278
280
  expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
279
- expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
280
- const post = calls.find((c) => c.includes("--method POST"));
281
- expect(post).toContain("https://api.github.com/repos/acme/infra/issues");
282
- expect(post).toContain("<!-- chant-lexicon-upgrade:gcp -->");
281
+ expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
282
+ const post = calls.find((c) => c.cmd.includes("--method POST"));
283
+ expect(post?.cmd).toContain("https://api.github.com/repos/acme/infra/issues");
284
+ expect(post?.cmd).toContain("<!-- chant-lexicon-upgrade:gcp -->");
285
+ // #2320: the shared `postOrUpdateGithubIssue` resolves the credential
286
+ // and hands it to the runner, so this caller gets the same forwarding
287
+ // reconcilePr's issue mode does rather than leaving `gh` to guess.
288
+ for (const call of calls) expect(call.env?.GH_TOKEN).toBe("ghs-workflow");
283
289
  } finally {
284
290
  vi.unstubAllEnvs();
285
291
  }
@@ -287,9 +293,9 @@ describe("lexiconUpgrade issue mode", () => {
287
293
 
288
294
  test("a re-run on a GitHub Actions job PATCHes the issue it already owns (#2297)", async () => {
289
295
  const checkPinned: CheckPinnedFn = vi.fn(async () => pinnedResult());
290
- const calls: string[] = [];
291
- const gh = vi.fn(async (cmd: string) => {
292
- calls.push(cmd);
296
+ const calls: Array<{ cmd: string; env?: NodeJS.ProcessEnv }> = [];
297
+ const gh = vi.fn(async (cmd: string, opts?: { env?: NodeJS.ProcessEnv }) => {
298
+ calls.push({ cmd, env: opts?.env });
293
299
  if (cmd.includes("--paginate")) return { stdout: "11\n", stderr: "" }; // marker search found issue 11
294
300
  if (cmd.includes("--method PATCH")) return { stdout: "https://github.com/acme/infra/issues/11\n", stderr: "" };
295
301
  return { stdout: "", stderr: "" };
@@ -297,6 +303,10 @@ describe("lexiconUpgrade issue mode", () => {
297
303
 
298
304
  vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
299
305
  vi.stubEnv("GITHUB_API_URL", "");
306
+ // The cross-instance case (#2320): a Forgejo token that must outrank the
307
+ // job's own `github.token`, on the path that used to forward neither.
308
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
309
+ vi.stubEnv("GH_TOKEN", "ghs-this-instance-only");
300
310
  try {
301
311
  const r = await lexiconUpgrade({
302
312
  lexicon: "gcp",
@@ -306,8 +316,10 @@ describe("lexiconUpgrade issue mode", () => {
306
316
  });
307
317
 
308
318
  expect(r.issueUrl).toBe("https://github.com/acme/infra/issues/11");
309
- expect(calls.some((c) => c.includes("--method POST"))).toBe(false);
310
- expect(calls.some((c) => c.includes("gh issue create"))).toBe(false);
319
+ expect(calls.some((c) => c.cmd.includes("--method POST"))).toBe(false);
320
+ expect(calls.some((c) => c.cmd.includes("gh issue create"))).toBe(false);
321
+ const patch = calls.find((c) => c.cmd.includes("--method PATCH"));
322
+ expect(patch?.env?.GH_TOKEN).toBe("forgejo-cross-instance");
311
323
  } finally {
312
324
  vi.unstubAllEnvs();
313
325
  }
@@ -120,8 +120,21 @@ export type CheckRollingFn = (opts: {
120
120
  verbose?: boolean;
121
121
  }) => Promise<RollingUpgradeResult>;
122
122
 
123
- /** Minimal shell-exec interface for gh/git invocations. */
124
- export type GhRunner = (cmd: string) => Promise<{ stdout: string; stderr: string }>;
123
+ /**
124
+ * Minimal shell-exec interface for gh/git invocations.
125
+ *
126
+ * `opts` carries the environment `postOrUpdateGithubIssue` resolves the issue
127
+ * credential into (chant #2320) — the same `GhExec` shape reconcile.ts
128
+ * declares, so the two stay one signature and this can keep being passed
129
+ * straight through. Every other call site here forwards nothing and passes
130
+ * nothing. A mock runner is free to ignore the argument; the default one
131
+ * hands it to `execAsync`, which is what makes CHANT_FORGEJO_TOKEN actually
132
+ * reach `gh` on a cross-instance Forgejo run.
133
+ */
134
+ export type GhRunner = (
135
+ cmd: string,
136
+ opts?: { env?: NodeJS.ProcessEnv },
137
+ ) => Promise<{ stdout: string; stderr: string }>;
125
138
 
126
139
  /** Applies a pinned version bump permanently (no revert). Async (core loads the lexicon's pin descriptor); a sync mock is also accepted. */
127
140
  export type ApplyBumpFn = (
@@ -283,7 +296,7 @@ function shellQuote(s: string): string {
283
296
  return `'${s.replace(/'/g, "'\\''")}'`;
284
297
  }
285
298
 
286
- const defaultGh: GhRunner = async (cmd) => execAsync(cmd);
299
+ const defaultGh: GhRunner = async (cmd, opts) => execAsync(cmd, { ...opts });
287
300
 
288
301
  /**
289
302
  * The hidden marker that makes this lexicon's upgrade-status issue findable
@@ -320,6 +333,9 @@ async function postLexiconIssue(
320
333
  const marker = lexiconUpgradeIssueMarker(lexicon);
321
334
  const repo = process.env.GITHUB_REPOSITORY;
322
335
  if (repo) {
336
+ // `postOrUpdateGithubIssue` resolves the token itself and hands it back
337
+ // through `gh`'s second argument (#2320), so this runner has to forward
338
+ // what it is given rather than dropping it — see `GhRunner`.
323
339
  return postOrUpdateGithubIssue(repo, marker, title, body, gh);
324
340
  }
325
341
  const { stdout } = await gh(
@@ -1,5 +1,6 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { computePlanDigest } from "../../lifecycle/plan-digest";
3
4
 
4
5
  const execAsync = promisify(exec);
5
6
 
@@ -39,6 +40,40 @@ export interface LifecycleDiffResult {
39
40
  * `chant lifecycle diff --live`).
40
41
  */
41
42
  drifted: boolean;
43
+ /**
44
+ * This diff's identity (#2300) — the change set `chant lifecycle diff`
45
+ * reported, hashed. `ApplyOp` hands it to its gate step's `plan`, so an
46
+ * approval binds to the change set the approver read rather than to the
47
+ * next run of the Op.
48
+ *
49
+ * Computed over {@link normalizeDiffChangeSet}'s canonical form of
50
+ * `output`, so incidental whitespace does not read as a changed plan, while
51
+ * any added, removed or reworded row does.
52
+ */
53
+ planDigest: string;
54
+ }
55
+
56
+ /**
57
+ * The change set half of a `chant lifecycle diff` render, canonicalised for
58
+ * digesting (#2300).
59
+ *
60
+ * The diff render is line-oriented: section headers and the resource rows
61
+ * under them. Two runs over an unchanged environment print the same lines, so
62
+ * the lines are the change set. Normalising is deliberately minimal — CRLF to
63
+ * LF, trailing whitespace off each line, blank lines dropped — because
64
+ * anything more aggressive would start discarding rows, and a digest that
65
+ * discards rows is a digest that approves changes nobody saw.
66
+ *
67
+ * Unlike a terraform plan there is no timestamp to strip: the diff render
68
+ * carries none. If one is ever added it has to be dropped here, or every
69
+ * approval would be stale the moment it was written.
70
+ */
71
+ export function normalizeDiffChangeSet(output: string): string[] {
72
+ return output
73
+ .replace(/\r\n/g, "\n")
74
+ .split("\n")
75
+ .map((line) => line.replace(/\s+$/, ""))
76
+ .filter((line) => line !== "");
42
77
  }
43
78
 
44
79
  /**
@@ -59,6 +94,20 @@ function detectDrift(output: string): boolean {
59
94
  return DRIFT_HEADERS.some((h) => output.includes(`${h} (`) || output.includes(`\n${h}`));
60
95
  }
61
96
 
97
+ /**
98
+ * The identity of one `chant lifecycle diff` result (#2300). The environment
99
+ * and the `--live` flag are hashed alongside the change set because they say
100
+ * what the change set is *of*: the same rows against `staging` are not an
101
+ * approval to apply against `prod`.
102
+ */
103
+ function lifecycleDiffDigest(args: LifecycleDiffArgs, output: string): string {
104
+ return computePlanDigest("lifecycle-diff", {
105
+ env: args.env,
106
+ live: args.live === true,
107
+ changeSet: normalizeDiffChangeSet(output),
108
+ });
109
+ }
110
+
62
111
  /**
63
112
  * Run `chant lifecycle diff <env>` and return the output + structured drift
64
113
  * flag. Read-only; intended for use inside watch/observation Ops.
@@ -75,11 +124,11 @@ export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSigna
75
124
  const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
76
125
  const output = `${stdout}${stderr}`.trim();
77
126
  if (output) console.log(output);
78
- return { output, exitCode: 0, drifted: detectDrift(output) };
127
+ return { output, exitCode: 0, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
79
128
  } catch (err) {
80
129
  const e = err as { code?: number; stdout?: string; stderr?: string };
81
130
  const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
82
131
  if (output) console.error(output);
83
- return { output, exitCode: e.code ?? 1, drifted: detectDrift(output) };
132
+ return { output, exitCode: e.code ?? 1, drifted: detectDrift(output), planDigest: lifecycleDiffDigest(args, output) };
84
133
  }
85
134
  }