@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,125 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createMockPlugin, staticObservation } from "@intentius/chant-test-utils";
3
+ import { observation } from "../observation";
4
+ import { assertLiveEntity, LiveAssertionError, UnobservedAssertionError } from "./assert-live";
5
+ import type { ResourceMetadata } from "../lexicon";
6
+
7
+ const MARKER = { stack: "shop", env: "test-suite-abc123" };
8
+
9
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
10
+ type: "Mock::Queue",
11
+ status: "READY",
12
+ marker: MARKER,
13
+ ownership: "owned",
14
+ ...overrides,
15
+ });
16
+
17
+ const call = (opts: Partial<Parameters<typeof assertLiveEntity>[0]> = {}) =>
18
+ assertLiveEntity({
19
+ plugin: createMockPlugin(),
20
+ name: "taskQueue",
21
+ entityType: "Mock::Queue",
22
+ props: {},
23
+ buildOutput: "",
24
+ environment: MARKER.env,
25
+ marker: MARKER,
26
+ ...opts,
27
+ });
28
+
29
+ describe("assertLiveEntity", () => {
30
+ test("resolves the metadata for an observed, marker-matched entity", async () => {
31
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta() }) });
32
+ await expect(call({ plugin })).resolves.toEqual(meta());
33
+ });
34
+
35
+ test("checks status when given, passes when it matches", async () => {
36
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "READY" }) }) });
37
+ await expect(call({ plugin, status: "READY" })).resolves.toEqual(meta());
38
+ });
39
+
40
+ test("throws naming the entity when observed absent", async () => {
41
+ const plugin = createMockPlugin({ describeResources: staticObservation({}) });
42
+ await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
43
+ await expect(call({ plugin })).rejects.toThrow(/taskQueue.*observed absent/s);
44
+ });
45
+
46
+ test("throws UnobservedAssertionError, not a plain failure, when NOT-OBSERVED", async () => {
47
+ const plugin = createMockPlugin({
48
+ describeResources: staticObservation({}, { taskQueue: { reason: "no-credentials", type: "Mock::Queue" } }),
49
+ });
50
+ const err = await call({ plugin }).then(
51
+ () => undefined,
52
+ (e: unknown) => e,
53
+ );
54
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
55
+ expect(err).not.toBeInstanceOf(LiveAssertionError);
56
+ expect((err as UnobservedAssertionError).reason).toBe("no-credentials");
57
+ });
58
+
59
+ test("a thrown describeResources degrades to NOT-OBSERVED read-failed, never a silent absence", async () => {
60
+ const plugin = createMockPlugin({
61
+ describeResources: async () => {
62
+ throw new Error("ECONNREFUSED");
63
+ },
64
+ });
65
+ const err = await call({ plugin }).then(
66
+ () => undefined,
67
+ (e: unknown) => e,
68
+ );
69
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
70
+ expect((err as UnobservedAssertionError).reason).toBe("read-failed");
71
+ expect((err as UnobservedAssertionError).detail).toContain("ECONNREFUSED");
72
+ });
73
+
74
+ test("a lexicon with no describeResources is NOT-OBSERVED, unsupported-kind", async () => {
75
+ const plugin = createMockPlugin();
76
+ const err = await call({ plugin }).then(
77
+ () => undefined,
78
+ (e: unknown) => e,
79
+ );
80
+ expect(err).toBeInstanceOf(UnobservedAssertionError);
81
+ expect((err as UnobservedAssertionError).reason).toBe("unsupported-kind");
82
+ });
83
+
84
+ test("throws when the observed marker names a foreign stack", async () => {
85
+ const plugin = createMockPlugin({
86
+ describeResources: staticObservation({ taskQueue: meta({ marker: { stack: "other-stack", env: MARKER.env } }) }),
87
+ });
88
+ await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
89
+ });
90
+
91
+ test("throws when the observed marker names a foreign env — a same-named leftover from another run", async () => {
92
+ const plugin = createMockPlugin({
93
+ describeResources: staticObservation({ taskQueue: meta({ marker: { stack: MARKER.stack, env: "test-other-999999" } }) }),
94
+ });
95
+ await expect(call({ plugin })).rejects.toThrow(LiveAssertionError);
96
+ });
97
+
98
+ test("throws when the entity is confirmed foreign (no marker, ownership foreign)", async () => {
99
+ const plugin = createMockPlugin({
100
+ describeResources: staticObservation({
101
+ taskQueue: meta({ marker: undefined, ownership: "foreign" }),
102
+ }),
103
+ });
104
+ await expect(call({ plugin })).rejects.toThrow(/not this deploy's/);
105
+ });
106
+
107
+ test("passes through an entity whose lexicon has no marker channel (ownership unknown, no marker) rather than always failing", async () => {
108
+ const plugin = createMockPlugin({
109
+ describeResources: staticObservation({
110
+ taskQueue: meta({ marker: undefined, ownership: "unknown" }),
111
+ }),
112
+ });
113
+ await expect(call({ plugin })).resolves.toEqual(meta({ marker: undefined, ownership: "unknown" }));
114
+ });
115
+
116
+ test("throws a status mismatch after identity is confirmed", async () => {
117
+ const plugin = createMockPlugin({ describeResources: staticObservation({ taskQueue: meta({ status: "PENDING" }) }) });
118
+ await expect(call({ plugin, status: "READY" })).rejects.toThrow(/status "PENDING"/);
119
+ });
120
+
121
+ test("uses the observation() envelope form identically to the bare-map form", async () => {
122
+ const plugin = createMockPlugin({ describeResources: async () => observation({ taskQueue: meta() }) });
123
+ await expect(call({ plugin })).resolves.toEqual(meta());
124
+ });
125
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * assertLive (#1857) — the read half of the test harness (#1224):
3
+ * observation-backed assertions against a live deploy, for exactly one
4
+ * declared entity at a time. Same primitive teardown.ts's fallback path
5
+ * uses — `describeResources` — turned into a pass/throw instead of a
6
+ * would-delete set.
7
+ *
8
+ * The observation contract (#1089) draws a hard line between OBSERVED-ABSENT
9
+ * and NOT-OBSERVED: a declared entity the read could not cover is never the
10
+ * same as one confirmed missing. `assertLiveEntity` preserves that line by
11
+ * construction — NOT-OBSERVED throws {@link UnobservedAssertionError}, a type
12
+ * distinct from the {@link LiveAssertionError} an observed-absent, foreign,
13
+ * or status-mismatched verdict throws, so a caller can tell "could not tell"
14
+ * from "confirmed wrong" without parsing a message.
15
+ *
16
+ * Marker verification is best-effort by the same logic {@link
17
+ * ResourceMetadata.marker}'s own contract states: a lexicon with no marker
18
+ * channel on this read path (aws's thin `describeResources`, `ownership:
19
+ * "unknown"`) reports no marker at all, which is not the same claim as
20
+ * "foreign". Enforcing a match whenever the channel exists — a present
21
+ * mismatch, or `ownership: "foreign"` with no marker to show — catches the
22
+ * case the harness cares about (a same-named leftover from another env);
23
+ * an absent channel is passed through unverified rather than making every
24
+ * lexicon without one unusable.
25
+ */
26
+
27
+ import { normalizeObservation, unobservedAll, unobservedReasonText, type UnobservedReason } from "../observation";
28
+ import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
29
+ import type { OwnershipMarker } from "../ownership";
30
+
31
+ /**
32
+ * Thrown by {@link assertLiveEntity} for a confirmed failure: observed
33
+ * absent, a marker that names another stack/env, a resource confirmed
34
+ * foreign, or a status mismatch. Never thrown for NOT-OBSERVED — see {@link
35
+ * UnobservedAssertionError}.
36
+ */
37
+ export class LiveAssertionError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "LiveAssertionError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Thrown when the entity is NOT-OBSERVED (#1089) rather than confirmed
46
+ * present or absent. Kept as its own type, not a flag on {@link
47
+ * LiveAssertionError}: a suite (or a CI policy) that wants to fail loudly on
48
+ * "could not tell" but treat it differently from a confirmed miss can catch
49
+ * this one specifically.
50
+ */
51
+ export class UnobservedAssertionError extends Error {
52
+ constructor(
53
+ public readonly entity: string,
54
+ public readonly reason: UnobservedReason,
55
+ public readonly detail?: string,
56
+ ) {
57
+ super(
58
+ `assertLive("${entity}") is NOT-OBSERVED — ${unobservedReasonText(reason)}` +
59
+ `${detail ? `: ${detail}` : ""}. An entity chant could not read is never the same as one confirmed absent.`,
60
+ );
61
+ this.name = "UnobservedAssertionError";
62
+ }
63
+ }
64
+
65
+ export interface AssertLiveOptions {
66
+ /** Expected `ResourceMetadata.status`, where the lexicon reports one. Skipped when omitted. */
67
+ status?: string;
68
+ }
69
+
70
+ export interface AssertLiveEntityOptions extends AssertLiveOptions {
71
+ plugin: ObservationLexicon;
72
+ /** chant entity name — the key to assert on. */
73
+ name: string;
74
+ entityType: string;
75
+ props: Record<string, unknown>;
76
+ /** This lexicon's own built output for the deploy, or `""` when none was built. */
77
+ buildOutput: string;
78
+ environment: string;
79
+ /** This deploy's identity — the marker an observed resource is checked against. */
80
+ marker: OwnershipMarker;
81
+ }
82
+
83
+ /** True when `meta` names a different stack/env than `marker`, on whichever signal it carries. */
84
+ function isConfirmedForeign(meta: ResourceMetadata, marker: OwnershipMarker): boolean {
85
+ if (meta.marker) return meta.marker.stack !== marker.stack || meta.marker.env !== marker.env;
86
+ return meta.ownership === "foreign";
87
+ }
88
+
89
+ /**
90
+ * Assert one declared entity is live: observed present in `environment`, not
91
+ * a confirmed-foreign resource, and — when `status` is given — reporting
92
+ * that status. Resolves to the entity's {@link ResourceMetadata} on success.
93
+ *
94
+ * Throws {@link UnobservedAssertionError} for NOT-OBSERVED. Throws {@link
95
+ * LiveAssertionError} for observed-absent, a confirmed-foreign identity, or a
96
+ * status mismatch.
97
+ */
98
+ export async function assertLiveEntity(opts: AssertLiveEntityOptions): Promise<ResourceMetadata> {
99
+ const { plugin, name, entityType, props, buildOutput, environment, marker, status } = opts;
100
+
101
+ if (!plugin.describeResources) {
102
+ throw new UnobservedAssertionError(
103
+ name,
104
+ "unsupported-kind",
105
+ `the "${plugin.name}" lexicon implements no describeResources`,
106
+ );
107
+ }
108
+
109
+ let observed;
110
+ try {
111
+ observed = normalizeObservation(
112
+ await plugin.describeResources({
113
+ environment,
114
+ buildOutput,
115
+ entityNames: [name],
116
+ entities: new Map([[name, { entityType, props }]]),
117
+ }),
118
+ );
119
+ } catch (err) {
120
+ const detail = err instanceof Error ? err.message : String(err);
121
+ observed = {
122
+ resources: {},
123
+ unobserved: unobservedAll([name], "read-failed", detail, { [name]: entityType }),
124
+ queried: {},
125
+ notes: [],
126
+ };
127
+ }
128
+
129
+ const unobserved = observed.unobserved[name];
130
+ if (unobserved) throw new UnobservedAssertionError(name, unobserved.reason, unobserved.detail);
131
+
132
+ const meta = observed.resources[name];
133
+ if (!meta) {
134
+ throw new LiveAssertionError(
135
+ `assertLive("${name}"): observed absent — chant looked and "${environment}" reported no such resource.`,
136
+ );
137
+ }
138
+
139
+ if (isConfirmedForeign(meta, marker)) {
140
+ const found = meta.marker ? `{ stack: "${meta.marker.stack}", env: "${meta.marker.env ?? ""}" }` : "no chant marker";
141
+ throw new LiveAssertionError(
142
+ `assertLive("${name}"): observed, but it is not this deploy's — carries ${found}, not ` +
143
+ `{ stack: "${marker.stack}", env: "${marker.env ?? ""}" }. A same-named resource from another stack or env cannot satisfy this assertion.`,
144
+ );
145
+ }
146
+
147
+ if (status !== undefined && meta.status !== status) {
148
+ throw new LiveAssertionError(
149
+ `assertLive("${name}", { status: "${status}" }): observed with status "${meta.status}".`,
150
+ );
151
+ }
152
+
153
+ return meta;
154
+ }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
16
16
  import { unobservedReasonText, type UnobservedReason } from "../observation";
17
+ import { renderDisruption, summarizeDisruption, type Disruption } from "./disruption";
17
18
 
18
19
  /**
19
20
  * What the projection proposes for a single resource.
@@ -129,6 +130,22 @@ export interface ChangeSetEntry {
129
130
  effectReason?: EffectFireReason;
130
131
  /** Human-readable backing for `effectReason` (the digests that differ, the unresolved path). */
131
132
  effectDetail?: string;
133
+ /**
134
+ * How much applying this change hurts (#1665) — in-place / rolling / replace
135
+ * / destroy / unknown. Set on `update` entries only: every other action
136
+ * carries its blast radius in the action itself.
137
+ *
138
+ * The verdict comes from the lexicon that owns the spec
139
+ * ({@link LexiconPlugin.classifyDisruption}), never from core, which has no
140
+ * per-provider replacement rules and must not grow any. `unknown` is the
141
+ * default and the only fallback — read it as "nobody could say", never as
142
+ * "probably in place".
143
+ */
144
+ disruption?: Disruption;
145
+ /** The attribute paths that forced `disruption` (#1665). */
146
+ disruptionBecause?: string[];
147
+ /** Human-readable backing for `disruption` — the spec knowledge behind the call, or why there is none. */
148
+ disruptionDetail?: string;
132
149
  }
