@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
@@ -5,7 +5,7 @@ import { spawn as spawnChild, type ChildProcess } from "node:child_process";
5
5
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
6
6
  import { discoverOps } from "../../op/discover";
7
7
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
- import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
8
+ import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
9
9
  import { renderHuman, renderJson } from "../../op/local-output";
10
10
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
11
11
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
@@ -15,11 +15,13 @@ import {
15
15
  connectionOptions,
16
16
  resolveWorkflowId,
17
17
  resolveProfile,
18
+ fetchNormalizedHistory,
18
19
  type WorkflowHandleRaw,
19
20
  type WorkflowExecutionDescription,
20
21
  type WorkflowHistoryRaw,
21
22
  } from "./run-client";
22
23
  import { generateReport, writeReport } from "./run-report";
24
+ import { extractStepRecords, countActivities, queryGateState } from "./op-progress";
23
25
  import { runComponents, resolveComponentTargets, findComponentGate, listComponents } from "../../components/cli-support";
24
26
  import { renderDriverHuman, renderDriverJson } from "../../components/driver-output";
25
27
  import { ndjsonProgressSink } from "../../components/run-progress";
@@ -250,12 +252,12 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
250
252
  if (ctx.args.components) return runComponentStatus(ctx, name);
251
253
 
252
254
  const projectPath = resolve(".");
253
- let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw;
255
+ let client, desc: WorkflowExecutionDescription, history: WorkflowHistoryRaw, handle: WorkflowHandleRaw;
254
256
  try {
255
257
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
256
- const handle = client.workflow.getHandle(resolveWorkflowId(name));
258
+ handle = client.workflow.getHandle(resolveWorkflowId(name));
257
259
  desc = await handle.describe();
258
- history = await handle.fetchHistory();
260
+ history = await fetchNormalizedHistory(handle);
259
261
  } catch (err) {
260
262
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
261
263
  return 1;
@@ -269,13 +271,19 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
269
271
  console.log(` Started : ${desc.startTime.toISOString()}`);
270
272
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
271
273
 
272
- const events = history.events ?? [];
273
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
274
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
274
+ const { completed, scheduled } = countActivities(history);
275
275
  if (scheduled > 0) {
276
276
  console.log(` Activities : ${completed}/${scheduled} completed`);
277
277
  }
278
278
 
279
+ // Queryable gate state (#1676) — undefined means the query itself failed
280
+ // (an ungated Op never registers a "gateState" handler), so print nothing;
281
+ // null means the query answered "no gate pending" — also nothing to print.
282
+ const gate = await queryGateState(handle);
283
+ if (gate) {
284
+ console.log(` Gate : ${gate.signalName}${gate.description ? ` — ${gate.description}` : ""} (waiting since ${gate.since})`);
285
+ }
286
+
279
287
  return 0;
280
288
  }
281
289
 
@@ -293,7 +301,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
293
301
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
294
302
  const handle = client.workflow.getHandle(componentWorkflowId(name));
295
303
  desc = await handle.describe();
296
- history = await handle.fetchHistory();
304
+ history = await fetchNormalizedHistory(handle);
297
305
  } catch (err) {
298
306
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
299
307
  return 1;
@@ -307,9 +315,7 @@ async function runComponentStatus(ctx: CommandContext, name: string): Promise<nu
307
315
  console.log(` Started : ${desc.startTime.toISOString()}`);
308
316
  if (desc.closeTime) console.log(` Closed : ${desc.closeTime.toISOString()}`);
309
317
 
310
- const events = history.events ?? [];
311
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
312
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
318
+ const { completed, scheduled } = countActivities(history);
313
319
  if (scheduled > 0) {
314
320
  console.log(` Activities : ${completed}/${scheduled} completed`);
315
321
  }
@@ -528,9 +534,7 @@ async function waitForTemporalServer(address: string, maxWaitMs = 30_000): Promi
528
534
  }
529
535
 
530
536
  function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
531
- const events = history.events ?? [];
532
- const completed = events.filter((e) => e.eventType === "ActivityTaskCompleted").length;
533
- const scheduled = events.filter((e) => e.eventType === "ActivityTaskScheduled").length;
537
+ const { completed, scheduled } = countActivities(history);
534
538
  process.stderr.write(
535
539
  `\r${formatInfo(`[${opName}]`)} ${completed}/${scheduled} activities completed`,
536
540
  );
