@intentius/chant 0.49.0 → 0.51.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 (294) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Per-change disruption classification (#1665).
3
+ *
4
+ * The change set says WHAT a pending change is (`create`/`update`/`delete`/…).
5
+ * It says nothing about what applying it costs. An `update` that flips a tag
6
+ * and an `update` that rebuilds a database read identically, and the second one
7
+ * is the one that wakes somebody up.
8
+ *
9
+ * The knowledge that separates them is spec knowledge. CloudFormation's
10
+ * registry schema declares `createOnlyProperties` per type; Kubernetes' SSA
11
+ * schema knows which field changes roll a workload. Core owns neither, and
12
+ * hardcoding either here would put per-provider replacement rules in the tool
13
+ * — the same mistake `postSynthChecks` exists to avoid. So core defines the
14
+ * contract and the reporting, and the lexicon that compiled the spec supplies
15
+ * the answer, via {@link LexiconPlugin.classifyDisruption}.
16
+ *
17
+ * The invariant that makes the field trustworthy is that `unknown` is the
18
+ * default and the only fallback. No classifier, a classifier that says nothing
19
+ * about an entry, a classifier that throws, a classifier that returns a level
20
+ * outside the vocabulary — all of them land on `unknown`, never on `in-place`.
21
+ * A confident "this mutates in place" is only ever a lexicon's own claim.
22
+ */
23
+ import type { AttributeChange } from "./live-diff";
24
+ import type { ChangeSet, ChangeSetEntry } from "./change-set";
25
+
26
+ /**
27
+ * How much applying one pending change hurts.
28
+ *
29
+ * - `in-place` — the provider mutates the existing resource. No new identity,
30
+ * no window where it is absent.
31
+ * - `rolling` — the resource survives, but its workload is replaced
32
+ * incrementally (a Deployment's pod template changing). Disruptive to what
33
+ * is running, not to the resource.
34
+ * - `replace` — a new resource is created and the old one removed. The
35
+ * physical id changes; anything holding the old one has to be updated.
36
+ * - `destroy` — replacement that removes the old resource FIRST. There is a
37
+ * window with nothing there, and whatever the old one held is gone.
38
+ * - `unknown` — nobody could say. The honest value, and the default: it is
39
+ * what a change gets when no lexicon classifies it, and it must never be
40
+ * read as "probably fine".
41
+ */
42
+ export type Disruption = "in-place" | "rolling" | "replace" | "destroy" | "unknown";
43
+
44
+ /** Every level, most disruptive last — also the guard core validates a lexicon's answer against. */
45
+ export const DISRUPTION_LEVELS: readonly Disruption[] = [
46
+ "in-place",
47
+ "rolling",
48
+ "replace",
49
+ "destroy",
50
+ "unknown",
51
+ ];
52
+
53
+ /** Ordering for "the worst thing in this plan", with `unknown` above every confident verdict. */
54
+ const DISRUPTION_RANK: Record<Disruption, number> = {
55
+ "in-place": 0,
56
+ rolling: 1,
57
+ replace: 2,
58
+ destroy: 3,
59
+ unknown: 4,
60
+ };
61
+
62
+ /** One pending change put to a lexicon for classification. */
63
+ export interface DisruptionQuery {
64
+ /** The change set entry's `name` — the key a verdict comes back under. */
65
+ name: string;
66
+ /** Resource type, when the observation reported one. */
67
+ type?: string;
68
+ /** The attribute-level changes the entry carries. */
69
+ deltas: AttributeChange[];
70
+ }
71
+
72
+ /** A lexicon's answer for one query. */
73
+ export interface DisruptionVerdict {
74
+ disruption: Disruption;
75
+ /** The attribute paths that forced the verdict — empty or absent when none did. */
76
+ because?: string[];
77
+ /** One line of human-readable backing, naming the spec knowledge behind the call. */
78
+ detail?: string;
79
+ }
80
+
81
+ /**
82
+ * The shape of {@link LexiconPlugin.classifyDisruption}. Keyed by query `name`;
83
+ * a name the lexicon says nothing about degrades to `unknown`, so a partial
84
+ * answer is a valid answer.
85
+ */
86
+ export type DisruptionClassifier = (options: {
87
+ environment: string;
88
+ changes: DisruptionQuery[];
89
+ }) => Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
90
+
91
+ /** The verdict every fallback path produces. */
92
+ export function unknownDisruption(detail: string): DisruptionVerdict {
93
+ return { disruption: "unknown", detail };
94
+ }
95
+
96
+ /**
97
+ * Annotate one lexicon's change set with a disruption verdict per `update`.
98
+ *
99
+ * Only `update` entries are asked about: every other action already carries its
100
+ * blast radius in the action itself. Called once per lexicon, before the plan
101
+ * merges the change sets, so `classify` is always the lexicon that produced the
102
+ * entries — the only party that can map its own observation's attribute paths
103
+ * back onto spec properties.
104
+ *
105
+ * Returns a new change set; the input is not mutated.
106
+ */
107
+ export async function annotateDisruption(
108
+ cs: ChangeSet,
109
+ environment: string,
110
+ classify?: DisruptionClassifier,
111
+ ): Promise<ChangeSet> {
112
+ const updates = cs.entries.filter((e) => e.action === "update");
113
+ if (updates.length === 0) return cs;
114
+
115
+ const who = updates[0].lexicon ? `the ${updates[0].lexicon} lexicon` : "this lexicon";
116
+
117
+ let verdicts: Record<string, DisruptionVerdict> = {};
118
+ let fallback: string | undefined;
119
+
120
+ if (!classify) {
121
+ fallback = `${who} does not classify disruption — replacement semantics are spec knowledge it has not published`;
122
+ } else {
123
+ try {
124
+ verdicts = (await classify({
125
+ environment,
126
+ changes: updates.map((e) => ({
127
+ name: e.name,
128
+ ...(e.type ? { type: e.type } : {}),
129
+ deltas: e.deltas ?? [],
130
+ })),
131
+ })) ?? {};
132
+ } catch (err) {
133
+ // A broken classifier is not evidence of anything. It must not be able to
134
+ // leave a confident verdict behind, and it must not fail the plan either.
135
+ verdicts = {};
136
+ fallback = `${who}'s disruption classifier failed: ${err instanceof Error ? err.message : String(err)}`;
137
+ }
138
+ }
139
+
140
+ const entries = cs.entries.map((e) => {
141
+ if (e.action !== "update") return e;
142
+ const verdict = resolveVerdict(verdicts[e.name], fallback, who);
143
+ const annotated: ChangeSetEntry = {
144
+ ...e,
145
+ disruption: verdict.disruption,
146
+ ...(verdict.because && verdict.because.length > 0 ? { disruptionBecause: verdict.because } : {}),
147
+ ...(verdict.detail ? { disruptionDetail: verdict.detail } : {}),
148
+ };
149
+ return annotated;
150
+ });
151
+
152
+ return { ...cs, entries };
153
+ }
154
+
155
+ function resolveVerdict(
156
+ verdict: DisruptionVerdict | undefined,
157
+ fallback: string | undefined,
158
+ who: string,
159
+ ): DisruptionVerdict {
160
+ if (fallback) return unknownDisruption(fallback);
161
+ if (!verdict) return unknownDisruption(`${who} returned no verdict for this change`);
162
+ if (!DISRUPTION_LEVELS.includes(verdict.disruption)) {
163
+ return unknownDisruption(
164
+ `${who} returned "${String(verdict.disruption)}", which is not a disruption level`,
165
+ );
166
+ }
167
+ return verdict;
168
+ }
169
+
170
+ /** Count `update` entries per level. Entries with no verdict at all are not counted. */
171
+ export function summarizeDisruption(cs: ChangeSet): Record<Disruption, number> {
172
+ const counts: Record<Disruption, number> = {
173
+ "in-place": 0,
174
+ rolling: 0,
175
+ replace: 0,
176
+ destroy: 0,
177
+ unknown: 0,
178
+ };
179
+ for (const e of cs.entries) {
180
+ if (e.action === "update" && e.disruption) counts[e.disruption]++;
181
+ }
182
+ return counts;
183
+ }
184
+
185
+ /** The most disruptive verdict in the set, or undefined when nothing was classified. */
186
+ export function worstDisruption(cs: ChangeSet): Disruption | undefined {
187
+ let worst: Disruption | undefined;
188
+ for (const e of cs.entries) {
189
+ if (e.action !== "update" || !e.disruption) continue;
190
+ if (!worst || DISRUPTION_RANK[e.disruption] > DISRUPTION_RANK[worst]) worst = e.disruption;
191
+ }
192
+ return worst;
193
+ }
194
+
195
+ /**
196
+ * Warnings a plan should print on stderr — so a `--json` or `--report gitlab-mr`
197
+ * consumer, whose shape has no column for disruption, still hears about the
198
+ * expensive rows. Same discipline as the unobserved warning (#1089).
199
+ */
200
+ export function disruptionNotices(cs: ChangeSet): string[] {
201
+ const counts = summarizeDisruption(cs);
202
+ const notices: string[] = [];
203
+ const replacing = counts.replace + counts.destroy;
204
+ if (replacing > 0) {
205
+ notices.push(
206
+ `${replacing} update(s) replace the resource rather than mutating it in place` +
207
+ (counts.destroy > 0
208
+ ? `, ${counts.destroy} of them by deleting it first — that window has nothing in it.`
209
+ : "."),
210
+ );
211
+ }
212
+ if (counts.unknown > 0) {
213
+ notices.push(
214
+ `${counts.unknown} update(s) could not be classified — no lexicon could say whether applying them replaces the resource. Unknown is not "in place".`,
215
+ );
216
+ }
217
+ return notices;
218
+ }
219
+
220
+ /** Render one entry's verdict for the human plan, or "" when there is none. */
221
+ export function renderDisruption(entry: ChangeSetEntry): string {
222
+ if (!entry.disruption) return "";
223
+ return ` — ${entry.disruption}${entry.disruptionDetail ? `: ${entry.disruptionDetail}` : ""}`;
224
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { withTestDir } from "@intentius/chant-test-utils";
3
+ import { spawnSync } from "node:child_process";
4
+ import { writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { appendGateResolution, readGateResolutions, latestResolutionSince } from "./gate-ledger";
7
+ import { readBlobFromPath } from "./git";
8
+
9
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
10
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
11
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
12
+ }
13
+
14
+ async function initRepo(dir: string): Promise<void> {
15
+ git(["init", "-q", "-b", "main"], dir);
16
+ git(["config", "user.email", "test@chant.dev"], dir);
17
+ git(["config", "user.name", "Test"], dir);
18
+ writeFileSync(join(dir, "README.md"), "fixture\n");
19
+ git(["add", "README.md"], dir);
20
+ git(["commit", "-q", "-m", "init"], dir);
21
+ }
22
+
23
+ describe("lifecycle/gate-ledger", () => {
24
+ test("appendGateResolution + readGateResolutions round-trip", async () => {
25
+ await withTestDir(async (dir) => {
26
+ await initRepo(dir);
27
+ const { record } = await appendGateResolution(
28
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
29
+ { cwd: dir },
30
+ );
31
+ expect(record.version).toBe(1);
32
+
33
+ const { records, malformed } = await readGateResolutions("fountain-apply", { cwd: dir });
34
+ expect(malformed).toBe(0);
35
+ expect(records).toEqual([record]);
36
+ });
37
+ });
38
+
39
+ test("stores under a global _gates namespace, keyed by op — not per-environment", async () => {
40
+ await withTestDir(async (dir) => {
41
+ await initRepo(dir);
42
+ await appendGateResolution(
43
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
44
+ { cwd: dir },
45
+ );
46
+ const raw = await readBlobFromPath("_gates", "fountain-apply.jsonl", { cwd: dir });
47
+ expect(raw).toContain("rollout-gate");
48
+ });
49
+ });
50
+
51
+ test("readGateResolutions returns [] for an op with no resolutions yet", async () => {
52
+ await withTestDir(async (dir) => {
53
+ await initRepo(dir);
54
+ expect(await readGateResolutions("no-such-op", { cwd: dir })).toEqual({ records: [], malformed: 0 });
55
+ });
56
+ });
57
+
58
+ test("appends without clobbering — multiple gates/resolutions on the same op accumulate", async () => {
59
+ await withTestDir(async (dir) => {
60
+ await initRepo(dir);
61
+ await appendGateResolution(
62
+ { op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
63
+ { cwd: dir },
64
+ );
65
+ await appendGateResolution(
66
+ { op: "fountain-apply", gate: "prod-gate", resolvedBy: "sam", timestamp: "2026-01-02T00:00:00.000Z", note: "https://github.com/x/y/pull/1" },
67
+ { cwd: dir },
68
+ );
69
+ const { records } = await readGateResolutions("fountain-apply", { cwd: dir });
70
+ expect(records.map((r) => r.gate)).toEqual(["rollout-gate", "prod-gate"]);
71
+ expect(records[1].note).toBe("https://github.com/x/y/pull/1");
72
+ });
73
+ });
74
+
75
+ describe("latestResolutionSince", () => {
76
+ test("finds a resolution recorded after the gated tick's own timestamp", () => {
77
+ const records = [
78
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
79
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "b", timestamp: "2026-01-03T00:00:00.000Z" },
80
+ ];
81
+ const found = latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z");
82
+ expect(found?.resolvedBy).toBe("b");
83
+ });
84
+
85
+ test("returns undefined when the only resolution predates the gated tick (a stale, superseded approval)", () => {
86
+ const records = [
87
+ { version: 1 as const, op: "x", gate: "g1", resolvedBy: "a", timestamp: "2026-01-01T00:00:00.000Z" },
88
+ ];
89
+ expect(latestResolutionSince(records, "g1", "2026-01-02T00:00:00.000Z")).toBeUndefined();
90
+ });
91
+
92
+ test("ignores resolutions for a different gate", () => {
93
+ const records = [
94
+ { version: 1 as const, op: "x", gate: "other-gate", resolvedBy: "a", timestamp: "2026-01-05T00:00:00.000Z" },
95
+ ];
96
+ expect(latestResolutionSince(records, "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
97
+ });
98
+
99
+ test("undefined when there are no resolutions at all", () => {
100
+ expect(latestResolutionSince([], "g1", "2026-01-01T00:00:00.000Z")).toBeUndefined();
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Gate resolution ledger (#1485, epic #1487) — the durable counterpart to a
3
+ * converge tick's gate-as-fact outcome (`./converge-ledger.ts`'s
4
+ * `ConvergeRuleOutcome.action === "gated"`). Same append-only, content-
5
+ * addressed shape as the converge/release ledgers, reusing
6
+ * `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly — one line per
7
+ * resolution at `_gates/<op>.jsonl` on the `chant/lifecycle` orphan branch.
8
+ *
9
+ * Keyed by op name rather than environment (`_gates`, not `<env>/gates...`)
10
+ * because a gate belongs to the *dispatched* op — the thing a converge
11
+ * rule's `run()` action names — and that op's own env, if it declares one at
12
+ * all, isn't always the calling `ConvergeOp`'s env. `writeBlobToPath`'s own
13
+ * doc already establishes this generic-namespace pattern
14
+ * (`./build-ledger-store.ts`'s `_builds`); this is the same move for a
15
+ * second non-env top-level directory.
16
+ *
17
+ * `chant approve <op> <gate>` (`../cli/handlers/operator.ts`) is what
18
+ * appends here — issue #1485's "resolution is an out-of-band act that
19
+ * writes the counterpart fact". Per the issue's own leaning on open
20
+ * question 3 ("local trust in v1, signature as an additive follow-up"),
21
+ * this record is *not* itself an authorization check — anyone who can run
22
+ * `chant approve` locally can write one, the same trust boundary a local
23
+ * `git commit` already has. What it changes: `chant operator status` (and
24
+ * any future gate-aware dispatch retry) can tell a resolved gate from a
25
+ * still-pending one by finding a resolution newer than the tick that
26
+ * recorded it. It does **not** (v1) retroactively make a gated op's local
27
+ * dispatch succeed — the local executor still refuses any op containing a
28
+ * gate outright (`../op/local-executor.ts`'s `LocalGateUnsupportedError`),
29
+ * unconditionally, gate resolution or not. Wiring an approved gate back
30
+ * into the local executor's dispatch path is the GateStep semantic change
31
+ * issue #1485 itself flags as open question 1 ("suspension → fact... does
32
+ * it land as its own issue first? Leaning: yes, split it out") — deferred
33
+ * here for the same reason the issue defers it: it touches both the local
34
+ * executor and the generated Temporal workflow, and has migration impact on
35
+ * shipped ops. `chant approve` in v1 is the durable, queryable record of
36
+ * "this gate is cleared" that a human (or a future auto-resume path) reads;
37
+ * it is not itself the unblock.
38
+ */
39
+ import { sortedJsonReplacer } from "../utils";
40
+ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
41
+
42
+ const DIR = "_gates";
43
+ const APPEND_RETRY_ATTEMPTS = 5;
44
+
45
+ /** One immutable gate-resolution record. */
46
+ export interface GateResolutionRecord {
47
+ /** Schema version, so an incompatible future shape is detected before being misread. */
48
+ version: 1;
49
+ /** The dispatched op the gate belongs to. */
50
+ op: string;
51
+ /** The gate's signal name (matches `ConvergeRuleOutcome.gateName`). */
52
+ gate: string;
53
+ /** Who resolved it — an actor name, the same convention `components release --actor` and `run signal --approver` use. */
54
+ resolvedBy: string;
55
+ /** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
56
+ timestamp: string;
57
+ /** Optional free-text context (e.g. a PR URL — "or a merged PR" is the issue's other resolution path; recording its link here keeps both paths visible from one ledger). */
58
+ note?: string;
59
+ }
60
+
61
+ export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
62
+
63
+ function filename(op: string): string {
64
+ return `${op}.jsonl`;
65
+ }
66
+
67
+ /** Append one immutable gate-resolution record. Does not push to the remote — call `pushLifecycle` (./git.ts) afterward, same two-step shape every other ledger write here uses. Retries on `RefCASConflictError` the same way `appendConvergeRecord` does (./converge-ledger.ts) — a concurrent writer to a different op's/env's file on the same orphan branch is the ordinary case, not an edge case. The baseline read must be `readPathSha` + `readBlobBySha` rather than `readBlobFromPath`, so the exact sha `existing` came from can be passed as `expectPriorPathSha` — see `writeBlobToPath` (./git.ts) for the race that closes. */
68
+ export async function appendGateResolution(
69
+ input: GateResolutionInput,
70
+ opts?: { cwd?: string },
71
+ ): Promise<{ commit: string; record: GateResolutionRecord }> {
72
+ const record: GateResolutionRecord = { version: 1, ...input };
73
+ const json = JSON.stringify(record, sortedJsonReplacer);
74
+
75
+ let lastErr: unknown;
76
+ for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
77
+ try {
78
+ const priorSha = await readPathSha(DIR, filename(record.op), opts);
79
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
80
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
81
+ const commit = await writeBlobToPath(DIR, filename(record.op), content, "Gate resolution record", {
82
+ ...opts,
83
+ expectPriorPathSha: priorSha,
84
+ });
85
+ return { commit, record };
86
+ } catch (err) {
87
+ if (!(err instanceof RefCASConflictError)) throw err;
88
+ lastErr = err;
89
+ }
90
+ }
91
+ throw lastErr;
92
+ }
93
+
94
+ /** Read every gate-resolution record for `op`, oldest first. Malformed lines are skipped, not thrown on, the same graceful-degradation stance `readConvergeLedger` takes. Returns `[]` (never throws) when `op` has no resolutions recorded yet. */
95
+ export async function readGateResolutions(
96
+ op: string,
97
+ opts?: { cwd?: string },
98
+ ): Promise<{ records: GateResolutionRecord[]; malformed: number }> {
99
+ const content = await readBlobFromPath(DIR, filename(op), opts);
100
+ if (!content) return { records: [], malformed: 0 };
101
+
102
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
103
+ const records: GateResolutionRecord[] = [];
104
+ let malformed = 0;
105
+ for (const line of lines) {
106
+ try {
107
+ const parsed = JSON.parse(line) as Partial<GateResolutionRecord>;
108
+ if (
109
+ parsed.version !== 1 ||
110
+ typeof parsed.op !== "string" ||
111
+ typeof parsed.gate !== "string" ||
112
+ typeof parsed.resolvedBy !== "string" ||
113
+ typeof parsed.timestamp !== "string"
114
+ ) {
115
+ malformed++;
116
+ continue;
117
+ }
118
+ records.push(parsed as GateResolutionRecord);
119
+ } catch {
120
+ malformed++;
121
+ }
122
+ }
123
+ return { records, malformed };
124
+ }
125
+
126
+ /** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
127
+ export function latestResolutionSince(
128
+ records: GateResolutionRecord[],
129
+ gate: string,
130
+ sinceIso: string,
131
+ ): GateResolutionRecord | undefined {
132
+ const since = new Date(sinceIso).getTime();
133
+ let latest: GateResolutionRecord | undefined;
134
+ for (const r of records) {
135
+ if (r.gate !== gate) continue;
136
+ if (new Date(r.timestamp).getTime() < since) continue;
137
+ if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
138
+ }
139
+ return latest;
140
+ }