@intentius/chant 0.46.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 (377) 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 +30 -3
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +9 -2
  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 +3 -3
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/cli/commands/audit.d.ts +7 -0
  20. package/dist/cli/commands/audit.d.ts.map +1 -1
  21. package/dist/cli/commands/build.d.ts +23 -0
  22. package/dist/cli/commands/build.d.ts.map +1 -1
  23. package/dist/cli/commands/lint.d.ts.map +1 -1
  24. package/dist/cli/handlers/build.d.ts.map +1 -1
  25. package/dist/cli/handlers/components.d.ts +31 -0
  26. package/dist/cli/handlers/components.d.ts.map +1 -1
  27. package/dist/cli/handlers/lifecycle.d.ts +12 -1
  28. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  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/scenario.d.ts +39 -0
  32. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  33. package/dist/cli/main.d.ts.map +1 -1
  34. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  35. package/dist/cli/mcp/server.d.ts +35 -2
  36. package/dist/cli/mcp/server.d.ts.map +1 -1
  37. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  38. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  39. package/dist/cli/mcp/types.d.ts +29 -1
  40. package/dist/cli/mcp/types.d.ts.map +1 -1
  41. package/dist/cli/plugins.d.ts +1 -1
  42. package/dist/cli/plugins.d.ts.map +1 -1
  43. package/dist/cli/registry.d.ts +14 -2
  44. package/dist/cli/registry.d.ts.map +1 -1
  45. package/dist/cli/reporters/stylish.d.ts +15 -1
  46. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  47. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  48. package/dist/components/auto-release.d.ts +4 -0
  49. package/dist/components/auto-release.d.ts.map +1 -1
  50. package/dist/components/capability.d.ts +17 -2
  51. package/dist/components/capability.d.ts.map +1 -1
  52. package/dist/components/cli-support.d.ts +7 -0
  53. package/dist/components/cli-support.d.ts.map +1 -1
  54. package/dist/components/component.d.ts +15 -0
  55. package/dist/components/component.d.ts.map +1 -1
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/starter-plugin.d.ts +2 -0
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  60. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  61. package/dist/components/verbs/index.d.ts +13 -0
  62. package/dist/components/verbs/index.d.ts.map +1 -1
  63. package/dist/components/verbs/r2-sync.d.ts +76 -0
  64. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  65. package/dist/components/verbs/run-agent.d.ts +499 -0
  66. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  67. package/dist/components/verbs/sign.d.ts +30 -0
  68. package/dist/components/verbs/sign.d.ts.map +1 -1
  69. package/dist/components/verbs/wrangler.d.ts +108 -0
  70. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  71. package/dist/composite.d.ts +6 -1
  72. package/dist/composite.d.ts.map +1 -1
  73. package/dist/config.d.ts +26 -0
  74. package/dist/config.d.ts.map +1 -1
  75. package/dist/deep-observation.d.ts +14 -0
  76. package/dist/deep-observation.d.ts.map +1 -1
  77. package/dist/discovery/collect.d.ts.map +1 -1
  78. package/dist/discovery/fold-import.d.ts +15 -1
  79. package/dist/discovery/fold-import.d.ts.map +1 -1
  80. package/dist/discovery/fold-rank.d.ts +66 -0
  81. package/dist/discovery/fold-rank.d.ts.map +1 -0
  82. package/dist/discovery/index.d.ts +15 -0
  83. package/dist/discovery/index.d.ts.map +1 -1
  84. package/dist/discovery/param-deps.d.ts +17 -0
  85. package/dist/discovery/param-deps.d.ts.map +1 -0
  86. package/dist/effect-receipt.d.ts +177 -0
  87. package/dist/effect-receipt.d.ts.map +1 -0
  88. package/dist/fold/fold.d.ts +55 -2
  89. package/dist/fold/fold.d.ts.map +1 -1
  90. package/dist/fold/subset.d.ts +20 -0
  91. package/dist/fold/subset.d.ts.map +1 -1
  92. package/dist/index.d.ts +4 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/lexicon-schema.d.ts +2 -0
  95. package/dist/lexicon-schema.d.ts.map +1 -1
  96. package/dist/lexicon.d.ts +137 -3
  97. package/dist/lexicon.d.ts.map +1 -1
  98. package/dist/lifecycle/change-set.d.ts +33 -5
  99. package/dist/lifecycle/change-set.d.ts.map +1 -1
  100. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  101. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  102. package/dist/lifecycle/deep-diff.d.ts +18 -0
  103. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  104. package/dist/lifecycle/deep-observe.d.ts +9 -1
  105. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  106. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  107. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  108. package/dist/lifecycle/git.d.ts +145 -21
  109. package/dist/lifecycle/git.d.ts.map +1 -1
  110. package/dist/lifecycle/index.d.ts +6 -0
  111. package/dist/lifecycle/index.d.ts.map +1 -1
  112. package/dist/lifecycle/lease.d.ts +113 -0
  113. package/dist/lifecycle/lease.d.ts.map +1 -0
  114. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  115. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  116. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  117. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  118. package/dist/lifecycle/release-ledger.d.ts +20 -0
  119. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  120. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  121. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  122. package/dist/lifecycle/scenario.d.ts +163 -0
  123. package/dist/lifecycle/scenario.d.ts.map +1 -0
  124. package/dist/lifecycle/symptoms.d.ts +63 -0
  125. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  126. package/dist/lifecycle/teardown.d.ts +6 -4
  127. package/dist/lifecycle/teardown.d.ts.map +1 -1
  128. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  129. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  130. package/dist/lint/knowledge-checks.d.ts +48 -0
  131. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  132. package/dist/lint/output-checks.d.ts +5 -0
  133. package/dist/lint/output-checks.d.ts.map +1 -0
  134. package/dist/lint/output-docs.d.ts +94 -0
  135. package/dist/lint/output-docs.d.ts.map +1 -0
  136. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  137. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  138. package/dist/lint/post-synth.d.ts +41 -0
  139. package/dist/lint/post-synth.d.ts.map +1 -1
  140. package/dist/lint/receipt-checks.d.ts +9 -0
  141. package/dist/lint/receipt-checks.d.ts.map +1 -0
  142. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  143. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  144. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  145. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  146. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  147. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  148. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  149. package/dist/lint/rules/index.d.ts +3 -1
  150. package/dist/lint/rules/index.d.ts.map +1 -1
  151. package/dist/lsp/lexicon-providers.d.ts +7 -0
  152. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  153. package/dist/okf-read.d.ts +78 -0
  154. package/dist/okf-read.d.ts.map +1 -0
  155. package/dist/op/activity-contract.d.ts +139 -0
  156. package/dist/op/activity-contract.d.ts.map +1 -0
  157. package/dist/op/builders.d.ts +140 -3
  158. package/dist/op/builders.d.ts.map +1 -1
  159. package/dist/op/converge-rule.d.ts +161 -0
  160. package/dist/op/converge-rule.d.ts.map +1 -0
  161. package/dist/op/generate-pipeline.d.ts +39 -0
  162. package/dist/op/generate-pipeline.d.ts.map +1 -0
  163. package/dist/op/index.d.ts +18 -2
  164. package/dist/op/index.d.ts.map +1 -1
  165. package/dist/op/local-executor.d.ts +2 -1
  166. package/dist/op/local-executor.d.ts.map +1 -1
  167. package/dist/op/op-verb-class.d.ts +42 -0
  168. package/dist/op/op-verb-class.d.ts.map +1 -0
  169. package/dist/op/operator.d.ts +128 -0
  170. package/dist/op/operator.d.ts.map +1 -0
  171. package/dist/op/receipt-store.d.ts +138 -0
  172. package/dist/op/receipt-store.d.ts.map +1 -0
  173. package/dist/op/step-output-ref.d.ts +187 -0
  174. package/dist/op/step-output-ref.d.ts.map +1 -0
  175. package/dist/op/types.d.ts +49 -2
  176. package/dist/op/types.d.ts.map +1 -1
  177. package/dist/provenance.d.ts +73 -3
  178. package/dist/provenance.d.ts.map +1 -1
  179. package/dist/runtime-adapter.d.ts +7 -1
  180. package/dist/runtime-adapter.d.ts.map +1 -1
  181. package/dist/secret-materialization.d.ts +138 -0
  182. package/dist/secret-materialization.d.ts.map +1 -0
  183. package/dist/secret-provenance.d.ts +218 -0
  184. package/dist/secret-provenance.d.ts.map +1 -0
  185. package/dist/serializer.d.ts +29 -0
  186. package/dist/serializer.d.ts.map +1 -1
  187. package/dist/toml.d.ts +40 -5
  188. package/dist/toml.d.ts.map +1 -1
  189. package/dist/yaml.d.ts.map +1 -1
  190. package/package.json +4 -1
  191. package/src/audit/catalog.test.ts +1 -1
  192. package/src/audit/catalog.ts +75 -3
  193. package/src/audit/core.test.ts +57 -0
  194. package/src/audit/core.ts +0 -0
  195. package/src/audit/detect-bundle.test.ts +1 -1
  196. package/src/audit/discover.test.ts +24 -0
  197. package/src/audit/discover.ts +40 -4
  198. package/src/audit/fetch.test.ts +216 -3
  199. package/src/audit/fetch.ts +270 -59
  200. package/src/audit/report-html.ts +5 -2
  201. package/src/audit/report-model.ts +9 -0
  202. package/src/audit/report.test.ts +22 -0
  203. package/src/audit/report.ts +3 -2
  204. package/src/audit/rules-doc.ts +13 -1
  205. package/src/audit/secrets.test.ts +303 -0
  206. package/src/audit/secrets.ts +406 -0
  207. package/src/audit/wrangler.test.ts +230 -0
  208. package/src/audit/wrangler.ts +290 -0
  209. package/src/build.test.ts +41 -0
  210. package/src/build.ts +39 -6
  211. package/src/cli/command-group.ts +1 -1
  212. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  213. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  214. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  215. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  216. package/src/cli/commands/audit.test.ts +268 -1
  217. package/src/cli/commands/audit.ts +87 -18
  218. package/src/cli/commands/build.test.ts +245 -0
  219. package/src/cli/commands/build.ts +210 -21
  220. package/src/cli/commands/lint.ts +15 -3
  221. package/src/cli/handlers/build.ts +2 -0
  222. package/src/cli/handlers/components.test.ts +199 -1
  223. package/src/cli/handlers/components.ts +160 -3
  224. package/src/cli/handlers/explain.test.ts +70 -1
  225. package/src/cli/handlers/graph.test.ts +20 -0
  226. package/src/cli/handlers/graph.ts +12 -3
  227. package/src/cli/handlers/lifecycle.test.ts +115 -1
  228. package/src/cli/handlers/lifecycle.ts +96 -15
  229. package/src/cli/handlers/operator.test.ts +255 -0
  230. package/src/cli/handlers/operator.ts +240 -0
  231. package/src/cli/handlers/scenario.test.ts +456 -0
  232. package/src/cli/handlers/scenario.ts +330 -0
  233. package/src/cli/main.test.ts +23 -0
  234. package/src/cli/main.ts +72 -1
  235. package/src/cli/mcp/resource-handlers.ts +38 -1
  236. package/src/cli/mcp/server.test.ts +323 -3
  237. package/src/cli/mcp/server.ts +84 -7
  238. package/src/cli/mcp/tools/explain.ts +51 -2
  239. package/src/cli/mcp/types.ts +27 -1
  240. package/src/cli/plugins.ts +4 -2
  241. package/src/cli/registry.ts +14 -2
  242. package/src/cli/reporters/stylish.test.ts +154 -0
  243. package/src/cli/reporters/stylish.ts +154 -33
  244. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  245. package/src/codegen/docs-rule-scanning.ts +25 -2
  246. package/src/components/README.md +7 -0
  247. package/src/components/auto-release.ts +6 -0
  248. package/src/components/capability.ts +17 -2
  249. package/src/components/cli-support.test.ts +17 -0
  250. package/src/components/cli-support.ts +13 -1
  251. package/src/components/component-schema.test.ts +32 -0
  252. package/src/components/component.schema.json +6 -0
  253. package/src/components/component.test.ts +21 -0
  254. package/src/components/component.ts +15 -0
  255. package/src/components/driver.ts +12 -4
  256. package/src/components/registry.test.ts +7 -2
  257. package/src/components/starter-plugin.ts +17 -0
  258. package/src/components/verbs/ensure-secret.test.ts +130 -0
  259. package/src/components/verbs/ensure-secret.ts +79 -0
  260. package/src/components/verbs/index.ts +13 -0
  261. package/src/components/verbs/r2-sync.test.ts +107 -0
  262. package/src/components/verbs/r2-sync.ts +124 -0
  263. package/src/components/verbs/run-agent.test.ts +683 -0
  264. package/src/components/verbs/run-agent.ts +786 -0
  265. package/src/components/verbs/sign.test.ts +19 -0
  266. package/src/components/verbs/sign.ts +34 -2
  267. package/src/components/verbs/wrangler.test.ts +170 -0
  268. package/src/components/verbs/wrangler.ts +241 -0
  269. package/src/composite.ts +31 -2
  270. package/src/config.test.ts +15 -0
  271. package/src/config.ts +30 -0
  272. package/src/deep-observation.test.ts +19 -0
  273. package/src/deep-observation.ts +17 -0
  274. package/src/discovery/collect.ts +11 -2
  275. package/src/discovery/fold-import.test.ts +54 -0
  276. package/src/discovery/fold-import.ts +178 -38
  277. package/src/discovery/fold-rank.test.ts +197 -0
  278. package/src/discovery/fold-rank.ts +346 -0
  279. package/src/discovery/index.ts +16 -1
  280. package/src/discovery/param-deps.test.ts +118 -0
  281. package/src/discovery/param-deps.ts +170 -0
  282. package/src/effect-receipt-exclusion.test.ts +190 -0
  283. package/src/effect-receipt.test.ts +419 -0
  284. package/src/effect-receipt.ts +412 -0
  285. package/src/fold/fold.test.ts +6 -2
  286. package/src/fold/fold.ts +184 -3
  287. package/src/fold/subset.test.ts +95 -6
  288. package/src/fold/subset.ts +66 -2
  289. package/src/index.ts +4 -0
  290. package/src/lexicon-schema.ts +3 -0
  291. package/src/lexicon.ts +151 -5
  292. package/src/lifecycle/change-set.ts +46 -7
  293. package/src/lifecycle/converge-ledger.test.ts +199 -0
  294. package/src/lifecycle/converge-ledger.ts +179 -0
  295. package/src/lifecycle/deep-diff.test.ts +79 -1
  296. package/src/lifecycle/deep-diff.ts +23 -0
  297. package/src/lifecycle/deep-observe.ts +13 -2
  298. package/src/lifecycle/gate-ledger.test.ts +103 -0
  299. package/src/lifecycle/gate-ledger.ts +140 -0
  300. package/src/lifecycle/git.test.ts +430 -0
  301. package/src/lifecycle/git.ts +446 -84
  302. package/src/lifecycle/index.ts +6 -0
  303. package/src/lifecycle/lease.test.ts +343 -0
  304. package/src/lifecycle/lease.ts +270 -0
  305. package/src/lifecycle/observation-baseline.test.ts +46 -0
  306. package/src/lifecycle/observation-baseline.ts +33 -1
  307. package/src/lifecycle/receipt-plan.test.ts +250 -0
  308. package/src/lifecycle/receipt-plan.ts +249 -0
  309. package/src/lifecycle/release-ledger.ts +20 -0
  310. package/src/lifecycle/scenario-eval.test.ts +199 -0
  311. package/src/lifecycle/scenario-eval.ts +158 -0
  312. package/src/lifecycle/scenario.test.ts +195 -0
  313. package/src/lifecycle/scenario.ts +321 -0
  314. package/src/lifecycle/symptoms.test.ts +116 -0
  315. package/src/lifecycle/symptoms.ts +126 -0
  316. package/src/lifecycle/teardown.test.ts +31 -0
  317. package/src/lifecycle/teardown.ts +6 -4
  318. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  319. package/src/lifecycle/unobserved-gate.ts +102 -0
  320. package/src/lint/knowledge-checks.test.ts +80 -0
  321. package/src/lint/knowledge-checks.ts +74 -0
  322. package/src/lint/output-checks.test.ts +85 -0
  323. package/src/lint/output-checks.ts +99 -0
  324. package/src/lint/output-docs.test.ts +220 -0
  325. package/src/lint/output-docs.ts +204 -0
  326. package/src/lint/pipeline-change-gate.test.ts +144 -0
  327. package/src/lint/pipeline-change-gate.ts +153 -0
  328. package/src/lint/post-synth.test.ts +97 -0
  329. package/src/lint/post-synth.ts +60 -0
  330. package/src/lint/receipt-checks.test.ts +101 -0
  331. package/src/lint/receipt-checks.ts +93 -0
  332. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  333. package/src/lint/rules/comp/comp.test.ts +49 -1
  334. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  335. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  336. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  337. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  338. package/src/lint/rules/evl001-non-literal-expression.test.ts +35 -3
  339. package/src/lint/rules/evl001-non-literal-expression.ts +7 -0
  340. package/src/lint/rules/index.ts +7 -1
  341. package/src/lsp/lexicon-providers.test.ts +44 -0
  342. package/src/lsp/lexicon-providers.ts +11 -1
  343. package/src/okf-read.test.ts +149 -0
  344. package/src/okf-read.ts +197 -0
  345. package/src/op/activity-contract.test.ts +180 -0
  346. package/src/op/activity-contract.ts +278 -0
  347. package/src/op/builders-exports.test.ts +17 -1
  348. package/src/op/builders.ts +198 -6
  349. package/src/op/converge-rule.test.ts +179 -0
  350. package/src/op/converge-rule.ts +311 -0
  351. package/src/op/effect-step.test.ts +311 -0
  352. package/src/op/generate-pipeline.test.ts +53 -0
  353. package/src/op/generate-pipeline.ts +99 -0
  354. package/src/op/index.ts +40 -3
  355. package/src/op/local-executor.test.ts +92 -0
  356. package/src/op/local-executor.ts +212 -29
  357. package/src/op/op-verb-class.test.ts +126 -0
  358. package/src/op/op-verb-class.ts +115 -0
  359. package/src/op/op.test.ts +25 -2
  360. package/src/op/operator.test.ts +346 -0
  361. package/src/op/operator.ts +213 -0
  362. package/src/op/receipt-store.ts +211 -0
  363. package/src/op/step-output-ref.test.ts +334 -0
  364. package/src/op/step-output-ref.ts +453 -0
  365. package/src/op/types.ts +51 -2
  366. package/src/provenance.test.ts +151 -4
  367. package/src/provenance.ts +118 -4
  368. package/src/runtime-adapter.ts +31 -10
  369. package/src/secret-materialization.test.ts +199 -0
  370. package/src/secret-materialization.ts +235 -0
  371. package/src/secret-provenance.test.ts +388 -0
  372. package/src/secret-provenance.ts +475 -0
  373. package/src/serializer.ts +30 -0
  374. package/src/toml.test.ts +157 -384
  375. package/src/toml.ts +371 -5
  376. package/src/yaml.test.ts +88 -0
  377. package/src/yaml.ts +76 -6