@@ -954,7 +958,7 @@ async function runComponentTemporal(
954
958
  while (true) {
955
959
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
956
960
  const desc = await handle.describe();
957
- const history = await handle.fetchHistory();
961
+ const history = await fetchNormalizedHistory(handle);
958
962
  renderProgress(component.name, history);
959
963
  if (TERMINAL_STATUSES.has(desc.status.name)) {
960
964
  process.stderr.write("\n");
@@ -1160,7 +1164,7 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1160
1164
  ({ client } = await makeTemporalClient(ctx.args.profile, projectPath));
1161
1165
  const handle = client.workflow.getHandle(resolveWorkflowId(opName));
1162
1166
  desc = await handle.describe();
1163
- history = await handle.fetchHistory();
1167
+ history = await fetchNormalizedHistory(handle);
1164
1168
  } catch (err) {
1165
1169
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
1166
1170
  return 1;
@@ -1248,7 +1252,23 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1248
1252
  return 1;
1249
1253
  }
1250
1254
 
1251
- // Poll for progress until terminal state
1255
+ // Poll for progress until terminal state.
1256
+ //
1257
+ // `--progress-json` (chant #1676): stream one NDJSON StepRecord per line to
1258
+ // stdout as each declared step settles — the durable-path counterpart to
1259
+ // `chant run <name> --json`'s local-executor record shape (op-progress.ts).
1260
+ // Purely additive: when the flag is absent `emitProgress` stays undefined
1261
+ // and every `emitProgress?.(...)` call is a no-op, so poll-loop behavior
1262
+ // is unchanged. `extractStepRecords` is stable and append-only across
1263
+ // polls (same declared order, a growing history), so a plain emitted-count
1264
+ // watermark is enough to emit only what's new each round.
1265
+ const emitProgress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
1266
+ let emittedCount = 0;
1267
+ const emitNewRecords = (records: StepRecord[]) => {
1268
+ for (const record of records.slice(emittedCount)) emitProgress?.(record);
1269
+ emittedCount = records.length;
1270
+ };
1271
+
1252
1272
  let finalDesc: WorkflowExecutionDescription | undefined;
1253
1273
  let finalHistory: WorkflowHistoryRaw | undefined;
1254
1274
 
@@ -1256,12 +1276,14 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
1256
1276
  while (true) {
1257
1277
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1258
1278
  const desc = await handle.describe();
1259
- const history = await handle.fetchHistory();
1279
+ const history = await fetchNormalizedHistory(handle);
1260
1280
 
1261
1281
  renderProgress(opName, history);
1282
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history));
1262
1283
 
1263
1284
  if (TERMINAL_STATUSES.has(desc.status.name)) {
1264
1285
  process.stderr.write("\n");
1286
+ if (emitProgress) emitNewRecords(extractStepRecords(config, history, { final: true }));
1265
1287
  finalDesc = desc;
1266
1288
  finalHistory = history;
1267
1289
  break;
@@ -0,0 +1,456 @@
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { BuildResult } from "../../build";
6
+ import type { ParsedArgs } from "../registry";
7
+ import { Scenario, snapshot } from "../../lifecycle/scenario";
8
+ import { EffectReceipt, receiptExpectation, type EffectReceiptDeclaration } from "../../effect-receipt";
9
+ import type { ResourceMetadata } from "../../lexicon";
10
+ import type { LifecycleSnapshot } from "../../lifecycle/types";
11
+
12
+ const buildMock = vi.fn();
13
+ const fetchLifecycleMock = vi.fn();
14
+ const readEnvironmentSnapshotsMock = vi.fn();
15
+ const loadChantConfigMock = vi.fn();
16
+
17
+ vi.mock("../../build", async () => {
18
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
19
+ return { ...actual, build: (...args: unknown[]) => buildMock(...args) };
20
+ });
21
+ vi.mock("../../lifecycle/git", () => ({
22
+ fetchLifecycle: () => fetchLifecycleMock(),
23
+ readEnvironmentSnapshots: (...args: unknown[]) => readEnvironmentSnapshotsMock(...args),
24
+ }));
25
+ vi.mock("../../config", async () => {
26
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
27
+ return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
28
+ });
29
+
30
+ const { runScenarioCheck, runScenarioUnknown } = await import("./scenario");
31
+
32
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
33
+ return {
34
+ command: "scenario",
35
+ path: "check",
36
+ format: "",
37
+ fix: false,
38
+ watch: false,
39
+ verbose: false,
40
+ help: false,
41
+ live: false,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ /** A synthetic BuildResult — same shape ./lifecycle.test.ts's makeBuildResult
47
+ * uses: plain `{lexicon, entityType, props}` objects, not real Declarables,
48
+ * since `isResourceDeclarable` only checks for a `props` key. Scenario
49
+ * entities must be real `Scenario(...)` objects — collection keys on the
50
+ * marker symbol. */
51
+ function makeBuildResult(
52
+ resourcesByLexicon: Record<string, string[]>,
53
+ scenarios: Record<string, ReturnType<typeof Scenario>>,
54
+ receipts: Record<string, EffectReceiptDeclaration> = {},
55
+ ): BuildResult {
56
+ const entities = new Map<string, unknown>();
57
+ for (const [lexicon, names] of Object.entries(resourcesByLexicon)) {
58
+ for (const name of names) entities.set(name, { lexicon, entityType: `${lexicon}::Mock`, props: {} });
59
+ }
60
+ for (const [name, decl] of Object.entries(scenarios)) entities.set(name, decl);
61
+ for (const [name, decl] of Object.entries(receipts)) entities.set(name, decl);
62
+ return {
63
+ outputs: new Map(Object.keys(resourcesByLexicon).map((l) => [l, "{}"])),
64
+ entities,
65
+ dependencies: new Map(),
66
+ errors: [],
67
+ warnings: [],
68
+ manifest: { lexicons: Object.keys(resourcesByLexicon), outputs: {}, deployOrder: Object.keys(resourcesByLexicon) },
69
+ sourceFileCount: 1,
70
+ } as unknown as BuildResult;
71
+ }
72
+
73
+ /**
74
+ * An aws-materialized effect receipt shaped exactly like
75
+ * lexicons/aws/src/effect-receipt-row.ts's real factory (an `AWS::SSM::Parameter`
76
+ * row with `props: { Type: "String" }`) — built inline off the core
77
+ * `EffectReceipt` factory rather than importing the lexicon package, the same
78
+ * "minimal test EffectReceipt" pattern receipt-plan.test.ts uses. Carrying
79
+ * `props` is the load-bearing detail: it is what made the reviewer's bug
80
+ * reproducible — `isResourceDeclarable` (packages/core/src/declarable.ts) key
81
+ * on `"props" in value`, so an aws-materialized receipt WAS swept into
82
+ * `evaluateOneScenario`'s old isResourceDeclarable-only declared axis and
83
+ * generically misclassified, exactly as a bare `AWS::S3::Bucket` would be.
84
+ */
85
+ function makeAwsReceipt(
86
+ name: string,
87
+ effect: string,
88
+ flavor: "existence" | "hash",
89
+ inputs?: Record<string, unknown>,
90
+ ): EffectReceiptDeclaration {
91
+ const core = EffectReceipt(name, { effect, flavor, inputs });
92
+ return {
93
+ ...core,
94
+ lexicon: "aws",
95
+ entityType: "AWS::SSM::Parameter",
96
+ props: { Type: "String" },
97
+ } as unknown as EffectReceiptDeclaration;
98
+ }
99
+
100
+ const meta = (overrides: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
101
+ type: "AWS::S3::Bucket",
102
+ status: "CREATE_COMPLETE",
103
+ ownership: "owned",
104
+ ...overrides,
105
+ });
106
+
107
+ const snap = (overrides: Partial<LifecycleSnapshot> = {}): LifecycleSnapshot => ({
108
+ lexicon: "aws",
109
+ environment: "prod",
110
+ commit: "abc123",
111
+ timestamp: "2026-01-01T00:00:00Z",
112
+ resources: {},
113
+ ...overrides,
114
+ });
115
+
116
+ describe("runScenarioCheck", () => {
117
+ let stdoutBuf: string[];
118
+ let stderrBuf: string[];
119
+ let tmpFiles: string[];
120
+
121
+ beforeEach(() => {
122
+ stdoutBuf = [];
123
+ stderrBuf = [];
124
+ tmpFiles = [];
125
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
126
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
127
+ buildMock.mockReset();
128
+ fetchLifecycleMock.mockReset();
129
+ fetchLifecycleMock.mockResolvedValue(undefined);
130
+ readEnvironmentSnapshotsMock.mockReset();
131
+ readEnvironmentSnapshotsMock.mockResolvedValue(new Map());
132
+ loadChantConfigMock.mockReset();
133
+ loadChantConfigMock.mockResolvedValue({ config: { environments: [{ name: "prod" }, { name: "staging" }] } });
134
+ });
135
+
136
+ afterEach(async () => {
137
+ for (const d of tmpFiles) await rm(d, { recursive: true, force: true });
138
+ });
139
+
140
+ function combined(): string {
141
+ return [...stdoutBuf, ...stderrBuf].join("\n");
142
+ }
143
+
144
+ async function writeFixture(content: LifecycleSnapshot): Promise<string> {
145
+ const dir = await mkdtemp(join(tmpdir(), "chant-scenario-"));
146
+ const path = join(dir, "baseline.json");
147
+ await writeFile(path, JSON.stringify(content));
148
+ tmpFiles.push(dir);
149
+ return path;
150
+ }
151
+
152
+ test("no scenarios declared: exits 0 and says so", async () => {
153
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, {}));
154
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
155
+ expect(exit).toBe(0);
156
+ expect(stdoutBuf.join("\n")).toContain("No scenarios declared");
157
+ });
158
+
159
+ test("build failure exits 1 before any scenario runs", async () => {
160
+ buildMock.mockResolvedValue({ ...makeBuildResult({}, {}), errors: [{}] });
161
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
162
+ expect(exit).toBe(1);
163
+ expect(stderrBuf.join("\n")).toContain("Build failed");
164
+ });
165
+
166
+ test("a noop scenario passes against a fixture matching the declared entity", async () => {
167
+ const fixturePath = await writeFixture(snap({ resources: { bucket: meta() } }));
168
+ const scenario = Scenario("plan-neutral", { given: snapshot(fixturePath), expect: { noop: true } });
169
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { plan: scenario }));
170
+
171
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
172
+ expect(exit).toBe(0);
173
+ expect(stdoutBuf.join("\n")).toContain("plan-neutral");
174
+ expect(stdoutBuf.join("\n")).toContain("PASS");
175
+ });
176
+
177
+ test("a noop scenario fails with a legible detail when the fixture lacks a declared resource (create proposed)", async () => {
178
+ const fixturePath = await writeFixture(snap({ resources: {} }));
179
+ const scenario = Scenario("should-be-neutral", { given: snapshot(fixturePath), expect: { noop: true } });
180
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { plan: scenario }));
181
+
182
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
183
+ expect(exit).toBe(1);
184
+ const out = stdoutBuf.join("\n");
185
+ expect(out).toContain("FAIL");
186
+ expect(out).toContain("noop:");
187
+ expect(out).toContain("1 create");
188
+ expect(out).toContain("bucket");
189
+ });
190
+
191
+ test("a deletes scenario passes when the fixture's extra owned resource is proposed for delete", async () => {
192
+ const fixturePath = await writeFixture(snap({ resources: { legacy: meta({ ownership: "owned" }) } }));
193
+ const scenario = Scenario("drop legacy", {
194
+ given: snapshot(fixturePath),
195
+ expect: { deletes: [{ name: "legacy", ownership: "owned" }], create: 0, update: 0 },
196
+ });
197
+ buildMock.mockResolvedValue(makeBuildResult({}, { plan: scenario }));
198
+
199
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
200
+ expect(exit).toBe(0);
201
+ });
202
+
203
+ // buildChangeSet never classifies a non-owned resource as `delete` (only
204
+ // `adopt`) — the ownership safety property is structural, upstream of
205
+ // evaluateScenario. A `deletes` expectation against a foreign resource
206
+ // therefore fails as "not proposed", never as an ownership mismatch; that
207
+ // IS the assertion worth making — the scenario correctly refuses to treat
208
+ // an adopt candidate as the delete it expected.
209
+ test("a deletes scenario fails and names the resource when the fixture marks it foreign (never auto-deleted)", async () => {
210
+ const fixturePath = await writeFixture(snap({ resources: { legacy: meta({ ownership: "foreign" }) } }));
211
+ const scenario = Scenario("drop legacy", {
212
+ given: snapshot(fixturePath),
213
+ expect: { deletes: [{ name: "legacy", ownership: "owned" }] },
214
+ });
215
+ buildMock.mockResolvedValue(makeBuildResult({}, { plan: scenario }));
216
+
217
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
218
+ expect(exit).toBe(1);
219
+ const out = stdoutBuf.join("\n");
220
+ expect(out).toContain("deletes:");
221
+ expect(out).toContain("legacy");
222
+ expect(out).toContain("not proposed for delete");
223
+ });
224
+
225
+ test("given: snapshot(path) — a missing fixture file fails the scenario, not the whole run", async () => {
226
+ const scenario = Scenario("s", { given: snapshot("fixtures/does-not-exist.json"), expect: { noop: true } });
227
+ buildMock.mockResolvedValue(makeBuildResult({}, { plan: scenario }));
228
+
229
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
230
+ expect(exit).toBe(1);
231
+ expect(stdoutBuf.join("\n")).toContain("fixture not found");
232
+ });
233
+
234
+ test("given: snapshot(env) — an unknown environment fails with the declared-environments hint", async () => {
235
+ const scenario = Scenario("s", { given: snapshot("nowhere"), expect: { noop: true } });
236
+ buildMock.mockResolvedValue(makeBuildResult({}, { plan: scenario }));
237
+
238
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
239
+ expect(exit).toBe(1);
240
+ expect(stdoutBuf.join("\n")).toMatch(/Unknown environment/);
241
+ });
242
+
243
+ test("given: snapshot(env) — no recorded snapshot fails with a recording hint", async () => {
244
+ readEnvironmentSnapshotsMock.mockResolvedValue(new Map());
245
+ const scenario = Scenario("s", { given: snapshot("prod"), expect: { noop: true } });
246
+ buildMock.mockResolvedValue(makeBuildResult({}, { plan: scenario }));
247
+
248
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
249
+ expect(exit).toBe(1);
250
+ expect(stdoutBuf.join("\n")).toContain("chant lifecycle snapshot prod");
251
+ expect(fetchLifecycleMock).toHaveBeenCalled();
252
+ });
253
+
254
+ test("given: snapshot(env) — replays every recorded lexicon's snapshot", async () => {
255
+ readEnvironmentSnapshotsMock.mockResolvedValue(
256
+ new Map([["aws", JSON.stringify(snap({ resources: { bucket: meta() } }))]]),
257
+ );
258
+ const scenario = Scenario("s", { given: snapshot("prod"), expect: { noop: true } });
259
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { plan: scenario }));
260
+
261
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
262
+ expect(exit).toBe(0);
263
+ });
264
+
265
+ test("a lexicon the fixture doesn't cover is marked unobserved, not silently create", async () => {
266
+ const fixturePath = await writeFixture(snap({ lexicon: "aws", resources: { bucket: meta() } }));
267
+ const scenario = Scenario("multi-lexicon", {
268
+ given: snapshot(fixturePath),
269
+ expect: { unobserved: "refuse" },
270
+ });
271
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"], k8s: ["web"] }, { plan: scenario }));
272
+
273
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
274
+ expect(exit).toBe(1);
275
+ const out = stdoutBuf.join("\n");
276
+ expect(out).toContain("unobserved:");
277
+ expect(out).toContain("web");
278
+ expect(out).not.toContain("1 create"); // bucket must not have become a spurious pass/fail signal here
279
+ });
280
+
281
+ test("multiple scenarios: overall exit reflects any failure, and every scenario is reported", async () => {
282
+ const fixturePath = await writeFixture(snap({ resources: { bucket: meta() } }));
283
+ const passing = Scenario("passing", { given: snapshot(fixturePath), expect: { noop: true } });
284
+ const failing = Scenario("failing", { given: snapshot(fixturePath), expect: { create: 5 } });
285
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { passing, failing }));
286
+
287
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
288
+ expect(exit).toBe(1);
289
+ const out = combined();
290
+ expect(out).toContain("passing");
291
+ expect(out).toContain("failing");
292
+ expect(out).toContain("1/2 scenario(s) failed");
293
+ });
294
+
295
+ test("--json emits every scenario's verdict as structured data", async () => {
296
+ const fixturePath = await writeFixture(snap({ resources: { bucket: meta() } }));
297
+ const scenario = Scenario("plan-neutral", { given: snapshot(fixturePath), expect: { noop: true } });
298
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }, { plan: scenario }));
299
+
300
+ const exit = await runScenarioCheck({ args: makeArgs({ json: true }), plugins: [], serializers: [] });
301
+ expect(exit).toBe(0);
302
+ const parsed = JSON.parse(stdoutBuf[0]);
303
+ expect(parsed).toEqual([{ name: "plan-neutral", entity: "plan", env: "prod", pass: true, checks: [{ clause: "noop", pass: true }] }]);
304
+ });
305
+
306
+ // ───────────────────────────────────────────────────────────────────────
307
+ // Effect receipts (#1832 parity): the reviewer's two reproductions against
308
+ // `evaluateOneScenario`, now that it routes receipts through the same
309
+ // collectEffectReceipts → planReceipts → mergeReceiptEntries pipeline
310
+ // `runLifecyclePlan` uses (lifecycle.ts), instead of classifying them
311
+ // through isResourceDeclarable + buildChangeSet alone.
312
+ // ───────────────────────────────────────────────────────────────────────
313
+
314
+ test("a declared-but-unfired receipt classifies as effect, never as a generic create", async () => {
315
+ // Confirmed absent: the fixture's aws lexicon has no "dbMigrated" entry
316
+ // in `resources` and names nothing `unobserved` for it — the same
317
+ // "looked and it's not there" claim a live read makes.
318
+ const receipt = makeAwsReceipt("dbMigrated", "db-migrate", "existence");
319
+ const fixturePath = await writeFixture(snap({ lexicon: "aws", resources: {} }));
320
+ const scenario = Scenario("nothing pending", { given: snapshot(fixturePath), expect: { noop: true } });
321
+ buildMock.mockResolvedValue(makeBuildResult({}, { check: scenario }, { dbMigrated: receipt }));
322
+
323
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
324
+
325
+ // Before the fix, isResourceDeclarable(receipt) is true (the aws row
326
+ // carries `props`), so the generic classification proposed a `create` —
327
+ // exactly the reviewer's "false noop FAILURE" reproduction. Fixed, the
328
+ // receipt pipeline reclassifies it as `effect` (the fire is pending),
329
+ // which never reads as a `create`.
330
+ expect(exit).toBe(1);
331
+ const out = combined();
332
+ expect(out).toContain("FAIL");
333
+ expect(out).not.toContain("1 create");
334
+ expect(out).toContain("0 create");
335
+ expect(out).toContain("1 effect");
336
+ expect(out).toContain("dbMigrated");
337
+ });
338
+
339
+ test("a stale receipt in the fixture never reads as a clean noop pass", async () => {
340
+ // hash-flavor, static inputs: `receiptExpectation` is computable offline,
341
+ // no reference resolver needed. The fixture's live value is a DIFFERENT
342
+ // digest — a stale receipt, present but wrong, exactly the reviewer's
343
+ // "false noop PASS" reproduction (observedThen is always undefined for a
344
+ // scenario, so the generic pass sees no drift and calls it `noop`).
345
+ const receipt = makeAwsReceipt("seeded", "seed", "hash", { rows: 10 });
346
+ const staleValue = receiptExpectation(makeAwsReceipt("seeded", "seed", "hash", { rows: 9 }));
347
+ const fixturePath = await writeFixture(
348
+ snap({ lexicon: "aws", resources: { seeded: meta({ type: "AWS::SSM::Parameter", attributes: { value: staleValue } }) } }),
349
+ );
350
+ const scenario = Scenario("seed already ran", { given: snapshot(fixturePath), expect: { noop: true } });
351
+ buildMock.mockResolvedValue(makeBuildResult({}, { check: scenario }, { seeded: receipt }));
352
+
353
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
354
+
355
+ expect(exit).toBe(1);
356
+ const out = combined();
357
+ expect(out).toContain("FAIL");
358
+ expect(out).toContain("1 effect");
359
+ expect(out).toContain("seeded");
360
+ });
361
+
362
+ test("a receipt whose live value matches its expectation is a genuine noop pass", async () => {
363
+ // The positive control for the two reproductions above: a receipt that
364
+ // HAS fired, with the right value, is still a clean noop — the pipeline
365
+ // doesn't just fail every scenario touching a receipt.
366
+ const receipt = makeAwsReceipt("bootstrapped", "bootstrap", "existence");
367
+ const fixturePath = await writeFixture(
368
+ snap({
369
+ lexicon: "aws",
370
+ resources: { bootstrapped: meta({ type: "AWS::SSM::Parameter", attributes: { value: receiptExpectation(receipt) } }) },
371
+ }),
372
+ );
373
+ const scenario = Scenario("already bootstrapped", { given: snapshot(fixturePath), expect: { noop: true } });
374
+ buildMock.mockResolvedValue(makeBuildResult({}, { check: scenario }, { bootstrapped: receipt }));
375
+
376
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
377
+
378
+ expect(exit).toBe(0);
379
+ expect(combined()).toContain("PASS");
380
+ });
381
+ });
382
+
383
+ describe("runScenarioUnknown", () => {
384
+ test("reports the unknown subcommand and exits 1", async () => {
385
+ const stderrBuf: string[] = [];
386
+ vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
387
+ const exit = await runScenarioUnknown({ args: makeArgs({ path: "bogus" }), plugins: [], serializers: [] });
388
+ expect(exit).toBe(1);
389
+ expect(stderrBuf.join("\n")).toContain("bogus");
390
+ });
391
+ });
392
+
393
+ // ─────────────────────────────────────────────────────────────────────────
394
+ // Fixture-driven integration test (#1292): a tiny declared project + a
395
+ // committed fixture snapshot. Exercises the real path end to end — factory,
396
+ // evaluator, and the CLI handler's fixture replay — with no mocked fixture
397
+ // I/O: the JSON file is written to disk and read back for real.
398
+ // ─────────────────────────────────────────────────────────────────────────
399
+ describe("fixture-driven integration", () => {
400
+ let stdoutBuf: string[];
401
+ let dir: string;
402
+
403
+ beforeEach(async () => {
404
+ stdoutBuf = [];
405
+ vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
406
+ vi.spyOn(console, "error").mockImplementation(() => {});
407
+ buildMock.mockReset();
408
+ loadChantConfigMock.mockReset();
409
+ loadChantConfigMock.mockResolvedValue({ config: { environments: [{ name: "prod" }] } });
410
+ dir = await mkdtemp(join(tmpdir(), "chant-scenario-integration-"));
411
+ await writeFile(
412
+ join(dir, "prod-baseline.json"),
413
+ JSON.stringify(
414
+ snap({
415
+ resources: {
416
+ webBucket: meta({ type: "AWS::S3::Bucket", physicalId: "arn:aws:s3:::web" }),
417
+ },
418
+ }),
419
+ ),
420
+ );
421
+ });
422
+
423
+ afterEach(async () => {
424
+ await rm(dir, { recursive: true, force: true });
425
+ });
426
+
427
+ test("the committed fixture: the declared project as-is is plan-neutral", async () => {
428
+ const fixturePath = join(dir, "prod-baseline.json");
429
+ const scenario = Scenario("web stack is plan-neutral", { given: snapshot(fixturePath), expect: { noop: true } });
430
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["webBucket"] }, { neutralityCheck: scenario }));
431
+
432
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
433
+
434
+ expect(exit).toBe(0);
435
+ expect(stdoutBuf.join("\n")).toContain("PASS");
436
+ });
437
+
438
+ test("dropping the declaration from source, against the same fixture, fails the scenario with a legible diff naming the resource", async () => {
439
+ const fixturePath = join(dir, "prod-baseline.json");
440
+ const scenario = Scenario("web stack is plan-neutral", { given: snapshot(fixturePath), expect: { noop: true } });
441
+ // The mutation: webBucket no longer declared (dropped from source), same
442
+ // committed fixture. The fixture still remembers it as owned+live, so the
443
+ // plan now proposes deleting it — exactly the silent-delete the issue
444
+ // describes chant having no way to catch today.
445
+ buildMock.mockResolvedValue(makeBuildResult({}, { neutralityCheck: scenario }));
446
+
447
+ const exit = await runScenarioCheck({ args: makeArgs(), plugins: [], serializers: [] });
448
+
449
+ expect(exit).toBe(1);
450
+ const out = stdoutBuf.join("\n");
451
+ expect(out).toContain("FAIL");
452
+ expect(out).toContain("noop:");
453
+ expect(out).toContain("1 delete");
454
+ expect(out).toContain("webBucket");
455
+ });
456
+ });