@intentius/chant 0.70.1 → 0.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +36 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/fold/subset.d.ts +22 -0
  26. package/dist/fold/subset.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/lifecycle/affected.d.ts +26 -0
  30. package/dist/lifecycle/affected.d.ts.map +1 -1
  31. package/dist/op/activities/activity-contracts.d.ts +16 -1
  32. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +1 -1
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/shell.d.ts +31 -2
  36. package/dist/op/activities/shell.d.ts.map +1 -1
  37. package/dist/op/activity-contract.d.ts +1 -1
  38. package/dist/op/activity-contract.d.ts.map +1 -1
  39. package/dist/op/activity-profiles.d.ts +19 -0
  40. package/dist/op/activity-profiles.d.ts.map +1 -1
  41. package/dist/op/builders.d.ts +21 -3
  42. package/dist/op/builders.d.ts.map +1 -1
  43. package/dist/op/gate-name.d.ts +10 -0
  44. package/dist/op/gate-name.d.ts.map +1 -1
  45. package/dist/op/step-output-ref.d.ts +25 -8
  46. package/dist/op/step-output-ref.d.ts.map +1 -1
  47. package/package.json +2 -1
  48. package/src/cli/handlers/fan-out.test.ts +394 -0
  49. package/src/cli/handlers/fan-out.ts +336 -0
  50. package/src/cli/handlers/lifecycle.test.ts +74 -1
  51. package/src/cli/handlers/lifecycle.ts +29 -1
  52. package/src/cli/handlers/operator.test.ts +22 -0
  53. package/src/cli/handlers/operator.ts +11 -3
  54. package/src/cli/handlers/run.ts +7 -1
  55. package/src/cli/main.ts +25 -3
  56. package/src/cli/registry.ts +23 -0
  57. package/src/components/deploy-units.ts +14 -4
  58. package/src/components/fan-out-output.test.ts +216 -0
  59. package/src/components/fan-out-output.ts +162 -0
  60. package/src/components/fan-out-run.test.ts +194 -0
  61. package/src/components/fan-out-run.ts +221 -0
  62. package/src/components/fan-out-support.test.ts +125 -0
  63. package/src/components/fan-out-support.ts +95 -0
  64. package/src/components/fan-out.test.ts +284 -0
  65. package/src/components/fan-out.ts +421 -0
  66. package/src/components/index.ts +33 -0
  67. package/src/discovery/fold-import.ts +81 -3
  68. package/src/fold/subset-public-export.test.ts +31 -0
  69. package/src/fold/subset.ts +23 -0
  70. package/src/index.ts +6 -0
  71. package/src/lifecycle/affected.test.ts +118 -0
  72. package/src/lifecycle/affected.ts +118 -14
  73. package/src/meta/declared-imports.test.ts +141 -0
  74. package/src/op/activities/activity-contracts.ts +17 -1
  75. package/src/op/activities/index.ts +1 -1
  76. package/src/op/activities/shell.test.ts +156 -0
  77. package/src/op/activities/shell.ts +84 -9
  78. package/src/op/activity-profiles.test.ts +16 -2
  79. package/src/op/activity-profiles.ts +18 -0
  80. package/src/op/builders.ts +22 -4
  81. package/src/op/gate-name.ts +11 -0
  82. package/src/op/op-ir.test.ts +4 -1
  83. package/src/op/op.test.ts +7 -2
  84. package/src/op/step-output-ref.ts +29 -8
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The subset planner (#2417).
3
+ *
4
+ * The property under test throughout: order comes from the full graph, and
5
+ * execution comes from the selection. Those are different sets, and conflating
6
+ * them is what makes a fan-out either wrong or useless.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import { componentsForUnits, planFanOut, remainingFanOut, UnknownComponentError } from "./fan-out";
11
+ import { DependencyCycleError, UnknownDependencyError, type DriverComponent } from "./driver";
12
+
13
+ const c = (name: string, dependsOn?: string[]): DriverComponent =>
14
+ ({ name, deploy: [], ...(dependsOn ? { dependsOn } : {}) });
15
+
16
+ /** net → two clusters → apps, the shape the issue's proof asks for. */
17
+ const ESTATE: DriverComponent[] = [
18
+ c("net"),
19
+ c("cluster-a", ["net"]),
20
+ c("cluster-b", ["net"]),
21
+ c("app-one", ["cluster-a"]),
22
+ c("app-two", ["cluster-a"]),
23
+ c("app-three", ["cluster-b"]),
24
+ c("billing"),
25
+ ];
26
+
27
+ describe("selection", () => {
28
+ test("a change at the root reaches everything downstream of it, and nothing else", () => {
29
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
30
+ expect(plan.order).toEqual(["net", "cluster-a", "cluster-b", "app-one", "app-three", "app-two"]);
31
+ // `billing` shares no edge with the change, so it does not run.
32
+ expect(plan.skipped).toEqual([{ component: "billing", reason: "unaffected" }]);
33
+ });
34
+
35
+ test("a change at a leaf propagates to nothing", () => {
36
+ const plan = planFanOut({ components: ESTATE, changed: ["app-three"] });
37
+ expect(plan.order).toEqual(["app-three"]);
38
+ expect(plan.skipped.map((s) => s.component)).toEqual([
39
+ "app-one", "app-two", "billing", "cluster-a", "cluster-b", "net",
40
+ ]);
41
+ });
42
+
43
+ test("no apply precedes its dependency", () => {
44
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
45
+ const position = new Map(plan.order.map((name, i) => [name, i]));
46
+ for (const name of plan.order) {
47
+ for (const dep of ESTATE.find((e) => e.name === name)!.dependsOn ?? []) {
48
+ if (position.has(dep)) expect(position.get(dep)!).toBeLessThan(position.get(name)!);
49
+ }
50
+ }
51
+ });
52
+ });
53
+
54
+ describe("waves", () => {
55
+ test("independent branches share a wave", () => {
56
+ const plan = planFanOut({ components: ESTATE, changed: ["net"] });
57
+ expect(plan.waves).toEqual([["net"], ["cluster-a", "cluster-b"], ["app-one", "app-three", "app-two"]]);
58
+ });
59
+
60
+ test("the derivation does not depend on the order components were declared in", () => {
61
+ // The printed derivation is part of what an operator approves, so it has to
62
+ // be a fact about the graph rather than about the file layout.
63
+ const a = planFanOut({ components: ESTATE, changed: ["net"] });
64
+ const b = planFanOut({ components: [...ESTATE].reverse(), changed: ["net"] });
65
+ expect(b.order).toEqual(a.order);
66
+ expect(b.waves).toEqual(a.waves);
67
+ });
68
+
69
+ test("an unselected dependency does not hold its dependents back a wave", () => {
70
+ // Only the apps changed. `cluster-a` is already applied and seeded, so
71
+ // `app-one` belongs in wave 1 rather than waiting behind a component that
72
+ // is not running at all.
73
+ const plan = planFanOut({ components: ESTATE, changed: ["app-one", "app-three"] });
74
+ expect(plan.waves).toEqual([["app-one", "app-three"]]);
75
+ expect(plan.seeds).toEqual(["cluster-a", "cluster-b"]);
76
+ });
77
+ });
78
+
79
+ describe("diamonds", () => {
80
+ const DIAMOND = [c("root"), c("left", ["root"]), c("right", ["root"]), c("join", ["left", "right"])];
81
+
82
+ test("a component reachable by two paths applies once, after both paths", () => {
83
+ const plan = planFanOut({ components: DIAMOND, changed: ["root"] });
84
+ expect(plan.order).toEqual(["root", "left", "right", "join"]);
85
+ expect(plan.order.filter((n) => n === "join")).toHaveLength(1);
86
+ expect(plan.waves).toEqual([["root"], ["left", "right"], ["join"]]);
87
+ });
88
+ });
89
+
90
+ describe("refusals", () => {
91
+ test("a cycle is refused before anything is selected, even away from the change", () => {
92
+ const withCycle = [...ESTATE, c("ping", ["pong"]), c("pong", ["ping"])];
93
+ // The change touches `net`, which shares no edge with the cycle. A broken
94
+ // graph is still broken.
95
+ expect(() => planFanOut({ components: withCycle, changed: ["net"] })).toThrow(DependencyCycleError);
96
+ });
97
+
98
+ test("a dependsOn naming a component the project does not have is refused", () => {
99
+ expect(() => planFanOut({ components: [c("app", ["ghost"])], changed: ["app"] }))
100
+ .toThrow(UnknownDependencyError);
101
+ });
102
+
103
+ test("a changed name that is not a component is refused, naming what is known", () => {
104
+ expect(() => planFanOut({ components: ESTATE, changed: ["nett"] }))
105
+ .toThrow(/changed names "nett".*known: app-one, app-three/s);
106
+ expect(() => planFanOut({ components: ESTATE, changed: ["nett"] })).toThrow(UnknownComponentError);
107
+ });
108
+
109
+ test("an indeterminate name that is not a component is refused too", () => {
110
+ expect(() => planFanOut({ components: ESTATE, changed: [], indeterminate: ["ghost"] }))
111
+ .toThrow(UnknownComponentError);
112
+ });
113
+ });
114
+
115
+ describe("indeterminate", () => {
116
+ test("one the walk reaches is selected like any other dependent", () => {
117
+ // Reachability is a fact about the graph. Whether `app-one`'s own inputs
118
+ // could be read from a source diff has no bearing on `cluster-a` having moved.
119
+ const plan = planFanOut({ components: ESTATE, changed: ["cluster-a"], indeterminate: ["app-one"] });
120
+ expect(plan.order).toContain("app-one");
121
+ expect(plan.indeterminate).toEqual([]);
122
+ });
123
+
124
+ test("one the walk does not reach is reported, not decided", () => {
125
+ const plan = planFanOut({ components: ESTATE, changed: ["cluster-b"], indeterminate: ["billing"] });
126
+ expect(plan.order).not.toContain("billing");
127
+ expect(plan.indeterminate).toEqual(["billing"]);
128
+ // And it is not quietly filed as unaffected, which would be a claim the
129
+ // diff could not support.
130
+ expect(plan.skipped).toContainEqual({ component: "billing", reason: "indeterminate" });
131
+ });
132
+
133
+ test("every non-selected component is accounted for exactly once", () => {
134
+ const plan = planFanOut({ components: ESTATE, changed: ["cluster-a"], indeterminate: ["billing"] });
135
+ const accounted = [...plan.order, ...plan.skipped.map((s) => s.component)].sort();
136
+ expect(accounted).toEqual(ESTATE.map((e) => e.name).sort());
137
+ });
138
+ });
139
+
140
+ describe("the binding digest", () => {
141
+ test("re-deriving an unchanged fan-out produces the same digest", () => {
142
+ const a = planFanOut({ components: ESTATE, changed: ["net"] });
143
+ const b = planFanOut({ components: [...ESTATE].reverse(), changed: ["net"] });
144
+ expect(b.digest).toBe(a.digest);
145
+ });
146
+
147
+ test("a different selection is a different approval", () => {
148
+ const root = planFanOut({ components: ESTATE, changed: ["net"] });
149
+ const leaf = planFanOut({ components: ESTATE, changed: ["app-three"] });
150
+ expect(leaf.digest).not.toBe(root.digest);
151
+ });
152
+
153
+ test("the same selection over changed content is a different approval", () => {
154
+ const before = planFanOut({ components: ESTATE, changed: ["net"], inputDigests: { net: "sha256:aa" } });
155
+ const after = planFanOut({ components: ESTATE, changed: ["net"], inputDigests: { net: "sha256:bb" } });
156
+ expect(after.digest).not.toBe(before.digest);
157
+ });
158
+
159
+ test("it looks like every other plan digest, so `chant approve --plan` accepts it", () => {
160
+ expect(planFanOut({ components: ESTATE, changed: ["net"] }).digest).toMatch(/^sha256:[0-9a-f]{64}$/);
161
+ });
162
+ });
163
+
164
+ describe("joining a stack-level change signal to components", () => {
165
+ /** Two components claiming stacks by their deploy steps, the way `--live` already reads them. */
166
+ const DEPLOYERS: DriverComponent[] = [
167
+ { name: "edge", deploy: [{ phase: "Deploy", steps: [{ kind: "cfn-deploy", stack: "edge-stack" }] }] },
168
+ { name: "core", deploy: [{ phase: "Deploy", steps: [{ kind: "cfn-deploy", stack: "core-stack" }] }] },
169
+ {
170
+ name: "mixed",
171
+ deploy: [{ phase: "Deploy", steps: [
172
+ { kind: "cfn-deploy", stack: "core-stack" },
173
+ { kind: "helm-upgrade", release: "mixed-release" },
174
+ ] }],
175
+ },
176
+ ];
177
+
178
+ test("a component deploying a changed stack is changed", () => {
179
+ const signal = componentsForUnits(DEPLOYERS, { changed: ["core-stack"] });
180
+ expect(signal.changed).toEqual(["core", "mixed"]);
181
+ expect(signal.indeterminate).toEqual([]);
182
+ expect(signal.unclaimed).toEqual([]);
183
+ });
184
+
185
+ test("a component deploying only an indeterminate stack is indeterminate", () => {
186
+ const signal = componentsForUnits(DEPLOYERS, { changed: [], indeterminate: ["edge-stack"] });
187
+ expect(signal.changed).toEqual([]);
188
+ expect(signal.indeterminate).toEqual(["edge"]);
189
+ });
190
+
191
+ test("certainty wins: one changed unit decides a component that also has an unjudgeable one", () => {
192
+ const signal = componentsForUnits(DEPLOYERS, {
193
+ changed: ["core-stack"],
194
+ indeterminate: ["mixed-release"],
195
+ });
196
+ expect(signal.changed).toContain("mixed");
197
+ expect(signal.indeterminate).not.toContain("mixed");
198
+ });
199
+
200
+ test("a changed stack no component claims is reported, not dropped", () => {
201
+ const signal = componentsForUnits(DEPLOYERS, { changed: ["core-stack", "orphan-stack"] });
202
+ expect(signal.unclaimed).toEqual(["orphan-stack"]);
203
+ });
204
+
205
+ test("the join feeds the planner directly", () => {
206
+ const graph: DriverComponent[] = [
207
+ { name: "core", deploy: [{ phase: "Deploy", steps: [{ kind: "cfn-deploy", stack: "core-stack" }] }] },
208
+ { name: "edge", dependsOn: ["core"], deploy: [{ phase: "Deploy", steps: [{ kind: "cfn-deploy", stack: "edge-stack" }] }] },
209
+ ];
210
+ const signal = componentsForUnits(graph, { changed: ["core-stack"] });
211
+ const plan = planFanOut({ components: graph, changed: signal.changed, indeterminate: signal.indeterminate });
212
+ expect(plan.order).toEqual(["core", "edge"]);
213
+ });
214
+ });
215
+
216
+ describe("finishing a fan-out that stopped", () => {
217
+ const plan = () => planFanOut({ components: ESTATE, changed: ["net"] });
218
+
219
+ test("a re-run does not redo what already applied", () => {
220
+ const rest = remainingFanOut(plan(), ESTATE, { completed: ["net", "cluster-a"] });
221
+ // cluster-a's apps are free immediately, because the thing they waited for
222
+ // is already applied; app-three still waits behind cluster-b.
223
+ expect(rest.waves).toEqual([["app-one", "app-two", "cluster-b"], ["app-three"]]);
224
+ expect(rest.order).toEqual(["app-one", "app-two", "cluster-b", "app-three"]);
225
+ expect(rest.skipped).toContainEqual({ component: "net", reason: "already-applied" });
226
+ });
227
+
228
+ test("the approval survives: the digest is the one that was approved", () => {
229
+ const original = plan();
230
+ const rest = remainingFanOut(original, ESTATE, { completed: ["net"], failed: [] });
231
+ // Re-deriving would mint a new identity and invalidate a standing
232
+ // resolution, which is exactly what a resumed run must not do.
233
+ expect(rest.digest).toBe(original.digest);
234
+ });
235
+
236
+ test("a failure blocks its subtree and nothing else", () => {
237
+ const rest = remainingFanOut(plan(), ESTATE, { completed: ["net"], failed: ["cluster-a"] });
238
+ // cluster-b shares no edge with the failure, so it and its app still run.
239
+ expect(rest.order).toEqual(["cluster-b", "app-three"]);
240
+ expect(rest.skipped).toContainEqual({ component: "app-one", reason: "blocked", blockedBy: "cluster-a" });
241
+ expect(rest.skipped).toContainEqual({ component: "app-two", reason: "blocked", blockedBy: "cluster-a" });
242
+ });
243
+
244
+ test("a blocked component names the failure to fix, not the nearest edge", () => {
245
+ // net → cluster-a → app-one: app-one is two hops from the failure.
246
+ const rest = remainingFanOut(plan(), ESTATE, { failed: ["net"] });
247
+ for (const name of ["cluster-a", "cluster-b", "app-one", "app-two", "app-three"]) {
248
+ expect(rest.skipped).toContainEqual({ component: name, reason: "blocked", blockedBy: "net" });
249
+ }
250
+ expect(rest.order).toEqual([]);
251
+ });
252
+
253
+ test("a completed dependency does not hold its dependents back a wave", () => {
254
+ const rest = remainingFanOut(plan(), ESTATE, { completed: ["net"] });
255
+ expect(rest.waves[0]).toEqual(["cluster-a", "cluster-b"]);
256
+ // And its outputs have to be seeded, like any dependency not running.
257
+ expect(rest.seeds).toEqual(["net"]);
258
+ });
259
+
260
+ test("nothing left to do is an empty plan, not an error", () => {
261
+ const rest = remainingFanOut(plan(), ESTATE, {
262
+ completed: ["net", "cluster-a", "cluster-b", "app-one", "app-two", "app-three"],
263
+ });
264
+ expect(rest.order).toEqual([]);
265
+ expect(rest.waves).toEqual([]);
266
+ });
267
+
268
+ test("a component downstream of two failures names the same one either way", () => {
269
+ const joined = [c("left"), c("right"), c("join", ["left", "right"])];
270
+ const plan = planFanOut({ components: joined, changed: ["left", "right"] });
271
+ const one = remainingFanOut(plan, joined, { failed: ["left", "right"] });
272
+ const other = remainingFanOut(plan, joined, { failed: ["right", "left"] });
273
+ expect(other.skipped).toEqual(one.skipped);
274
+ expect(one.skipped).toContainEqual({ component: "join", reason: "blocked", blockedBy: "left" });
275
+ });
276
+
277
+ test("progress naming a component outside the plan is ignored rather than trusted", () => {
278
+ // `billing` was never selected, so a stale record claiming it completed
279
+ // must not quietly widen or narrow the approved fan-out.
280
+ const rest = remainingFanOut(plan(), ESTATE, { completed: ["billing"], failed: ["billing"] });
281
+ expect(rest.order).toEqual(plan().order);
282
+ expect(rest.digest).toBe(plan().digest);
283
+ });
284
+ });