133
150
 
134
151
  export interface ChangeSet {
@@ -314,6 +331,16 @@ export function renderChangeSet(cs: ChangeSet): string {
314
331
  const header = ACTION_ORDER.map((a) => `${counts[a]} ${a}`).join(", ");
315
332
  const lines: string[] = [`Plan for ${cs.env}: ${header}`];
316
333
 
334
+ // Disruption (#1665) rides the header too, so the one number that says how
335
+ // much this plan hurts is visible without reading every row.
336
+ const disruption = summarizeDisruption(cs);
337
+ const disruptionParts = (Object.entries(disruption) as Array<[Disruption, number]>)
338
+ .filter(([, n]) => n > 0)
339
+ .map(([level, n]) => `${n} ${level}`);
340
+ if (disruptionParts.length > 0) {
341
+ lines.push(`Disruption: ${disruptionParts.join(", ")}`);
342
+ }
343
+
317
344
  for (const action of ACTION_ORDER) {
318
345
  const group = cs.entries.filter((e) => e.action === action);
319
346
  if (group.length === 0) continue;
@@ -324,7 +351,9 @@ export function renderChangeSet(cs: ChangeSet): string {
324
351
  ? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
325
352
  : action === "effect"
326
353
  ? "\nEFFECT (receipt absent or stale; the effect step fires — the generic apply never writes a receipt):"
327
- : `\n${action.toUpperCase()}:`,
354
+ : action === "update" && disruptionParts.length > 0
355
+ ? "\nUPDATE (disruption from the lexicon that owns the spec; unknown means nobody could say, not that it is safe):"
356
+ : `\n${action.toUpperCase()}:`,
328
357
  );
329
358
  for (const e of group) {
330
359
  if (e.action === "effect") {
@@ -339,9 +368,12 @@ export function renderChangeSet(cs: ChangeSet): string {
339
368
  ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
340
369
  : "";
341
370
  const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
342
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
371
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}${renderDisruption(e)}`);
372
+ const forced = new Set(e.disruptionBecause ?? []);
343
373
  for (const d of e.deltas ?? []) {
344
- lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
374
+ // A path the verdict rests on is marked, so a `replace` row says which
375
+ // of five changed properties caused it.
376
+ lines.push(` ${forced.has(d.path) ? "! " : ""}${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
345
377
  }
346
378
  }
347
379
  }
@@ -0,0 +1,199 @@
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 {
7
+ appendConvergeRecord,
8
+ readConvergeLedger,
9
+ consecutiveRuleFires,
10
+ type ConvergeTickRecordInput,
11
+ type ConvergeTickRecord,
12
+ } from "./converge-ledger";
13
+
14
+ function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
15
+ const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
16
+ return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
17
+ }
18
+
19
+ async function initRepo(dir: string): Promise<void> {
20
+ git(["init", "-q", "-b", "main"], dir);
21
+ git(["config", "user.email", "test@chant.dev"], dir);
22
+ git(["config", "user.name", "Test"], dir);
23
+ writeFileSync(join(dir, "README.md"), "fixture\n");
24
+ git(["add", "README.md"], dir);
25
+ git(["commit", "-q", "-m", "init"], dir);
26
+ }
27
+
28
+ function makeInput(overrides?: Partial<ConvergeTickRecordInput>): ConvergeTickRecordInput {
29
+ return {
30
+ op: "fountain-converge",
31
+ env: "staging",
32
+ timestamp: "2026-01-01T00:00:00.000Z",
33
+ firedRuleIds: [],
34
+ outcomes: [],
35
+ summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
36
+ log: "converge(staging): drifted=0 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 unobserved=0 adopted=0",
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ describe("converge-ledger", () => {
42
+ describe("appendConvergeRecord / readConvergeLedger", () => {
43
+ test("round-trips a single record", async () => {
44
+ await withTestDir(async (dir) => {
45
+ await initRepo(dir);
46
+ const { record } = await appendConvergeRecord(makeInput(), { cwd: dir });
47
+ expect(record.version).toBe(1);
48
+
49
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
50
+ expect(malformed).toBe(0);
51
+ expect(records).toEqual([record]);
52
+ });
53
+ });
54
+
55
+ test("appends without clobbering prior records", async () => {
56
+ await withTestDir(async (dir) => {
57
+ await initRepo(dir);
58
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:00:00.000Z" }), { cwd: dir });
59
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:10:00.000Z" }), { cwd: dir });
60
+ await appendConvergeRecord(makeInput({ timestamp: "2026-01-01T00:20:00.000Z" }), { cwd: dir });
61
+
62
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
63
+ expect(records).toHaveLength(3);
64
+ expect(records.map((r) => r.timestamp)).toEqual([
65
+ "2026-01-01T00:00:00.000Z",
66
+ "2026-01-01T00:10:00.000Z",
67
+ "2026-01-01T00:20:00.000Z",
68
+ ]);
69
+ });
70
+ });
71
+
72
+ test("empty ledger for an environment with no records — never throws", async () => {
73
+ await withTestDir(async (dir) => {
74
+ await initRepo(dir);
75
+ const { records, malformed } = await readConvergeLedger("nowhere", { cwd: dir });
76
+ expect(records).toEqual([]);
77
+ expect(malformed).toBe(0);
78
+ });
79
+ });
80
+
81
+ test("skips malformed lines but reports the count", async () => {
82
+ await withTestDir(async (dir) => {
83
+ await initRepo(dir);
84
+ await appendConvergeRecord(makeInput(), { cwd: dir });
85
+ // Directly corrupt the ledger by writing a second, non-JSON line via a
86
+ // second append whose "record" is actually malformed content — simulate
87
+ // by reading, then re-appending raw garbage through the same git path
88
+ // a hand-edited ledger would produce. We reuse appendConvergeRecord's
89
+ // underlying git plumbing indirectly by writing a second valid record,
90
+ // then asserting the reader tolerates an out-of-schema line mixed in
91
+ // at read time isn't directly expressible via the public API, so this
92
+ // test instead exercises the schema-validation branch: a record
93
+ // missing `firedRuleIds` is treated as malformed.
94
+ const { readBlobFromPath, writeBlobToPath } = await import("./git");
95
+ const existing = await readBlobFromPath("staging", "converge.jsonl", { cwd: dir });
96
+ const corrupted = `${existing}\n${JSON.stringify({ version: 1, op: "x", env: "staging" })}`;
97
+ await writeBlobToPath("staging", "converge.jsonl", corrupted, "corrupt", { cwd: dir });
98
+
99
+ const { records, malformed } = await readConvergeLedger("staging", { cwd: dir });
100
+ expect(records).toHaveLength(1);
101
+ expect(malformed).toBe(1);
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("consecutiveRuleFires", () => {
107
+ function rec(firedRuleIds: string[]): ConvergeTickRecord {
108
+ return { ...makeInput({ firedRuleIds }), version: 1 };
109
+ }
110
+
111
+ test("0 for an empty ledger", () => {
112
+ expect(consecutiveRuleFires([], "r1")).toBe(0);
113
+ });
114
+
115
+ test("0 when the newest tick didn't fire it", () => {
116
+ const records = [rec(["r1"]), rec(["r1"]), rec([])];
117
+ expect(consecutiveRuleFires(records, "r1")).toBe(0);
118
+ });
119
+
120
+ test("counts consecutive fires from the newest tick backward", () => {
121
+ const records = [rec([]), rec(["r1"]), rec(["r1"]), rec(["r1"])];
122
+ expect(consecutiveRuleFires(records, "r1")).toBe(3);
123
+ });
124
+
125
+ test("stops counting at the first non-firing tick, scanning backward", () => {
126
+ const records = [rec(["r1"]), rec([]), rec(["r1"]), rec(["r1"])];
127
+ expect(consecutiveRuleFires(records, "r1")).toBe(2);
128
+ });
129
+
130
+ test("only counts the named rule, ignoring others", () => {
131
+ const records = [rec(["r2"]), rec(["r1", "r2"])];
132
+ expect(consecutiveRuleFires(records, "r1")).toBe(1);
133
+ expect(consecutiveRuleFires(records, "r2")).toBe(2);
134
+ });
135
+ });
136
+
137
+ // ── Gate-as-fact (#1485) ───────────────────────────────────────────────────
138
+
139
+ describe("gated outcome", () => {
140
+ test("round-trips a 'gated' outcome with its gateName, and a gated count in the summary", async () => {
141
+ await withTestDir(async (dir) => {
142
+ await initRepo(dir);
143
+ const { record } = await appendConvergeRecord(
144
+ makeInput({
145
+ firedRuleIds: ["drift-apply"],
146
+ outcomes: [
147
+ {
148
+ ruleId: "drift-apply",
149
+ action: "gated",
150
+ op: "fountain-apply",
151
+ gateName: "rollout-gate",
152
+ reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
153
+ },
154
+ ],
155
+ summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
156
+ }),
157
+ { cwd: dir },
158
+ );
159
+ expect(record.outcomes[0]).toEqual({
160
+ ruleId: "drift-apply",
161
+ action: "gated",
162
+ op: "fountain-apply",
163
+ gateName: "rollout-gate",
164
+ reason: 'dispatch of "fountain-apply" hit gate "rollout-gate"',
165
+ });
166
+ expect(record.summary.gated).toBe(1);
167
+
168
+ const { records } = await readConvergeLedger("staging", { cwd: dir });
169
+ expect(records).toEqual([record]);
170
+ });
171
+ });
172
+ });
173
+
174
+ // ── Concurrent local writers (#1485) ────────────────────────────────────────
175
+
176
+ describe("appendConvergeRecord retries on RefCASConflictError", () => {
177
+ test("a concurrent writer to a different env's file on the same orphan branch doesn't lose either write", async () => {
178
+ await withTestDir(async (dir) => {
179
+ await initRepo(dir);
180
+ // Two "operators" appending to two different environments' ledgers on
181
+ // the same orphan branch, interleaved so their internal git-plumbing
182
+ // reads race — this is exactly the scenario `writeBlobToPath`'s CAS
183
+ // guard (chant #1485) now protects against, and `appendConvergeRecord`
184
+ // retries through rather than surfacing to the caller.
185
+ const [a, b] = await Promise.all([
186
+ appendConvergeRecord(makeInput({ env: "staging", op: "staging-converge" }), { cwd: dir }),
187
+ appendConvergeRecord(makeInput({ env: "prod", op: "prod-converge" }), { cwd: dir }),
188
+ ]);
189
+ expect(a.record.env).toBe("staging");
190
+ expect(b.record.env).toBe("prod");
191
+
192
+ const staging = await readConvergeLedger("staging", { cwd: dir });
193
+ const prod = await readConvergeLedger("prod", { cwd: dir });
194
+ expect(staging.records).toHaveLength(1);
195
+ expect(prod.records).toHaveLength(1);
196
+ });
197
+ });
198
+ });
199
+ });