@intentius/chant 0.49.0 → 0.50.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 (247) 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/operator.d.ts +32 -0
  28. package/dist/cli/handlers/operator.d.ts.map +1 -0
  29. package/dist/cli/handlers/scenario.d.ts +39 -0
  30. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/mcp/server.d.ts +35 -2
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/types.d.ts +29 -1
  35. package/dist/cli/mcp/types.d.ts.map +1 -1
  36. package/dist/cli/registry.d.ts +14 -2
  37. package/dist/cli/registry.d.ts.map +1 -1
  38. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  39. package/dist/components/capability.d.ts +17 -2
  40. package/dist/components/capability.d.ts.map +1 -1
  41. package/dist/components/cli-support.d.ts +7 -0
  42. package/dist/components/cli-support.d.ts.map +1 -1
  43. package/dist/components/component.d.ts +15 -0
  44. package/dist/components/component.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts.map +1 -1
  46. package/dist/components/verbs/index.d.ts +6 -1
  47. package/dist/components/verbs/index.d.ts.map +1 -1
  48. package/dist/components/verbs/run-agent.d.ts +499 -0
  49. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  50. package/dist/components/verbs/sign.d.ts +30 -0
  51. package/dist/components/verbs/sign.d.ts.map +1 -1
  52. package/dist/composite.d.ts +6 -1
  53. package/dist/composite.d.ts.map +1 -1
  54. package/dist/discovery/collect.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -1
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/discovery/fold-rank.d.ts +66 -0
  58. package/dist/discovery/fold-rank.d.ts.map +1 -0
  59. package/dist/discovery/index.d.ts +15 -0
  60. package/dist/discovery/index.d.ts.map +1 -1
  61. package/dist/discovery/param-deps.d.ts +17 -0
  62. package/dist/discovery/param-deps.d.ts.map +1 -0
  63. package/dist/fold/fold.d.ts +55 -2
  64. package/dist/fold/fold.d.ts.map +1 -1
  65. package/dist/fold/subset.d.ts +21 -14
  66. package/dist/fold/subset.d.ts.map +1 -1
  67. package/dist/lexicon-schema.d.ts +2 -0
  68. package/dist/lexicon-schema.d.ts.map +1 -1
  69. package/dist/lexicon.d.ts +93 -0
  70. package/dist/lexicon.d.ts.map +1 -1
  71. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  72. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  73. package/dist/lifecycle/deep-diff.d.ts +18 -0
  74. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  75. package/dist/lifecycle/deep-observe.d.ts +9 -1
  76. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  77. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  78. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  79. package/dist/lifecycle/git.d.ts +145 -21
  80. package/dist/lifecycle/git.d.ts.map +1 -1
  81. package/dist/lifecycle/index.d.ts +4 -0
  82. package/dist/lifecycle/index.d.ts.map +1 -1
  83. package/dist/lifecycle/lease.d.ts +113 -0
  84. package/dist/lifecycle/lease.d.ts.map +1 -0
  85. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  86. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  87. package/dist/lifecycle/scenario.d.ts +163 -0
  88. package/dist/lifecycle/scenario.d.ts.map +1 -0
  89. package/dist/lifecycle/symptoms.d.ts +63 -0
  90. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  91. package/dist/lint/output-docs.d.ts +94 -0
  92. package/dist/lint/output-docs.d.ts.map +1 -0
  93. package/dist/lint/post-synth.d.ts +29 -0
  94. package/dist/lint/post-synth.d.ts.map +1 -1
  95. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  96. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  97. package/dist/lsp/lexicon-providers.d.ts +7 -0
  98. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  99. package/dist/op/activity-contract.d.ts +139 -0
  100. package/dist/op/activity-contract.d.ts.map +1 -0
  101. package/dist/op/builders.d.ts +42 -2
  102. package/dist/op/builders.d.ts.map +1 -1
  103. package/dist/op/converge-rule.d.ts +161 -0
  104. package/dist/op/converge-rule.d.ts.map +1 -0
  105. package/dist/op/generate-pipeline.d.ts +39 -0
  106. package/dist/op/generate-pipeline.d.ts.map +1 -0
  107. package/dist/op/index.d.ts +14 -0
  108. package/dist/op/index.d.ts.map +1 -1
  109. package/dist/op/local-executor.d.ts.map +1 -1
  110. package/dist/op/op-verb-class.d.ts +42 -0
  111. package/dist/op/op-verb-class.d.ts.map +1 -0
  112. package/dist/op/operator.d.ts +128 -0
  113. package/dist/op/operator.d.ts.map +1 -0
  114. package/dist/op/step-output-ref.d.ts +187 -0
  115. package/dist/op/step-output-ref.d.ts.map +1 -0
  116. package/dist/op/types.d.ts +18 -1
  117. package/dist/op/types.d.ts.map +1 -1
  118. package/dist/provenance.d.ts +73 -3
  119. package/dist/provenance.d.ts.map +1 -1
  120. package/dist/runtime-adapter.d.ts +7 -1
  121. package/dist/runtime-adapter.d.ts.map +1 -1
  122. package/dist/serializer.d.ts +18 -0
  123. package/dist/serializer.d.ts.map +1 -1
  124. package/dist/toml.d.ts +40 -5
  125. package/dist/toml.d.ts.map +1 -1
  126. package/package.json +1 -1
  127. package/src/audit/catalog.test.ts +1 -1
  128. package/src/audit/catalog.ts +75 -3
  129. package/src/audit/core.ts +9 -0
  130. package/src/audit/discover.ts +29 -2
  131. package/src/audit/fetch.test.ts +216 -3
  132. package/src/audit/fetch.ts +270 -59
  133. package/src/audit/report-html.ts +5 -2
  134. package/src/audit/report-model.ts +9 -0
  135. package/src/audit/report.test.ts +22 -0
  136. package/src/audit/report.ts +3 -2
  137. package/src/audit/rules-doc.ts +2 -0
  138. package/src/audit/secrets.test.ts +303 -0
  139. package/src/audit/secrets.ts +406 -0
  140. package/src/audit/wrangler.test.ts +230 -0
  141. package/src/audit/wrangler.ts +290 -0
  142. package/src/build.ts +8 -3
  143. package/src/cli/command-group.ts +1 -1
  144. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  145. package/src/cli/commands/audit.test.ts +215 -1
  146. package/src/cli/commands/audit.ts +86 -17
  147. package/src/cli/commands/build.test.ts +167 -2
  148. package/src/cli/commands/build.ts +114 -23
  149. package/src/cli/handlers/build.ts +2 -0
  150. package/src/cli/handlers/components.test.ts +199 -1
  151. package/src/cli/handlers/components.ts +160 -3
  152. package/src/cli/handlers/graph.test.ts +20 -0
  153. package/src/cli/handlers/graph.ts +10 -1
  154. package/src/cli/handlers/lifecycle.ts +12 -4
  155. package/src/cli/handlers/operator.test.ts +255 -0
  156. package/src/cli/handlers/operator.ts +240 -0
  157. package/src/cli/handlers/scenario.test.ts +456 -0
  158. package/src/cli/handlers/scenario.ts +330 -0
  159. package/src/cli/main.test.ts +23 -0
  160. package/src/cli/main.ts +72 -1
  161. package/src/cli/mcp/server.test.ts +265 -2
  162. package/src/cli/mcp/server.ts +84 -7
  163. package/src/cli/mcp/types.ts +27 -1
  164. package/src/cli/registry.ts +14 -2
  165. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  166. package/src/codegen/docs-rule-scanning.ts +25 -2
  167. package/src/components/README.md +7 -0
  168. package/src/components/capability.ts +17 -2
  169. package/src/components/cli-support.test.ts +17 -0
  170. package/src/components/cli-support.ts +13 -1
  171. package/src/components/component-schema.test.ts +32 -0
  172. package/src/components/component.schema.json +6 -0
  173. package/src/components/component.test.ts +21 -0
  174. package/src/components/component.ts +15 -0
  175. package/src/components/driver.ts +12 -4
  176. package/src/components/verbs/index.ts +6 -1
  177. package/src/components/verbs/run-agent.test.ts +683 -0
  178. package/src/components/verbs/run-agent.ts +786 -0
  179. package/src/components/verbs/sign.test.ts +19 -0
  180. package/src/components/verbs/sign.ts +34 -2
  181. package/src/composite.ts +31 -2
  182. package/src/discovery/collect.ts +11 -2
  183. package/src/discovery/fold-import.test.ts +54 -0
  184. package/src/discovery/fold-import.ts +178 -38
  185. package/src/discovery/fold-rank.test.ts +197 -0
  186. package/src/discovery/fold-rank.ts +346 -0
  187. package/src/discovery/index.ts +16 -1
  188. package/src/discovery/param-deps.test.ts +118 -0
  189. package/src/discovery/param-deps.ts +170 -0
  190. package/src/fold/fold.test.ts +6 -2
  191. package/src/fold/fold.ts +184 -3
  192. package/src/fold/subset.test.ts +82 -19
  193. package/src/fold/subset.ts +79 -41
  194. package/src/lexicon-schema.ts +3 -0
  195. package/src/lexicon.ts +103 -2
  196. package/src/lifecycle/converge-ledger.test.ts +199 -0
  197. package/src/lifecycle/converge-ledger.ts +179 -0
  198. package/src/lifecycle/deep-diff.test.ts +79 -1
  199. package/src/lifecycle/deep-diff.ts +23 -0
  200. package/src/lifecycle/deep-observe.ts +13 -2
  201. package/src/lifecycle/gate-ledger.test.ts +103 -0
  202. package/src/lifecycle/gate-ledger.ts +140 -0
  203. package/src/lifecycle/git.test.ts +430 -0
  204. package/src/lifecycle/git.ts +446 -84
  205. package/src/lifecycle/index.ts +4 -0
  206. package/src/lifecycle/lease.test.ts +343 -0
  207. package/src/lifecycle/lease.ts +270 -0
  208. package/src/lifecycle/scenario-eval.test.ts +199 -0
  209. package/src/lifecycle/scenario-eval.ts +158 -0
  210. package/src/lifecycle/scenario.test.ts +195 -0
  211. package/src/lifecycle/scenario.ts +321 -0
  212. package/src/lifecycle/symptoms.test.ts +116 -0
  213. package/src/lifecycle/symptoms.ts +126 -0
  214. package/src/lint/output-docs.test.ts +220 -0
  215. package/src/lint/output-docs.ts +204 -0
  216. package/src/lint/post-synth.test.ts +97 -0
  217. package/src/lint/post-synth.ts +45 -0
  218. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  219. package/src/lint/rules/comp/comp.test.ts +49 -1
  220. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  221. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  222. package/src/lsp/lexicon-providers.test.ts +44 -0
  223. package/src/lsp/lexicon-providers.ts +11 -1
  224. package/src/op/activity-contract.test.ts +180 -0
  225. package/src/op/activity-contract.ts +278 -0
  226. package/src/op/builders-exports.test.ts +17 -1
  227. package/src/op/builders.ts +59 -5
  228. package/src/op/converge-rule.test.ts +179 -0
  229. package/src/op/converge-rule.ts +311 -0
  230. package/src/op/generate-pipeline.test.ts +53 -0
  231. package/src/op/generate-pipeline.ts +99 -0
  232. package/src/op/index.ts +30 -0
  233. package/src/op/local-executor.test.ts +92 -0
  234. package/src/op/local-executor.ts +45 -9
  235. package/src/op/op-verb-class.test.ts +126 -0
  236. package/src/op/op-verb-class.ts +115 -0
  237. package/src/op/operator.test.ts +346 -0
  238. package/src/op/operator.ts +213 -0
  239. package/src/op/step-output-ref.test.ts +334 -0
  240. package/src/op/step-output-ref.ts +453 -0
  241. package/src/op/types.ts +18 -1
  242. package/src/provenance.test.ts +151 -4
  243. package/src/provenance.ts +118 -4
  244. package/src/runtime-adapter.ts +31 -10
  245. package/src/serializer.ts +18 -0
  246. package/src/toml.test.ts +157 -384
  247. package/src/toml.ts +371 -5
