@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
@@ -107,16 +107,22 @@ export function activity(
107
107
  * that reaches this step with no resolution newer than its pending fact
108
108
  * records the pending fact and ends `gated`. `chant approve <op> <gate>`
109
109
  * writes the resolution, and the next run walks through carrying the approver.
110
+ *
111
+ * Pass `plan` to bind the approval to a specific plan (#2300) — normally the
112
+ * Plan phase's own digest, `plan.out.planDigest`. Then a resolution counts
113
+ * only for that plan, and a run whose fresh plan differs is refused by name
114
+ * instead of applying a change nobody approved. See {@link GateStep.plan}.
110
115
  */
111
116
  export function gate(
112
117
  name: string,
113
- opts?: { timeout?: string; description?: string },
118
+ opts?: { timeout?: string; description?: string; plan?: GateStep["plan"] },
114
119
  ): GateStep {
115
120
  return {
116
121
  kind: "gate",
117
122
  gate: name,
118
123
  ...(opts?.timeout ? { timeout: opts.timeout } : {}),
119
124
  ...(opts?.description ? { description: opts.description } : {}),
125
+ ...(opts?.plan !== undefined ? { plan: opts.plan } : {}),
120
126
  };
121
127
  }
122
128
 
@@ -19,6 +19,12 @@
19
19
  * approve <op> <gate>` line to run. Approve, run again, and the same Op
20
20
  * converges — no durable workflow engine is involved.
21
21
  *
22
+ * The gate binds the plan (#2300). The Plan phase's `lifecycleDiff` publishes
23
+ * a digest of its change set, the gate step references it, and a resolution
24
+ * counts only for that digest. Approve, change the declared source, re-run,
25
+ * and the run stops again with both digests named rather than applying a
26
+ * change set the approver never saw.
27
+ *
22
28
  * @example
23
29
  * ```typescript
24
30
  * // additive, local executor
@@ -38,6 +44,7 @@
38
44
  */
39
45
 
40
46
  import { Op, phase, activity, gate } from "../builders";
47
+ import { stepOutput } from "../step-output-ref";
41
48
  import { gateName } from "../gate-name";
42
49
  import type { OpResource } from "../resource";
43
50
  import { defaultOutput, hasNativeRollback, type ApplyTarget, type DeleteMode } from "../activities/apply";
@@ -129,6 +136,10 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
129
136
  phase("Plan", [
130
137
  {
131
138
  kind: "activity" as const,
139
+ // #2300: the gate below references this step's digest, and a
140
+ // reference needs an id. `plan` rather than `diff`, because what the
141
+ // Approve phase binds to is the plan this step produced.
142
+ id: "plan",
132
143
  fn: "lifecycleDiff",
133
144
  args: { env: config.env, live: true },
134
145
  outcomeAttribute: { name: "Drift", from: "drifted" },
@@ -141,6 +152,11 @@ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
141
152
  phase("Approve", [
142
153
  gate(gateName(config.gate ?? {}) || `approve-${config.name}`, {
143
154
  ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
155
+ // #2300: the approval is for the change set the Plan phase just
156
+ // produced. Edit the source between approving and re-running and
157
+ // the gate refuses, naming the approved digest and the planned one,
158
+ // instead of applying what nobody read.
159
+ plan: stepOutput("plan", "planDigest"),
144
160
  description:
145
161
  config.gate?.description ??
146
162
  `Approve apply to ${config.env} (delete mode: ${deleteMode}` +
@@ -12,6 +12,7 @@ import { ReconcileOp } from "./reconcile-op";
12
12
  import { ApplyOp } from "./apply-op";
13
13
  import { DECLARABLE_MARKER } from "../../declarable";
14
14
  import { EffectReceipt, receiptExpectation } from "../../effect-receipt";
15
+ import { isStepOutputRef } from "../step-output-ref";
15
16
 
16
17
  function getProps(entity: unknown): Record<string, unknown> {
17
18
  return (entity as { props: Record<string, unknown> }).props;
@@ -115,14 +116,14 @@ describe("ReconcileOp: configuration", () => {
115
116
  expect(phases.map((p) => p.name)).toEqual(["Snapshot", "Plan", "Reconcile"]);
116
117
  const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
117
118
  expect(reconcileStep.fn).toBe("reconcilePr");
118
- expect(reconcileStep.args).toEqual({ env: "prod", mode: "pull-request", owned: false });
119
+ expect(reconcileStep.args).toEqual({ env: "prod", op: "p", mode: "pull-request", owned: false });
119
120
  });
120
121
 
121
122
  test("scope.owned + onDrift flow into the reconcilePr step", () => {
122
123
  const { op } = ReconcileOp({ name: "p", env: "prod", onDrift: "issue", scope: { owned: true } });
123
124
  const phases = getProps(op).phases as Array<Record<string, unknown>>;
124
125
  const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
125
- expect(reconcileStep.args).toEqual({ env: "prod", mode: "issue", owned: true });
126
+ expect(reconcileStep.args).toEqual({ env: "prod", op: "p", mode: "issue", owned: true });
126
127
  });
127
128
 
128
129
  test("labels include Reconcile + Env", () => {
@@ -204,6 +205,18 @@ describe("ApplyOp: gating + deletes", () => {
204
205
  const { op } = ApplyOp({ name: "p", env: "prod" });
205
206
  expect(getProps(op).labels).toEqual({ Apply: "true", Env: "prod" });
206
207
  });
208
+
209
+ // #2300 — the gate approves the change set the Plan phase produced, not the
210
+ // next run of the Op. The Plan step carries the id the reference needs.
211
+ test("the gate binds the Plan phase's own change-set digest", () => {
212
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "gated" });
213
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
214
+ const planStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
215
+ expect(planStep.id).toBe("plan");
216
+ const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
217
+ expect(isStepOutputRef(gateStep.plan)).toBe(true);
218
+ expect(gateStep.plan).toMatchObject({ step: "plan", path: "planDigest" });
219
+ });
207
220
  });
208
221
 
209
222
  describe("ApplyOp: compensation (#125, total-or-refused in #1449)", () => {
@@ -34,6 +34,24 @@ describe("ReconcileOp composite — PR/issue URL outcome (#8)", () => {
34
34
  });
35
35
  });
36
36
 
37
+ describe("ReconcileOp composite — the finding step names its Op (#2319)", () => {
38
+ test("passes the Op's own name as `op`, which is what keeps the issue marker unique", () => {
39
+ const { op } = ReconcileOp({ name: "prod-reconcile", env: "prod", onDrift: "issue" });
40
+ expect((reconcileStep(op).args as { op: string }).op).toBe("prod-reconcile");
41
+ });
42
+
43
+ test("two Ops over one env carry two identities", () => {
44
+ // The collision #2319 reports: `env` alone is shared here by construction,
45
+ // and a `TerraformWatchOp` over a root named "prod" would share it too.
46
+ const a = ReconcileOp({ name: "prod-reconcile", env: "prod", onDrift: "issue" });
47
+ const b = ReconcileOp({ name: "prod-reconcile-owned", env: "prod", onDrift: "issue" });
48
+ const argsA = reconcileStep(a.op).args as { op: string; env: string };
49
+ const argsB = reconcileStep(b.op).args as { op: string; env: string };
50
+ expect(argsA.env).toBe(argsB.env);
51
+ expect(argsA.op).not.toBe(argsB.op);
52
+ });
53
+ });
54
+
37
55
  describe("ReconcileOp composite — cadence on the op (#2120)", () => {
38
56
  function opProps(op: unknown): Record<string, unknown> {
39
57
  return (op as { props: Record<string, unknown> }).props;
@@ -96,10 +96,16 @@ export function ReconcileOp(config: ReconcileOpConfig): ReconcileOpResources {
96
96
  // regenerates via `chant import --from`, and opens a PR. Surface the
97
97
  // opened PR/issue URL as an outcome so `chant run` prints it — the
98
98
  // reconcile's result is the link.
99
+ //
100
+ // `op` names this Op in the marker `issue` mode's sticky issue is
101
+ // found by (#2319). The env alone is not unique across Ops: it shares
102
+ // a marker namespace with `TerraformWatchOp`, which passes a terraform
103
+ // *root* as its `env`, so a chant environment and a root that happen
104
+ // to share a name resolved to one marker and overwrote each other.
99
105
  {
100
106
  kind: "activity" as const,
101
107
  fn: "reconcilePr",
102
- args: { env: config.env, mode: onDrift, owned },
108
+ args: { env: config.env, op: config.name, mode: onDrift, owned },
103
109
  ...(reconcileOutcome ? { outcomeAttribute: reconcileOutcome } : {}),
104
110
  },
105
111
  ]),
@@ -28,6 +28,39 @@ describe("gatedRunSummaryMarkdown (#2243)", () => {
28
28
  expect(md).toContain("chant approve app-apply approve-app-apply --approver <you>");
29
29
  expect(md).toContain("_gates/app-apply.jsonl");
30
30
  });
31
+
32
+ // #2310: `recordGateApproval`'s push was already reported (#2309); the
33
+ // gate's own `appendPending` push was not. The GitHub/Forgejo/Gitea step
34
+ // summary is exactly the surface an operator working from a different
35
+ // checkout would open, so it is where the gap mattered most.
36
+ test("says so when this run's own append never reached the remote", () => {
37
+ const md = gatedRunSummaryMarkdown({
38
+ ...summary,
39
+ pushed: false,
40
+ pushWarning: "chant/lifecycle remote branch has moved since this run started",
41
+ });
42
+ expect(md).toContain("was not pushed to the remote");
43
+ expect(md).toContain("chant/lifecycle remote branch has moved since this run started");
44
+ expect(md).toContain("cannot see it to approve it");
45
+ });
46
+
47
+ test("says nothing extra when the push landed or nothing was pushed this run", () => {
48
+ const md = gatedRunSummaryMarkdown(summary);
49
+ expect(md).not.toContain("was not pushed");
50
+ });
51
+
52
+ // #2300 — the block is where a CI approver reads what they are approving,
53
+ // so it names the plan and says the approval does not carry to the next one.
54
+ test("names the plan the approval binds to, when the gate binds one", () => {
55
+ const digest = `sha256:${"d".repeat(64)}`;
56
+ const md = gatedRunSummaryMarkdown({ ...summary, planDigest: digest });
57
+ expect(md).toContain(digest);
58
+ expect(md).toContain("and not the next run");
59
+ });
60
+
61
+ test("a gate that binds no plan says nothing about one", () => {
62
+ expect(gatedRunSummaryMarkdown(summary)).not.toContain("and not the next run");
63
+ });
31
64
  });
32
65
 
33
66
  describe("writeGatedRunSummary surfaces (#2243, #2256)", () => {
@@ -34,6 +34,22 @@ export interface GatedRunSummary {
34
34
  expiresAt?: string;
35
35
  /** The approval surface the run resolved, when it knew one. */
36
36
  url?: string;
37
+ /**
38
+ * False when this run's own append reached only the local chant/lifecycle
39
+ * branch, not the remote (#2310) — the reason lives in `pushWarning`.
40
+ * Absent when this run left an already-standing pending fact alone, or when
41
+ * the push landed.
42
+ */
43
+ pushed?: boolean;
44
+ /** Set when `pushed` is false. */
45
+ pushWarning?: string;
46
+ /**
47
+ * The plan the pending fact was recorded against (#2300), when the gate
48
+ * binds one. Shown because it is what the `chant approve` line below
49
+ * approves — and because a reader who comes back to a stale summary needs
50
+ * to see that the digest has moved on.
51
+ */
52
+ planDigest?: string;
37
53
  }
38
54
 
39
55
  /**
@@ -51,6 +67,13 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
51
67
  if (summary.description) {
52
68
  lines.push(summary.description, "");
53
69
  }
70
+ if (summary.planDigest) {
71
+ lines.push(
72
+ `This approves one plan, \`${summary.planDigest}\`, and not the next run (#2300). ` +
73
+ "Change the configuration after approving and the next run refuses rather than applying.",
74
+ "",
75
+ );
76
+ }
54
77
  lines.push(
55
78
  "Approve it, then re-run this workflow:",
56
79
  "",
@@ -62,6 +85,14 @@ export function gatedRunSummaryMarkdown(summary: GatedRunSummary): string {
62
85
  );
63
86
  if (summary.expiresAt) lines.push(`Expires: ${summary.expiresAt}`);
64
87
  if (summary.url) lines.push(`Approve at: ${summary.url}`);
88
+ if (summary.pushed === false) {
89
+ lines.push(
90
+ "",
91
+ `**This pending fact was not pushed to the remote** (${summary.pushWarning ?? "no reason given"}). ` +
92
+ "It exists only in this job's checkout. An operator working from a clone of the remote cannot " +
93
+ "see it to approve it until it reaches \`chant/lifecycle\` there.",
94
+ );
95
+ }
65
96
  lines.push("");
66
97
  return lines.join("\n");
67
98
  }