@@ -0,0 +1,220 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { parseOutputDocs, pick, get } from "./output-docs";
3
+ import type { SerializerResult } from "../serializer";
4
+
5
+ describe("parseOutputDocs", () => {
6
+ test("parses a single JSON output as one document", () => {
7
+ const outputs = new Map<string, string | SerializerResult>([
8
+ ["aws", JSON.stringify({ AWSTemplateFormatVersion: "2010-09-09", Resources: {} })],
9
+ ]);
10
+ const docs = parseOutputDocs(outputs);
11
+ expect(docs).toHaveLength(1);
12
+ expect(docs[0]).toMatchObject({
13
+ lexicon: "aws",
14
+ index: 0,
15
+ format: "json",
16
+ });
17
+ expect(docs[0].error).toBeUndefined();
18
+ expect((docs[0].value as { Resources: unknown }).Resources).toEqual({});
19
+ });
20
+
21
+ test("splits multi-document YAML on `---` into one OutputDoc per document", () => {
22
+ const yaml = [
23
+ "apiVersion: v1",
24
+ "kind: Namespace",
25
+ "metadata:",
26
+ " name: ns-a",
27
+ "---",
28
+ "apiVersion: apps/v1",
29
+ "kind: Deployment",
30
+ "metadata:",
31
+ " name: web",
32
+ ].join("\n");
33
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
34
+ const docs = parseOutputDocs(outputs);
35
+ expect(docs).toHaveLength(2);
36
+ expect(docs[0]).toMatchObject({ lexicon: "k8s", index: 0, format: "yaml" });
37
+ expect(docs[1]).toMatchObject({ lexicon: "k8s", index: 1, format: "yaml" });
38
+ expect((docs[0].value as { kind: string }).kind).toBe("Namespace");
39
+ expect((docs[1].value as { kind: string }).kind).toBe("Deployment");
40
+ });
41
+
42
+ test("handles a leading `---` document separator before any content", () => {
43
+ const yaml = ["---", "kind: Namespace", "---", "kind: Deployment"].join("\n");
44
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
45
+ const docs = parseOutputDocs(outputs);
46
+ expect(docs).toHaveLength(2);
47
+ expect((docs[0].value as { kind: string }).kind).toBe("Namespace");
48
+ expect((docs[1].value as { kind: string }).kind).toBe("Deployment");
49
+ });
50
+
51
+ test("drops empty documents between/around separators without erroring", () => {
52
+ const yaml = ["---", "", "---", "kind: Deployment", "---", ""].join("\n");
53
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
54
+ const docs = parseOutputDocs(outputs);
55
+ expect(docs).toHaveLength(1);
56
+ expect((docs[0].value as { kind: string }).kind).toBe("Deployment");
57
+ });
58
+
59
+ test("parses SerializerResult.files as additional, separately-indexed documents", () => {
60
+ const output: SerializerResult = {
61
+ primary: JSON.stringify({ kind: "root" }),
62
+ files: {
63
+ "nested.template.json": JSON.stringify({ kind: "nested-a" }),
64
+ "sidecar.yaml": "kind: nested-b\n---\nkind: nested-c",
65
+ },
66
+ };
67
+ const outputs = new Map<string, string | SerializerResult>([["aws", output]]);
68
+ const docs = parseOutputDocs(outputs);
69
+
70
+ const primaryDocs = docs.filter((d) => d.file === undefined);
71
+ expect(primaryDocs).toHaveLength(1);
72
+ expect((primaryDocs[0].value as { kind: string }).kind).toBe("root");
73
+
74
+ const nestedJson = docs.filter((d) => d.file === "nested.template.json");
75
+ expect(nestedJson).toHaveLength(1);
76
+ expect((nestedJson[0].value as { kind: string }).kind).toBe("nested-a");
77
+
78
+ // The sidecar YAML file is itself multi-document — index resets to 0
79
+ // within that file, since index is scoped to its own source.
80
+ const sidecarDocs = docs.filter((d) => d.file === "sidecar.yaml");
81
+ expect(sidecarDocs).toHaveLength(2);
82
+ expect(sidecarDocs[0].index).toBe(0);
83
+ expect(sidecarDocs[1].index).toBe(1);
84
+ expect((sidecarDocs[0].value as { kind: string }).kind).toBe("nested-b");
85
+ expect((sidecarDocs[1].value as { kind: string }).kind).toBe("nested-c");
86
+
87
+ expect(docs.every((d) => d.lexicon === "aws")).toBe(true);
88
+ });
89
+
90
+ test("skips a source with no files entries and no primary content", () => {
91
+ const outputs = new Map<string, string | SerializerResult>([["empty", ""]]);
92
+ expect(parseOutputDocs(outputs)).toEqual([]);
93
+ });
94
+
95
+ test("parses every ctx.outputs entry, tagging each with its own lexicon key", () => {
96
+ const outputs = new Map<string, string | SerializerResult>([
97
+ ["aws", JSON.stringify({ kind: "aws-thing" })],
98
+ ["k8s", "kind: k8s-thing"],
99
+ ]);
100
+ const docs = parseOutputDocs(outputs);
101
+ expect(docs.map((d) => d.lexicon).sort()).toEqual(["aws", "k8s"]);
102
+ });
103
+
104
+ describe("malformed documents — marker, not throw", () => {
105
+ test("a bare scalar document is marked with an error instead of being treated as a manifest", () => {
106
+ // "42" is syntactically fine (valid YAML/JSON) but is a scalar, not an
107
+ // object/array — not something a manifest-shaped check can walk.
108
+ const yaml = ["kind: Deployment", "---", "42", "---", "kind: Service"].join("\n");
109
+ const outputs = new Map<string, string | SerializerResult>([["k8s", yaml]]);
110
+ const docs = parseOutputDocs(outputs);
111
+
112
+ expect(docs).toHaveLength(3);
113
+ expect(docs[0].error).toBeUndefined();
114
+ expect(docs[2].error).toBeUndefined();
115
+
116
+ // The malformed middle document is a marker: present, flagged, no value.
117
+ expect(docs[1].error).toBeDefined();
118
+ expect(docs[1].value).toBeUndefined();
119
+ expect(docs[1].format).toBe("yaml");
120
+
121
+ // Filtering it out is one line, and leaves the good documents intact.
122
+ const usable = docs.filter((d) => !d.error);
123
+ expect(usable).toHaveLength(2);
124
+ expect((usable[0].value as { kind: string }).kind).toBe("Deployment");
125
+ expect((usable[1].value as { kind: string }).kind).toBe("Service");
126
+ });
127
+
128
+ test("a lone scalar as the entire output is marked with an error, not thrown", () => {
129
+ // Valid JSON ("true" parses fine) but not an object/array — the
130
+ // whole-content JSON path hits the same isUsableDoc guard.
131
+ const outputs = new Map<string, string | SerializerResult>([["weird", "true"]]);
132
+ expect(() => parseOutputDocs(outputs)).not.toThrow();
133
+ const docs = parseOutputDocs(outputs);
134
+ expect(docs).toHaveLength(1);
135
+ expect(docs[0].format).toBe("json");
136
+ expect(docs[0].error).toBeDefined();
137
+ expect(docs[0].value).toBeUndefined();
138
+ });
139
+
140
+ test("a malformed document in one files entry does not affect the others", () => {
141
+ const output: SerializerResult = {
142
+ primary: "kind: root",
143
+ files: {
144
+ "good.yaml": "kind: good",
145
+ "bad.yaml": "null",
146
+ },
147
+ };
148
+ const outputs = new Map<string, string | SerializerResult>([["k8s", output]]);
149
+ const docs = parseOutputDocs(outputs);
150
+
151
+ const good = docs.find((d) => d.file === "good.yaml")!;
152
+ const bad = docs.find((d) => d.file === "bad.yaml")!;
153
+ expect(good.error).toBeUndefined();
154
+ expect((good.value as { kind: string }).kind).toBe("good");
155
+ expect(bad.error).toBeDefined();
156
+ expect(bad.value).toBeUndefined();
157
+ });
158
+ });
159
+ });
160
+
161
+ describe("pick", () => {
162
+ interface Widget {
163
+ kind: string;
164
+ name: string;
165
+ extra: unknown;
166
+ }
167
+
168
+ test("returns only the named keys that are present", () => {
169
+ const value = { kind: "Deployment", name: "web", other: "ignored" };
170
+ expect(pick<Widget>(value, ["kind", "name"])).toEqual({ kind: "Deployment", name: "web" });
171
+ });
172
+
173
+ test("omits a named key that is absent — no invented default", () => {
174
+ const value = { kind: "Deployment" };
175
+ expect(pick<Widget>(value, ["kind", "name"])).toEqual({ kind: "Deployment" });
176
+ });
177
+
178
+ test("returns an empty object for a non-object value", () => {
179
+ expect(pick<Widget>(null, ["kind"])).toEqual({});
180
+ expect(pick<Widget>("a string", ["kind"])).toEqual({});
181
+ expect(pick<Widget>(undefined, ["kind"])).toEqual({});
182
+ });
183
+
184
+ test("copies values through unvalidated, whatever their actual shape", () => {
185
+ const value = { kind: 42 }; // wrong runtime type for `kind: string`
186
+ expect(pick<Widget>(value, ["kind"])).toEqual({ kind: 42 });
187
+ });
188
+ });
189
+
190
+ describe("get", () => {
191
+ test("walks a dotted path through nested objects", () => {
192
+ const value = { spec: { template: { spec: { containers: [] } } } };
193
+ expect(get(value, "spec.template.spec")).toEqual({ containers: [] });
194
+ });
195
+
196
+ test("returns undefined as soon as a segment is missing", () => {
197
+ const value = { spec: {} };
198
+ expect(get(value, "spec.template.spec")).toBeUndefined();
199
+ });
200
+
201
+ test("returns undefined when an intermediate value is not indexable", () => {
202
+ const value = { spec: "not-an-object" };
203
+ expect(get(value, "spec.template")).toBeUndefined();
204
+ });
205
+
206
+ test("indexes into arrays with a numeric path segment", () => {
207
+ const value = { items: [{ name: "first" }, { name: "second" }] };
208
+ expect(get(value, "items.1.name")).toBe("second");
209
+ });
210
+
211
+ test("returns the value itself for an empty path", () => {
212
+ const value = { a: 1 };
213
+ expect(get(value, "")).toBe(value);
214
+ });
215
+
216
+ test("does not throw on null/undefined input", () => {
217
+ expect(get(null, "a.b")).toBeUndefined();
218
+ expect(get(undefined, "a.b")).toBeUndefined();
219
+ });
220
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Parsed-output view for post-synth checks (chant #975).
3
+ *
4
+ * `PostSynthContext.outputs` carries raw serialized strings — a `chant build`
5
+ * consumer has to parse YAML/JSON itself before it can reason about
6
+ * structure. Every lexicon that needs this today rolls its own splitter
7
+ * (`parseK8sManifests` in k8s, hand-rolled `JSON.parse` in aws's
8
+ * `cf-refs.ts`), and each of the ~30 k8s post-synth checks reparses the same
9
+ * output independently. This module is the shared, parse-once primitive:
10
+ * `parseOutputDocs` turns `ctx.outputs` into a flat list of `OutputDoc`s,
11
+ * and `PostSynthContext.docs` (see `./post-synth.ts`) memoizes one call to it
12
+ * per build so every check — lexicon-shipped or project-authored policy —
13
+ * shares the same parse.
14
+ *
15
+ * `pick`/`get` are the "look at only the fields you care about" ergonomic
16
+ * from the issue's `,remain`-style framing — a partial, unvalidated view of
17
+ * an already-parsed tree, not a query DSL. Selector languages over the
18
+ * *source* AST stay in `./declarative.ts`; this module is output-side only.
19
+ */
20
+
21
+ import type { SerializerResult } from "../serializer";
22
+ import { parseYAML } from "../yaml";
23
+
24
+ /**
25
+ * One parsed document from a build's serialized output.
26
+ *
27
+ * A single `ctx.outputs` entry can yield more than one `OutputDoc`: a
28
+ * multi-document YAML stream (`---`-separated) yields one per document, and
29
+ * a `SerializerResult` with `files` yields further docs for each nested
30
+ * file (a CloudFormation nested stack template, a sidecar manifest).
31
+ */
32
+ export interface OutputDoc {
33
+ /** The `ctx.outputs` map key this document came from. */
34
+ lexicon: string;
35
+ /** Position of this document within its source (primary output or one
36
+ * `files` entry) — 0 for a single-document source, 0..n-1 for a
37
+ * multi-document YAML stream. */
38
+ index: number;
39
+ /** Whether this document was decoded as YAML or as whole-content JSON. */
40
+ format: "yaml" | "json";
41
+ /** The parsed tree. `undefined` when parsing failed — see `error`. */
42
+ value: unknown;
43
+ /**
44
+ * Set when this document came from a `SerializerResult.files` entry rather
45
+ * than the primary output — the file's key in that map (e.g. a nested
46
+ * stack template's filename).
47
+ */
48
+ file?: string;
49
+ /**
50
+ * Set when this document could not be parsed into a usable tree. The
51
+ * document is still returned (a marker, not a throw) so a caller can see
52
+ * that something in the build output failed to parse, rather than the
53
+ * failure disappearing silently the way `parseK8sManifests` has always
54
+ * swallowed it. `value` is `undefined` on an errored document. Filter with
55
+ * `ctx.docs.filter((d) => !d.error)` to get only usable documents.
56
+ */
57
+ error?: string;
58
+ }
59
+
60
+ /**
61
+ * Parse every output in `outputs` into a flat list of `OutputDoc`s: one call,
62
+ * shared by every post-synth check via `PostSynthContext.docs`.
63
+ *
64
+ * Format detection is whole-source, not per-document: if the entire source
65
+ * parses as JSON, it is one `"json"` document (CloudFormation, most non-k8s
66
+ * serializers). Otherwise the source is treated as a YAML stream — split on
67
+ * `---` document separators — and each resulting document is parsed as YAML
68
+ * (`format: "yaml"`), even one that happens to also be valid JSON, since
69
+ * JSON is a YAML subset. `SerializerResult.files` entries (nested templates,
70
+ * sidecar manifests) are parsed the same way, tagged with `file`.
71
+ *
72
+ * A document that fails to parse into a non-null object/array — malformed
73
+ * input, or a bare scalar where a manifest was expected — is not thrown:
74
+ * it is included with `error` set and `value: undefined`. See `OutputDoc`.
75
+ */
76
+ export function parseOutputDocs(outputs: Map<string, string | SerializerResult>): OutputDoc[] {
77
+ const docs: OutputDoc[] = [];
78
+ for (const [lexicon, output] of outputs) {
79
+ const primary = typeof output === "string" ? output : output.primary;
80
+ docs.push(...parseSource(primary, lexicon));
81
+
82
+ const files = typeof output === "string" ? undefined : output.files;
83
+ for (const [file, content] of Object.entries(files ?? {})) {
84
+ docs.push(...parseSource(content, lexicon, file));
85
+ }
86
+ }
87
+ return docs;
88
+ }
89
+
90
+ /** Split a YAML stream on `---` document-separator lines (own line, optional
91
+ * trailing whitespace) — including a leading separator before any content,
92
+ * which `"\n---\n"`-style splitting misses. Empty documents (two separators
93
+ * back to back, or a leading/trailing one) are dropped. */
94
+ function splitYamlDocuments(source: string): string[] {
95
+ return source
96
+ .split(/^---[ \t]*$/m)
97
+ .map((doc) => doc.trim())
98
+ .filter((doc) => doc.length > 0);
99
+ }
100
+
101
+ /** True for a value a document is usable as — a non-null object or array.
102
+ * A bare scalar ("just a string") parses without error but is not a
103
+ * document a check can walk, so it is treated as malformed. */
104
+ function isUsableDoc(value: unknown): boolean {
105
+ return typeof value === "object" && value !== null;
106
+ }
107
+
108
+ /**
109
+ * Parse one source string (a primary output, or one `files` entry) into its
110
+ * `OutputDoc`s, trying whole-content JSON first and falling back to a
111
+ * (possibly multi-document) YAML stream. Never throws — a parse failure or
112
+ * an unusable result becomes a marker document with `error` set.
113
+ */
114
+ function parseSource(source: string, lexicon: string, file?: string): OutputDoc[] {
115
+ const trimmed = source.trim();
116
+ if (trimmed === "") return [];
117
+
118
+ try {
119
+ const value = JSON.parse(trimmed);
120
+ if (isUsableDoc(value)) {
121
+ return [{ lexicon, index: 0, format: "json", value, ...(file && { file }) }];
122
+ }
123
+ return [
124
+ {
125
+ lexicon,
126
+ index: 0,
127
+ format: "json",
128
+ value: undefined,
129
+ error: "parsed JSON is not an object or array",
130
+ ...(file && { file }),
131
+ },
132
+ ];
133
+ } catch {
134
+ // Not whole-content JSON — fall through to the YAML stream path.
135
+ }
136
+
137
+ return splitYamlDocuments(trimmed).map((segment, index) => {
138
+ try {
139
+ const value = parseYAML(segment);
140
+ if (isUsableDoc(value)) {
141
+ return { lexicon, index, format: "yaml" as const, value, ...(file && { file }) };
142
+ }
143
+ return {
144
+ lexicon,
145
+ index,
146
+ format: "yaml" as const,
147
+ value: undefined,
148
+ error: "parsed YAML document is not an object or array",
149
+ ...(file && { file }),
150
+ };
151
+ } catch (err) {
152
+ return {
153
+ lexicon,
154
+ index,
155
+ format: "yaml" as const,
156
+ value: undefined,
157
+ error: err instanceof Error ? err.message : String(err),
158
+ ...(file && { file }),
159
+ };
160
+ }
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Partial typed decode — the `,remain`-style ergonomic from `ma91n/tfpolicy`:
166
+ * decode the fields you care about, leave everything else in place,
167
+ * unvalidated. Returns a shallow, top-level view of `value` narrowed to the
168
+ * keys named in `shape`. A key absent from `value` (or `value` not being a
169
+ * plain object) is simply absent from the result — no default is invented.
170
+ *
171
+ * This is NOT runtime validation: a key that IS present is copied as-is,
172
+ * whatever its actual shape, and cast to `T`'s declared type for that key.
173
+ * Use `get` for a deeper walk into one of the picked values.
174
+ */
175
+ export function pick<T extends object>(value: unknown, shape: (keyof T)[]): Partial<T> {
176
+ const result: Partial<T> = {};
177
+ if (typeof value !== "object" || value === null) return result;
178
+ const source = value as Record<string, unknown>;
179
+ for (const key of shape) {
180
+ const propertyName = key as string;
181
+ if (propertyName in source) {
182
+ (result as Record<string, unknown>)[propertyName] = source[propertyName];
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /**
189
+ * Dotted-path getter for the common walk over a parsed doc, e.g.
190
+ * `get(doc.value, "spec.template.spec")`. Not a query DSL — no wildcards,
191
+ * no predicates, no array-flattening. An array index is just a numeric path
192
+ * segment (`get(value, "items.0.name")`), since JS indexes arrays by string
193
+ * key underneath. Returns `undefined` as soon as any segment is missing or
194
+ * the value at that point isn't indexable — it never throws on a bad path.
195
+ */
196
+ export function get(value: unknown, path: string): unknown {
197
+ if (path === "") return value;
198
+ let current: unknown = value;
199
+ for (const segment of path.split(".")) {
200
+ if (typeof current !== "object" || current === null) return undefined;
201
+ current = (current as Record<string, unknown>)[segment];
202
+ }
203
+ return current;
204
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { Component, Phase } from "../components/component";
3
+ import {
4
+ classifyComponentPipelineChange,
5
+ classifyPipelineChange,
6
+ componentVerbSet,
7
+ evaluatePipelineChangeGate,
8
+ } from "./pipeline-change-gate";
9
+
10
+ const KNOWN: ReadonlySet<string> = new Set(["cfn-deploy", "publish-image", "wait-for-stack"]);
11
+
12
+ const component = (deploy: Phase[]): Component => ({
13
+ name: "widget",
14
+ dependsOn: [],
15
+ deploy,
16
+ });
17
+
18
+ const ordinary = (): Component =>
19
+ component([{ phase: "Deploy", steps: [{ kind: "cfn-deploy" }, { kind: "wait-for-stack" }] }]);
20
+
21
+ const withShell = (): Component =>
22
+ component([{ phase: "Deploy", steps: [{ kind: "shell", cmd: "echo hi", reason: "no capability yet" }] }]);
23
+
24
+ const withUnregisteredVerb = (): Component =>
25
+ component([{ phase: "Deploy", steps: [{ kind: "some-new-verb" }] }]);
26
+
27
+ describe("classifyComponentPipelineChange (#1569)", () => {
28
+ test("a composition of only registry verbs is not a pipeline change", () => {
29
+ const result = classifyComponentPipelineChange(ordinary(), { knownKinds: KNOWN });
30
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
31
+ });
32
+
33
+ test("any shell step trips the class unconditionally, even with a declared reason", () => {
34
+ const result = classifyComponentPipelineChange(withShell(), { knownKinds: KNOWN });
35
+ expect(result.pipelineChange).toBe(true);
36
+ expect(result.reasons).toEqual([{ kind: "shell-step", phaseName: "Deploy" }]);
37
+ });
38
+
39
+ test("a verb outside the known registry trips the class", () => {
40
+ const result = classifyComponentPipelineChange(withUnregisteredVerb(), { knownKinds: KNOWN });
41
+ expect(result.pipelineChange).toBe(true);
42
+ expect(result.reasons).toEqual([{ kind: "unregistered-verb", stepKind: "some-new-verb", phaseName: "Deploy" }]);
43
+ });
44
+
45
+ test("gate steps are never mistaken for an escape hatch", () => {
46
+ const withGate = component([
47
+ { phase: "Approve", steps: [{ kind: "gate", signalName: "release-approved" }] },
48
+ ]);
49
+ expect(classifyComponentPipelineChange(withGate, { knownKinds: KNOWN })).toEqual({
50
+ pipelineChange: false,
51
+ reasons: [],
52
+ });
53
+ });
54
+
55
+ test("nested fan-out phases are walked, same as COMP005/COMP006", () => {
56
+ const nested = component([
57
+ {
58
+ phase: "Fanout",
59
+ steps: [{ phase: "per-region", steps: [{ kind: "shell", cmd: "aws sts get-caller-identity", reason: "no capability" }] }],
60
+ },
61
+ ]);
62
+ const result = classifyComponentPipelineChange(nested, { knownKinds: KNOWN });
63
+ expect(result.pipelineChange).toBe(true);
64
+ expect(result.reasons).toEqual([{ kind: "shell-step", phaseName: "per-region" }]);
65
+ });
66
+
67
+ test("without an explicit registry, falls back to core's starter verbs (same fallback as COMP005)", () => {
68
+ // cfn-deploy is not in core's starter set (it's an aws-lexicon leaf), so
69
+ // this asserts the fallback is core's own set, not "everything passes".
70
+ const result = classifyComponentPipelineChange(ordinary());
71
+ expect(result.pipelineChange).toBe(true);
72
+ expect(result.reasons.some((r) => r.kind === "unregistered-verb" && r.stepKind === "cfn-deploy")).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe("componentVerbSet (#1569)", () => {
77
+ test("collects every non-gate step kind, excluding gate steps", () => {
78
+ const c = component([
79
+ {
80
+ phase: "Deploy",
81
+ steps: [{ kind: "cfn-deploy" }, { kind: "gate", signalName: "go" }, { kind: "wait-for-stack" }],
82
+ },
83
+ ]);
84
+ expect(componentVerbSet(c)).toEqual(new Set(["cfn-deploy", "wait-for-stack"]));
85
+ });
86
+ });
87
+
88
+ describe("classifyPipelineChange (#1569)", () => {
89
+ test("no `before` (a brand-new component): reports only the single-composition findings, no verb-set-changed", () => {
90
+ const result = classifyPipelineChange(undefined, ordinary(), { knownKinds: KNOWN });
91
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
92
+ });
93
+
94
+ test("identical verb set before and after is not a pipeline change even with `before` present", () => {
95
+ const result = classifyPipelineChange(ordinary(), ordinary(), { knownKinds: KNOWN });
96
+ expect(result).toEqual({ pipelineChange: false, reasons: [] });
97
+ });
98
+
99
+ test("adding a verb to the composition trips verb-set-changed", () => {
100
+ const after = component([
101
+ { phase: "Deploy", steps: [{ kind: "cfn-deploy" }, { kind: "wait-for-stack" }, { kind: "publish-image" }] },
102
+ ]);
103
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
104
+ expect(result.pipelineChange).toBe(true);
105
+ expect(result.reasons).toEqual([{ kind: "verb-set-changed", added: ["publish-image"], removed: [] }]);
106
+ });
107
+
108
+ test("removing a verb from the composition trips verb-set-changed", () => {
109
+ const after = component([{ phase: "Deploy", steps: [{ kind: "cfn-deploy" }] }]);
110
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
111
+ expect(result.reasons).toEqual([{ kind: "verb-set-changed", added: [], removed: ["wait-for-stack"] }]);
112
+ });
113
+
114
+ test("a verb-set change composes with a shell finding on the same change, both reported", () => {
115
+ const after = withShell();
116
+ const result = classifyPipelineChange(ordinary(), after, { knownKinds: KNOWN });
117
+ expect(result.pipelineChange).toBe(true);
118
+ expect(result.reasons).toContainEqual({ kind: "shell-step", phaseName: "Deploy" });
119
+ expect(result.reasons).toContainEqual({
120
+ kind: "verb-set-changed",
121
+ added: ["shell"],
122
+ removed: ["cfn-deploy", "wait-for-stack"],
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("evaluatePipelineChangeGate (#1569)", () => {
128
+ test("an ordinary composition routes nowhere", () => {
129
+ const verdict = evaluatePipelineChangeGate(undefined, ordinary(), "human-always", { knownKinds: KNOWN });
130
+ expect(verdict.route).toBe("none");
131
+ expect(verdict.pipelineChange).toBe(false);
132
+ });
133
+
134
+ test('defaults to "human-always" — a pipeline change never free-runs unless the caller opts into "stricter-gate"', () => {
135
+ const verdict = evaluatePipelineChangeGate(undefined, withShell(), undefined, { knownKinds: KNOWN });
136
+ expect(verdict.route).toBe("human-always");
137
+ expect(verdict.pipelineChange).toBe(true);
138
+ });
139
+
140
+ test('"stricter-gate" routes a pipeline change to the named stricter class instead', () => {
141
+ const verdict = evaluatePipelineChangeGate(undefined, withShell(), "stricter-gate", { knownKinds: KNOWN });
142
+ expect(verdict.route).toBe("stricter-gate");
143
+ });
144
+ });