@@ -0,0 +1,330 @@
1
+ import { resolve } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { commandBuildParams } from "../build-params-cli";
4
+ import { build, type BuildResult } from "../../build";
5
+ import { readEnvironmentSnapshots, fetchLifecycle } from "../../lifecycle/git";
6
+ import { buildChangeSet, type ChangeSet, type ChangeSetEntry } from "../../lifecycle/change-set";
7
+ import {
8
+ planReceipts,
9
+ mergeReceiptEntries,
10
+ observedValueResolver,
11
+ readReceiptValue,
12
+ type ReceiptReading,
13
+ } from "../../lifecycle/receipt-plan";
14
+ import { collectEffectReceipts, isEffectReceipt, type EffectReceiptDeclaration } from "../../effect-receipt";
15
+ import { evaluateScenario, type ScenarioVerdict } from "../../lifecycle/scenario-eval";
16
+ import { collectScenarios, type ScenarioDeclaration, type ScenarioGiven } from "../../lifecycle/scenario";
17
+ import { isResourceDeclarable } from "../../declarable";
18
+ import { loadChantConfig } from "../../config";
19
+ import { unknownEnvError } from "../../env";
20
+ import { collectBuildRootContributors } from "../plugins";
21
+ import { resolveBuildRoot } from "./lifecycle";
22
+ import { formatError, formatSuccess, formatBold } from "../format";
23
+ import type { CommandContext } from "../registry";
24
+ import type { LifecycleSnapshot } from "../../lifecycle/types";
25
+ import type { UnobservedEntity } from "../../observation";
26
+ import type { ResourceMetadata } from "../../lexicon";
27
+
28
+ /**
29
+ * `chant scenario check` (#1292) — evaluate every declared `Scenario`
30
+ * offline, against a fixture snapshot standing in for live observation.
31
+ *
32
+ * Mirrors `runLifecyclePlan` (./lifecycle.ts): build the project, walk every
33
+ * lexicon's declared entities, produce a `ChangeSet`. The one substitution is
34
+ * the whole point — a scenario's `given` fixture stands in for
35
+ * `observeLexicon`'s live read, so no plugin ever makes a network call and no
36
+ * credentials are ever asked for. `evaluateScenario` (../../lifecycle/scenario-eval.ts)
37
+ * checks the resulting change set against the scenario's `expect`.
38
+ *
39
+ * A scenario has exactly one fixture, not a before-and-after pair, so the
40
+ * change set it produces never has an `observedThen` to diff against — the
41
+ * `update` count is therefore always 0. That is a real, not a placeholder,
42
+ * limitation of what a single-snapshot scenario can assert: presence
43
+ * (`create`/`delete`/`noop`) and ownership are fully expressive here; drift
44
+ * since a prior read is not, because there is no prior read in this model,
45
+ * only the one fixture.
46
+ *
47
+ * Effect receipts (#1832) get the same replacement `runLifecyclePlan` gives
48
+ * them: `collectEffectReceipts` pulls every declared receipt out of the
49
+ * generic axis, `evaluateOneScenario` builds a {@link ReceiptReading} per
50
+ * receipt from the SAME fixture data that stands in for `observed.resources`/
51
+ * `observed.unobserved` elsewhere in this function, and `planReceipts` +
52
+ * `mergeReceiptEntries` (../../lifecycle/receipt-plan.ts) replace whatever the
53
+ * generic classification proposed for a receipt with its real `effect`
54
+ * classification — never a bare create/noop. A receipt whose reading can't be
55
+ * derived from the fixture (its lexicon has no fixture data, or a reference
56
+ * input the receipt depends on isn't among the fixture's recorded attributes)
57
+ * is classified exactly the way `runLifecyclePlan` classifies it: an
58
+ * unreadable receipt lands `unobserved`, loudly; a receipt whose reference
59
+ * input can't resolve still proposes the fire, with an "unresolved input"
60
+ * note, never a guessed digest.
61
+ */
62
+ export async function runScenarioCheck(ctx: CommandContext): Promise<number> {
63
+ const { args, plugins, serializers } = ctx;
64
+
65
+ const { config } = await loadChantConfig(resolve("."));
66
+ const declaredParams = await commandBuildParams(config.buildParams, args);
67
+ if (!declaredParams) return 1;
68
+
69
+ const buildResult = await build(resolveBuildRoot(args, config), serializers, undefined, {
70
+ buildParams: declaredParams,
71
+ buildRoots: collectBuildRootContributors(plugins, config as unknown as Record<string, unknown>, resolve(".")),
72
+ });
73
+ if (buildResult.errors.length > 0) {
74
+ console.error(formatError({ message: "Build failed — fix errors before checking scenarios" }));
75
+ return 1;
76
+ }
77
+
78
+ const scenarios = collectScenarios(buildResult.entities);
79
+ if (scenarios.size === 0) {
80
+ console.log("No scenarios declared — nothing to check.");
81
+ return 0;
82
+ }
83
+
84
+ // Same read surface `runLifecyclePlan` uses (lifecycle.ts) — computed once,
85
+ // ahead of the per-scenario loop, since it's a property of the build, not
86
+ // of any one scenario's fixture.
87
+ const receipts = collectEffectReceipts(buildResult.entities);
88
+
89
+ // `snapshot(env)` reads the chant/lifecycle orphan branch — fetch once,
90
+ // up front, the same pre-read `runLifecyclePlan` does, rather than once per
91
+ // scenario that needs it.
92
+ if ([...scenarios.values()].some((s) => s.given.kind === "env")) {
93
+ await fetchLifecycle();
94
+ }
95
+
96
+ const results: Array<{ entityName: string; scenario: ScenarioDeclaration; verdict: ScenarioVerdict; env: string }> = [];
97
+ for (const [entityName, scenario] of scenarios) {
98
+ if (scenario.given.kind === "env") {
99
+ const envErr = unknownEnvError(scenario.given.env, config.environments);
100
+ if (envErr) {
101
+ results.push({
102
+ entityName,
103
+ scenario,
104
+ env: scenario.given.env,
105
+ verdict: { pass: false, checks: [{ clause: "given", pass: false, detail: envErr }] },
106
+ });
107
+ continue;
108
+ }
109
+ }
110
+ const { verdict, env } = await evaluateOneScenario(scenario, buildResult, receipts);
111
+ results.push({ entityName, scenario, verdict, env });
112
+ }
113
+
114
+ if (args.json) {
115
+ console.log(
116
+ JSON.stringify(
117
+ results.map((r) => ({ name: r.scenario.name, entity: r.entityName, env: r.env, ...r.verdict })),
118
+ null,
119
+ 2,
120
+ ),
121
+ );
122
+ } else {
123
+ for (const r of results) printResult(r.entityName, r.scenario, r.env, r.verdict);
124
+ }
125
+
126
+ const failed = results.filter((r) => !r.verdict.pass).length;
127
+ const total = results.length;
128
+ if (failed > 0) {
129
+ console.error(formatError({ message: `${failed}/${total} scenario(s) failed` }));
130
+ return 1;
131
+ }
132
+ console.log(formatSuccess(`${total}/${total} scenario(s) passed`));
133
+ return 0;
134
+ }
135
+
136
+ function printResult(entityName: string, scenario: ScenarioDeclaration, env: string, verdict: ScenarioVerdict): void {
137
+ const status = verdict.pass ? formatSuccess("PASS") : "FAIL";
138
+ console.log(`\n${formatBold(scenario.name)} [${entityName}] — given ${describeGiven(scenario.given)}${env ? `, env ${env}` : ""}: ${status}`);
139
+ for (const check of verdict.checks) {
140
+ if (check.pass) continue;
141
+ console.log(` ${check.clause}: ${check.detail ?? "failed"}`);
142
+ }
143
+ }
144
+
145
+ /** Human-readable rendering of a scenario's `given`, for output and error messages. */
146
+ function describeGiven(given: ScenarioGiven): string {
147
+ return given.kind === "file" ? given.path : `env "${given.env}"`;
148
+ }
149
+
150
+ interface GivenResolution {
151
+ env: string;
152
+ /** Fixture data per lexicon it covers. Empty when nothing could be read. */
153
+ perLexicon: Map<string, LifecycleSnapshot>;
154
+ /** Set when the fixture itself could not be resolved — the scenario fails on this alone. */
155
+ error?: string;
156
+ }
157
+
158
+ /** Read `given`'s fixture data. Offline: a file read for `snapshot(path)`, a
159
+ * git-plumbing read of the already-fetched orphan branch for `snapshot(env)` —
160
+ * never a live provider call. */
161
+ async function resolveGiven(given: ScenarioGiven): Promise<GivenResolution> {
162
+ if (given.kind === "file") {
163
+ const abs = resolve(given.path);
164
+ let raw: string;
165
+ try {
166
+ raw = await readFile(abs, "utf8");
167
+ } catch {
168
+ return { env: "", perLexicon: new Map(), error: `fixture not found: ${given.path}` };
169
+ }
170
+ let snap: LifecycleSnapshot;
171
+ try {
172
+ snap = JSON.parse(raw) as LifecycleSnapshot;
173
+ } catch {
174
+ return { env: "", perLexicon: new Map(), error: `fixture is not valid JSON: ${given.path}` };
175
+ }
176
+ if (typeof snap.lexicon !== "string" || typeof snap.environment !== "string" || typeof snap.resources !== "object") {
177
+ return {
178
+ env: typeof snap.environment === "string" ? snap.environment : "",
179
+ perLexicon: new Map(),
180
+ error: `${given.path} is not a LifecycleSnapshot — missing lexicon/environment/resources`,
181
+ };
182
+ }
183
+ return { env: snap.environment, perLexicon: new Map([[snap.lexicon, snap]]) };
184
+ }
185
+
186
+ const stored = await readEnvironmentSnapshots(given.env);
187
+ if (stored.size === 0) {
188
+ return {
189
+ env: given.env,
190
+ perLexicon: new Map(),
191
+ error: `no recorded snapshot for environment "${given.env}" on chant/lifecycle — record one with \`chant lifecycle snapshot ${given.env}\``,
192
+ };
193
+ }
194
+ const perLexicon = new Map<string, LifecycleSnapshot>();
195
+ for (const [key, content] of stored) {
196
+ const snap = JSON.parse(content) as LifecycleSnapshot;
197
+ perLexicon.set(snap.lexicon ?? key, snap);
198
+ }
199
+ return { env: given.env, perLexicon };
200
+ }
201
+
202
+ /**
203
+ * Build the merged change set for one scenario and evaluate it. A lexicon the
204
+ * fixture has no data for is never silently read as "nothing declared, all
205
+ * absent" — every entity it declares is marked `unobserved` (#1089's own
206
+ * discipline: a hole the fixture cannot fill is a hole, not a guess).
207
+ *
208
+ * Effect receipts (#1832) get the same treatment `runLifecyclePlan` gives
209
+ * them (lifecycle.ts:1145,1184,1213-1242,1278-1280): a receipt joins the
210
+ * declared axis alongside ordinary resources, a {@link ReceiptReading} is
211
+ * built per receipt from the SAME fixture data (`observedNow`/`unobserved`)
212
+ * every other entity in this loop reads, and `planReceipts` +
213
+ * `mergeReceiptEntries` replace whatever the generic classification proposed
214
+ * for the receipt with the real `effect` classification before the change set
215
+ * ever reaches `evaluateScenario` — an unfired receipt never reads as a bare
216
+ * `create`, and a stale one never reads as a clean `noop`.
217
+ */
218
+ async function evaluateOneScenario(
219
+ scenario: ScenarioDeclaration,
220
+ buildResult: BuildResult,
221
+ receipts: ReadonlyMap<string, EffectReceiptDeclaration>,
222
+ ): Promise<{ verdict: ScenarioVerdict; env: string }> {
223
+ const resolved = await resolveGiven(scenario.given);
224
+ if (resolved.error) {
225
+ return {
226
+ env: resolved.env,
227
+ verdict: { pass: false, checks: [{ clause: "given", pass: false, detail: resolved.error }] },
228
+ };
229
+ }
230
+
231
+ const declaredByLexicon = new Map<string, Set<string>>();
232
+ for (const [name, entity] of buildResult.entities) {
233
+ // A receipt has no `props` payload of its own but is declared, diffed,
234
+ // and observed like any resource (#1832) — it joins the declared axis so
235
+ // its lexicon's fixture data can confirm presence, absence, or a hole,
236
+ // the same as `runLifecyclePlan` (lifecycle.ts:1184).
237
+ if (!isResourceDeclarable(entity) && !isEffectReceipt(entity)) continue;
238
+ if (!declaredByLexicon.has(entity.lexicon)) declaredByLexicon.set(entity.lexicon, new Set());
239
+ declaredByLexicon.get(entity.lexicon)!.add(name);
240
+ }
241
+
242
+ // Every fixture lexicon's `resources` merged, for resolving a receipt's
243
+ // reference inputs against the fixture the same way `runLifecyclePlan`
244
+ // resolves them against its merged live observation (lifecycle.ts:1149,1213).
245
+ const allObservedResources: Record<string, ResourceMetadata> = {};
246
+ const receiptReadings = new Map<string, ReceiptReading>();
247
+
248
+ const lexicons = new Set<string>([...declaredByLexicon.keys(), ...resolved.perLexicon.keys()]);
249
+ const entries: ChangeSetEntry[] = [];
250
+ for (const lexiconName of lexicons) {
251
+ const declared = declaredByLexicon.get(lexiconName) ?? new Set<string>();
252
+ const fixture = resolved.perLexicon.get(lexiconName);
253
+ const observedNow = fixture?.resources ?? {};
254
+ const unobserved: Record<string, UnobservedEntity> = { ...(fixture?.unobserved ?? {}) };
255
+ if (!fixture) {
256
+ for (const name of declared) {
257
+ if (!(name in unobserved)) {
258
+ unobserved[name] = {
259
+ reason: "filtered",
260
+ detail: `given ${describeGiven(scenario.given)} has no recorded data for lexicon "${lexiconName}"`,
261
+ };
262
+ }
263
+ }
264
+ }
265
+
266
+ Object.assign(allObservedResources, observedNow);
267
+ for (const [receiptName, receipt] of receipts) {
268
+ if (receipt.lexicon !== lexiconName) continue;
269
+ const live = observedNow[receiptName];
270
+ const hole = unobserved[receiptName];
271
+ if (live) {
272
+ receiptReadings.set(receiptName, {
273
+ observed: true,
274
+ present: true,
275
+ value: readReceiptValue(live.attributes),
276
+ type: live.type,
277
+ ...(live.physicalId ? { physicalId: live.physicalId } : {}),
278
+ lexicon: lexiconName,
279
+ });
280
+ } else if (hole) {
281
+ receiptReadings.set(receiptName, {
282
+ observed: false,
283
+ present: false,
284
+ lexicon: lexiconName,
285
+ ...(hole.type ? { type: hole.type } : {}),
286
+ unobservedReason: hole.reason,
287
+ ...(hole.detail ? { unobservedDetail: hole.detail } : {}),
288
+ });
289
+ } else {
290
+ // Neither in the fixture's resources nor named unobserved: the
291
+ // fixture stands in for a live read that confirmed the receipt
292
+ // absent — the same claim `buildChangeSet` reads off a bare
293
+ // observation (#1089).
294
+ receiptReadings.set(receiptName, { observed: true, present: false, lexicon: lexiconName });
295
+ }
296
+ }
297
+
298
+ // No `observedThen`: a scenario has one fixture, standing in for live
299
+ // observation only — never a second, prior read to diff against. See this
300
+ // module's top comment on what that means for `update`.
301
+ const cs = buildChangeSet(
302
+ resolved.env,
303
+ { declared, observedNow, observedThen: undefined, unobserved },
304
+ { lexicon: lexiconName },
305
+ );
306
+ entries.push(...cs.entries);
307
+ }
308
+ entries.sort((a, b) => a.name.localeCompare(b.name));
309
+
310
+ const merged: ChangeSet = { env: resolved.env, entries };
311
+ // Receipt classification (#1832): replace whatever the generic pass above
312
+ // proposed for a receipt — a `create` for one confirmed absent, a `noop`
313
+ // for one present but stale — with the real `effect` classification, the
314
+ // same replacement `runLifecyclePlan` performs (lifecycle.ts:1278-1280).
315
+ if (receipts.size > 0) {
316
+ const receiptEntries = planReceipts(receipts, receiptReadings, observedValueResolver(allObservedResources));
317
+ mergeReceiptEntries(merged, receipts, receiptEntries);
318
+ }
319
+
320
+ return { env: resolved.env, verdict: evaluateScenario(merged, scenario.expect) };
321
+ }
322
+
323
+ /** Fallback for `chant scenario <unknown subcommand>` — mirrors `runLifecycleUnknown`. */
324
+ export async function runScenarioUnknown(ctx: CommandContext): Promise<number> {
325
+ console.error(formatError({
326
+ message: `Unknown scenario subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
327
+ hint: "Available: chant scenario check",
328
+ }));
329
+ return 1;
330
+ }
@@ -383,6 +383,29 @@ describe("parseArgs", () => {
383
383
  expect(() => parseArgs(["run", "myop", "--json=1"])).toThrow(/--json is a boolean flag/);
384
384
  });
385
385
 
386
+ test("--fold-rank has the same context-sensitive bare-vs-path behavior as --report (#1083)", () => {
387
+ // Bare --fold-rank is the boolean (print the ranked report); --fold-rank
388
+ // <path> is the collapsed-format export destination.
389
+ const bare = parseArgs(["build", "src", "--fold", "--fold-rank"]);
390
+ expect(bare.foldRank).toBe(true);
391
+ expect(bare.foldRankCollapsedFile).toBeUndefined();
392
+
393
+ const withPath = parseArgs(["build", "src", "--fold", "--fold-rank", "out.collapsed"]);
394
+ expect(withPath.foldRankCollapsedFile).toBe("out.collapsed");
395
+ expect(withPath.foldRank).toBeUndefined();
396
+
397
+ // A following flag (not a path) keeps the bare-boolean reading.
398
+ const beforeFlag = parseArgs(["build", "src", "--fold-rank", "--verbose"]);
399
+ expect(beforeFlag.foldRank).toBe(true);
400
+ expect(beforeFlag.verbose).toBe(true);
401
+ });
402
+
403
+ test("--fold-rank=<path> (joined form) resolves the same as the space-separated one", () => {
404
+ const result = parseArgs(["build", "src", "--fold-rank=out.collapsed"]);
405
+ expect(result.foldRankCollapsedFile).toBe("out.collapsed");
406
+ expect(result.foldRank).toBeUndefined();
407
+ });
408
+
386
409
  test("--report keeps its context-sensitive bare-vs-value behavior when joined", () => {
387
410
  // --report is deliberately not in the boolean-reject set: bare --report is
388
411
  // a boolean (`run`), but --report <path> is a SARIF destination (migrate).
package/src/cli/main.ts CHANGED
@@ -24,11 +24,13 @@ import { runCarveEmit } from "./handlers/carve-emit";
24
24
  import { runCarveBridge } from "./handlers/carve-bridge";
25
25
  import { runCarveApply } from "./handlers/carve-apply";
26
26
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleTeardown, runLifecycleUnknown } from "./handlers/lifecycle";
27
- import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
27
+ import { runComponentsStatus, runComponentsReleaseRecord, runComponentsExport, runComponentsUnknown } from "./handlers/components";
28
+ import { runScenarioCheck, runScenarioUnknown } from "./handlers/scenario";
28
29
  import { runGraph } from "./handlers/graph";
29
30
  import { runExplain } from "./handlers/explain";
30
31
  import { runSearch } from "./handlers/search";
31
32
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
33
+ import { runOperator, runOperatorStatus, runApprove } from "./handlers/operator";
32
34
  import { runEmulator } from "./handlers/emulator";
33
35
  import { splitJoinedFlags, dispatchCommandGroup, collectCommandGroups, formatCommandGroupsHelp, type CommandGroup } from "./command-group";
34
36
  import type { LexiconPlugin } from "../lexicon";
@@ -80,6 +82,7 @@ const BOOLEAN_FLAGS = new Set([
80
82
  "--sandbox",
81
83
  "--yes",
82
84
  "--confirm-prod",
85
+ "--once",
83
86
  ]);
84
87
 
85
88
  /**
@@ -334,6 +337,17 @@ export function parseArgs(args: string[]): ParsedArgs {
334
337
  result.fold = false;
335
338
  } else if (arg === "--sandbox") {
336
339
  result.sandbox = true;
340
+ } else if (arg === "--fold-rank") {
341
+ // chant #1083 — same context-sensitive shape as --report above:
342
+ // `--fold-rank` alone prints the ranked-blocker report; `--fold-rank
343
+ // <path>` ALSO writes the Brendan Gregg collapsed-format export there.
344
+ const next = args[i + 1];
345
+ if (next && !next.startsWith("-")) {
346
+ result.foldRankCollapsedFile = next;
347
+ i++;
348
+ } else {
349
+ result.foldRank = true;
350
+ }
337
351
  } else if (arg === "--param") {
338
352
  // chant #1118/#1127 — `--param name=value` (space-separated) and
339
353
  // `--param=name=value` (joined, split above at its first `=` into flag
@@ -347,6 +361,14 @@ export function parseArgs(args: string[]): ParsedArgs {
347
361
  result.paramsFile = args[++i];
348
362
  } else if (arg === "--projection") {
349
363
  result.projection = args[++i];
364
+ } else if (arg === "--interval") {
365
+ result.interval = args[++i];
366
+ } else if (arg === "--lease-ttl") {
367
+ result.leaseTtl = args[++i];
368
+ } else if (arg === "--once") {
369
+ result.once = true;
370
+ } else if (arg === "--note") {
371
+ result.note = args[++i];
350
372
  } else if (arg.startsWith("--")) {
351
373
  // chant #1127 — every recognized flag is matched above; anything left
352
374
  // starting with `--` is unrecognized, whether it arrived bare
@@ -462,6 +484,24 @@ Ops:
462
484
  consumer to render live wave/component/phase/step
463
485
  progress instead of tailing raw logs (additive; run
464
486
  semantics/exit code unchanged)
487
+ operator Run scheduled ticks for discovered ConvergeOps
488
+ locally, no Temporal (#1485): acquire/renew a per-op
489
+ lease (git ref CAS), tick on --interval, record every
490
+ result as a ledger fact. --env <env> scopes to one
491
+ environment; --interval <dur> (default 60s) and
492
+ --lease-ttl <dur> (default 5m) tune cadence; --once
493
+ runs a single round and exits (cron/systemd-timer/
494
+ CronJob invokers use this instead of the daemon)
495
+ operator status Last tick, outcomes, and pending gates per
496
+ ConvergeOp, read from the chant/lifecycle orphan
497
+ branch alone — no daemon needs to be running
498
+ (--env <env>, --json)
499
+ approve <op> <gate> Record a gate's out-of-band resolution fact
500
+ (--actor <name>, --note <text>) — the durable
501
+ counterpart to a converge tick's gate-as-fact
502
+ outcome; see the pending-gates list in operator
503
+ status. Does not itself unblock the gated op's local
504
+ dispatch (re-run --temporal, or merge its PR)
465
505
 
466
506
  graph Show Op dependency graph (--stacks for cross-stack order,
467
507
  --format ir|mermaid|dot|layout for the lint-gated graph IR,
@@ -496,6 +536,12 @@ Lifecycle (alias: lc):
496
536
  --confirm-prod, or an interactive confirmation)
497
537
  lifecycle log [env] History of lifecycle snapshots
498
538
 
539
+ Plan scenarios (#1292):
540
+ scenario check Evaluate every declared Scenario's expect clause
541
+ offline, against its given fixture (no cloud, no
542
+ credentials); --json: emit verdicts as JSON.
543
+ Nonzero exit on any failing scenario.
544
+
499
545
  Component release ledger + status:
500
546
  components status [env] What's built vs what's deployed where, joined by
501
547
  digest (--live: reconcile against live+ownership;
@@ -505,6 +551,11 @@ Component release ledger + status:
505
551
  components release <env> Append one immutable release record
506
552
  (--component <name> --digest <sha256:...>
507
553
  [--git-sha <sha>] [--run-id <id>] [--actor <name>])
554
+ components export <env> Materialize a persisted build archive manifest to
555
+ a portable directory (--component <name>
556
+ [--digest <manifestDigest>] -o <dir> [--json]);
557
+ copies every image/template/asset/sbom entry
558
+ byte-for-byte plus a self-describing manifest.json
508
559
 
509
560
  Lexicon development:
510
561
  dev generate Generate lexicon artifacts (+ validate + coverage)
@@ -590,6 +641,14 @@ Options:
590
641
  --no-fold (build) Opt out of folding for this invocation: every
591
642
  source module is imported and run, the pre-#1134
592
643
  behavior. Beats chant.config.ts's build.fold.
644
+ --fold-rank [<path>] (build, with --fold) Rank run-mode files by dominator
645
+ retained-count over the forward import-failure graph
646
+ (#1083) — fixing the top blocker unblocks the most
647
+ files. Files held back only by the reverse rule
648
+ (#1044) are reported separately, never folded into
649
+ the ranking. With a path argument, also writes a
650
+ Brendan Gregg collapsed-format export (weighted by
651
+ retained count) there, for any flame/icicle viewer.
593
652
  --sandbox (build) Run run-fallback source files (or every
594
653
  file, without --fold) together, isolated, in one
595
654
  sandboxed child process instead of in-process
@@ -620,6 +679,7 @@ Examples:
620
679
  chant build ./infra/ --components --generate gitlab --output .gitlab-ci.yml
621
680
  chant run --components search-service --env staging
622
681
  chant run --components all --env production
682
+ chant components export prod --component search-service -o ./dist/search-service
623
683
  chant import template.json --output ./infra/
624
684
  chant import --from prod --name my-bucket --output src/
625
685
  chant lint ./infra/
@@ -780,6 +840,10 @@ const registry: CommandDef[] = [
780
840
  { name: "run log", handler: runOpLog },
781
841
  { name: "run", handler: runOp },
782
842
 
843
+ { name: "operator status", handler: runOperatorStatus },
844
+ { name: "operator", handler: runOperator },
845
+ { name: "approve", handler: runApprove },
846
+
783
847
  { name: "graph", handler: runGraph },
784
848
  { name: "vendor", handler: runVendor },
785
849
 
@@ -793,9 +857,15 @@ const registry: CommandDef[] = [
793
857
  { name: "lifecycle teardown", requiresPlugins: true, handler: runLifecycleTeardown },
794
858
  { name: "lifecycle log", handler: runLifecycleLog },
795
859
 
860
+ // Plan scenarios (#1292) — fully offline, no plugin network calls, but
861
+ // requiresPlugins:true so the build step has serializers to partition
862
+ // against, same as every other build-then-something verb.
863
+ { name: "scenario check", requiresPlugins: true, handler: runScenarioCheck },
864
+
796
865
  // Component release ledger + status surface (#568, epic #551)
797
866
  { name: "components status", requiresPlugins: true, handler: runComponentsStatus },
798
867
  { name: "components release", handler: runComponentsReleaseRecord },
868
+ { name: "components export", handler: runComponentsExport },
799
869
 
800
870
  // Local emulators of configured lexicons (#920). Compound so the action word
801
871
  // lands in args.path (not consumed as a project dir) and projectPath is forced ".".
@@ -811,6 +881,7 @@ const registry: CommandDef[] = [
811
881
  { name: "carve", handler: runCarveUnknown },
812
882
  { name: "emulator", requiresPlugins: true, handler: runEmulator },
813
883
  { name: "lifecycle", handler: runLifecycleUnknown },
884
+ { name: "scenario", handler: runScenarioUnknown },
814
885
  { name: "dev", handler: runDevUnknown },
815
886
  { name: "serve", handler: runServeUnknown },
816
887
  { name: "components", handler: runComponentsUnknown },