@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,179 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ eq, neq, gt, gte, lt, lte, truthy, falsy, allOf, anyOf,
4
+ evaluatePredicate, isWellFormedPredicate, predicateReferencesField,
5
+ run, report, when, duplicateRuleIds,
6
+ DEFAULT_FLAP_THRESHOLD,
7
+ } from "./converge-rule";
8
+
9
+ interface TestSymptom {
10
+ status: string;
11
+ count: number;
12
+ flag: boolean;
13
+ }
14
+
15
+ const symptom: TestSymptom = { status: "drifted", count: 3, flag: true };
16
+
17
+ describe("evaluatePredicate", () => {
18
+ test("eq matches / doesn't match", () => {
19
+ expect(evaluatePredicate(eq<TestSymptom>("status", "drifted"), symptom)).toBe(true);
20
+ expect(evaluatePredicate(eq<TestSymptom>("status", "reconciled"), symptom)).toBe(false);
21
+ });
22
+
23
+ test("neq", () => {
24
+ expect(evaluatePredicate(neq<TestSymptom>("status", "reconciled"), symptom)).toBe(true);
25
+ expect(evaluatePredicate(neq<TestSymptom>("status", "drifted"), symptom)).toBe(false);
26
+ });
27
+
28
+ test("numeric comparisons", () => {
29
+ expect(evaluatePredicate(gt<TestSymptom>("count", 2), symptom)).toBe(true);
30
+ expect(evaluatePredicate(gt<TestSymptom>("count", 3), symptom)).toBe(false);
31
+ expect(evaluatePredicate(gte<TestSymptom>("count", 3), symptom)).toBe(true);
32
+ expect(evaluatePredicate(lt<TestSymptom>("count", 4), symptom)).toBe(true);
33
+ expect(evaluatePredicate(lt<TestSymptom>("count", 3), symptom)).toBe(false);
34
+ expect(evaluatePredicate(lte<TestSymptom>("count", 3), symptom)).toBe(true);
35
+ });
36
+
37
+ test("numeric comparison against a non-number field never throws, just doesn't match", () => {
38
+ expect(evaluatePredicate(gt<TestSymptom>("status" as never, 1), symptom)).toBe(false);
39
+ });
40
+
41
+ test("truthy / falsy", () => {
42
+ expect(evaluatePredicate(truthy<TestSymptom>("flag"), symptom)).toBe(true);
43
+ expect(evaluatePredicate(falsy<TestSymptom>("flag"), symptom)).toBe(false);
44
+ expect(evaluatePredicate(truthy<TestSymptom>("count"), { ...symptom, count: 0 })).toBe(false);
45
+ });
46
+
47
+ test("allOf requires every predicate to match", () => {
48
+ expect(evaluatePredicate(allOf<TestSymptom>(eq("status", "drifted"), gt("count", 2)), symptom)).toBe(true);
49
+ expect(evaluatePredicate(allOf<TestSymptom>(eq("status", "drifted"), gt("count", 10)), symptom)).toBe(false);
50
+ });
51
+
52
+ test("anyOf requires at least one predicate to match", () => {
53
+ expect(evaluatePredicate(anyOf<TestSymptom>(eq("status", "reconciled"), gt("count", 2)), symptom)).toBe(true);
54
+ expect(evaluatePredicate(anyOf<TestSymptom>(eq("status", "reconciled"), gt("count", 10)), symptom)).toBe(false);
55
+ });
56
+
57
+ test("nested allOf/anyOf compose", () => {
58
+ const p = allOf<TestSymptom>(truthy("flag"), anyOf(eq("status", "reconciled"), gt("count", 1)));
59
+ expect(evaluatePredicate(p, symptom)).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("predicateReferencesField", () => {
64
+ test("a field comparison references its own field, not another", () => {
65
+ expect(predicateReferencesField(eq<TestSymptom>("status", "drifted"), "status")).toBe(true);
66
+ expect(predicateReferencesField(eq<TestSymptom>("status", "drifted"), "count")).toBe(false);
67
+ });
68
+
69
+ test("a field truthiness predicate references its own field", () => {
70
+ expect(predicateReferencesField(truthy<TestSymptom>("flag"), "flag")).toBe(true);
71
+ expect(predicateReferencesField(truthy<TestSymptom>("flag"), "status")).toBe(false);
72
+ });
73
+
74
+ test("allOf/anyOf reference a field if any nested predicate does", () => {
75
+ const p = allOf<TestSymptom>(eq("status", "drifted"), gt("count", 2));
76
+ expect(predicateReferencesField(p, "count")).toBe(true);
77
+ expect(predicateReferencesField(p, "flag")).toBe(false);
78
+ });
79
+
80
+ test("recurses through nested allOf/anyOf", () => {
81
+ const p = allOf<TestSymptom>(truthy("flag"), anyOf(eq("status", "reconciled"), gt("count", 1)));
82
+ expect(predicateReferencesField(p, "count")).toBe(true);
83
+ expect(predicateReferencesField(p, "status")).toBe(true);
84
+ expect(predicateReferencesField(p, "flag")).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("isWellFormedPredicate", () => {
89
+ test("accepts every builder's output", () => {
90
+ expect(isWellFormedPredicate(eq<TestSymptom>("status", "x"))).toBe(true);
91
+ expect(isWellFormedPredicate(neq<TestSymptom>("status", "x"))).toBe(true);
92
+ expect(isWellFormedPredicate(gt<TestSymptom>("count", 1))).toBe(true);
93
+ expect(isWellFormedPredicate(truthy<TestSymptom>("flag"))).toBe(true);
94
+ expect(isWellFormedPredicate(falsy<TestSymptom>("flag"))).toBe(true);
95
+ expect(isWellFormedPredicate(allOf<TestSymptom>(eq("status", "x"), truthy("flag")))).toBe(true);
96
+ expect(isWellFormedPredicate(anyOf<TestSymptom>(eq("status", "x")))).toBe(true);
97
+ });
98
+
99
+ test("rejects a malformed shape", () => {
100
+ expect(isWellFormedPredicate(null)).toBe(false);
101
+ expect(isWellFormedPredicate("eq(status, drifted)")).toBe(false);
102
+ expect(isWellFormedPredicate({ kind: "field-comparison", field: "status", op: "regex", value: "x" })).toBe(false);
103
+ expect(isWellFormedPredicate({ kind: "all-of", predicates: [{ kind: "bogus" }] })).toBe(false);
104
+ expect(isWellFormedPredicate(() => true)).toBe(false);
105
+ });
106
+
107
+ test("respects a field whitelist", () => {
108
+ const whitelist = new Set(["status"]);
109
+ expect(isWellFormedPredicate(eq<TestSymptom>("status", "x"), whitelist)).toBe(true);
110
+ expect(isWellFormedPredicate(eq<TestSymptom>("count", 1), whitelist)).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe("run / report", () => {
115
+ test("run() builds a RunAction", () => {
116
+ expect(run("fountain-apply")).toEqual({ kind: "run", op: "fountain-apply" });
117
+ });
118
+ test("report() builds a ReportAction", () => {
119
+ expect(report("unowned resources present")).toEqual({ kind: "report", reason: "unowned resources present" });
120
+ });
121
+ });
122
+
123
+ describe("when()", () => {
124
+ test("builds a well-formed rule", () => {
125
+ const rule = when<TestSymptom>(eq("status", "drifted"), run("fountain-apply"), {
126
+ id: "drift-apply",
127
+ why: "Re-apply on drift.",
128
+ });
129
+ expect(rule).toEqual({
130
+ id: "drift-apply",
131
+ when: { kind: "field-comparison", field: "status", op: "eq", value: "drifted" },
132
+ then: { kind: "run", op: "fountain-apply" },
133
+ why: "Re-apply on drift.",
134
+ });
135
+ });
136
+
137
+ test("throws without an id", () => {
138
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "", why: "because" })).toThrow(/non-empty `id`/);
139
+ });
140
+
141
+ test("throws without a why", () => {
142
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "" })).toThrow(/carry its `why`/);
143
+ });
144
+
145
+ test("throws without a why (whitespace only)", () => {
146
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: " " })).toThrow(/carry its `why`/);
147
+ });
148
+
149
+ test("throws on a non-positive-integer flapThreshold", () => {
150
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y", flapThreshold: 0 })).toThrow(/flapThreshold/);
151
+ expect(() => when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y", flapThreshold: 1.5 })).toThrow(/flapThreshold/);
152
+ });
153
+
154
+ test("flapThreshold defaults are left to the caller (DEFAULT_FLAP_THRESHOLD is exported for that)", () => {
155
+ expect(DEFAULT_FLAP_THRESHOLD).toBeGreaterThan(0);
156
+ const rule = when<TestSymptom>(eq("status", "drifted"), run("x"), { id: "r1", why: "y" });
157
+ expect(rule.flapThreshold).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("duplicateRuleIds", () => {
162
+ test("empty when every id is unique", () => {
163
+ const rules = [
164
+ when<TestSymptom>(eq("status", "a"), report("x"), { id: "r1", why: "y" }),
165
+ when<TestSymptom>(eq("status", "b"), report("x"), { id: "r2", why: "y" }),
166
+ ];
167
+ expect(duplicateRuleIds(rules)).toEqual([]);
168
+ });
169
+
170
+ test("reports every id used more than once, sorted", () => {
171
+ const rules = [
172
+ when<TestSymptom>(eq("status", "a"), report("x"), { id: "r2", why: "y" }),
173
+ when<TestSymptom>(eq("status", "b"), report("x"), { id: "r1", why: "y" }),
174
+ when<TestSymptom>(eq("status", "c"), report("x"), { id: "r1", why: "y" }),
175
+ when<TestSymptom>(eq("status", "d"), report("x"), { id: "r2", why: "y" }),
176
+ ];
177
+ expect(duplicateRuleIds(rules)).toEqual(["r1", "r2"]);
178
+ });
179
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Converge rules (#1484) — the typed, JSON-serializable predicate language a
3
+ * `ConvergeOp` rule table is built from.
4
+ *
5
+ * The issue's illustrative sketch writes a rule's condition as a JS arrow
6
+ * function (`s => s.status === "drifted"`). That reads well but can't
7
+ * actually ship: a rule is evaluated per-tick against live, freshly-observed
8
+ * data inside a Temporal activity, and an activity's arguments — including
9
+ * whatever a `ConvergeOp` composite bakes into its generated workflow — must
10
+ * be plain JSON (see `step-output-ref.ts`'s module doc on why a value
11
+ * crossing the workflow/activity boundary can't carry a closure). A rule
12
+ * "outside the evaluable subset" is one of the build-time refusals #1484
13
+ * names explicitly, which only makes sense if there IS an evaluable subset
14
+ * to be outside of — so rules here are data: a small comparison language
15
+ * over one typed symptom record, interpreted by {@link evaluatePredicate}
16
+ * (pure, no I/O, matching Accessible Ops factor I — "predictable from the
17
+ * text alone").
18
+ *
19
+ * `S` is the symptom record type a `ConvergeOp` instance evaluates against
20
+ * (`ConvergeSymptom`, ../lifecycle/symptoms.ts, for the real thing — kept
21
+ * generic here so the predicate language and its tests don't depend on the
22
+ * lifecycle package). Since a predicate's `field` is typed `keyof S`, a rule
23
+ * that names a field the symptom record doesn't have is refused by
24
+ * TypeScript itself at the author's call site — the "symptom field nothing
25
+ * produces" refusal, satisfied for free. {@link isWellFormedPredicate} is the
26
+ * runtime backstop for a rule assembled by hand (bypassing `when()`/the
27
+ * comparison builders) rather than authored through them, and is what a
28
+ * build-time check (TMP014) re-validates the serialized rule table against.
29
+ */
30
+
31
+ // ── Predicates ───────────────────────────────────────────────────────────────
32
+
33
+ /** Comparison operators over a single symptom field. */
34
+ export type FieldComparisonOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte";
35
+
36
+ /** Truthiness operators over a single symptom field — no `value` needed. */
37
+ export type FieldTruthinessOp = "truthy" | "falsy";
38
+
39
+ export interface FieldComparisonPredicate<S> {
40
+ kind: "field-comparison";
41
+ field: keyof S & string;
42
+ op: FieldComparisonOp;
43
+ value: string | number | boolean;
44
+ }
45
+
46
+ export interface FieldTruthinessPredicate<S> {
47
+ kind: "field-truthiness";
48
+ field: keyof S & string;
49
+ op: FieldTruthinessOp;
50
+ }
51
+
52
+ export interface AllOfPredicate<S> {
53
+ kind: "all-of";
54
+ predicates: SymptomPredicate<S>[];
55
+ }
56
+
57
+ export interface AnyOfPredicate<S> {
58
+ kind: "any-of";
59
+ predicates: SymptomPredicate<S>[];
60
+ }
61
+
62
+ /**
63
+ * A rule's `when` — the evaluable subset #1484 requires: field comparisons
64
+ * and truthiness checks over `S`, composed with `allOf`/`anyOf`. Every
65
+ * variant is plain JSON; there is deliberately no escape hatch to an
66
+ * arbitrary expression.
67
+ */
68
+ export type SymptomPredicate<S> =
69
+ | FieldComparisonPredicate<S>
70
+ | FieldTruthinessPredicate<S>
71
+ | AllOfPredicate<S>
72
+ | AnyOfPredicate<S>;
73
+
74
+ const FIELD_COMPARISON_OPS: ReadonlySet<string> = new Set(["eq", "neq", "gt", "gte", "lt", "lte"]);
75
+ const FIELD_TRUTHINESS_OPS: ReadonlySet<string> = new Set(["truthy", "falsy"]);
76
+
77
+ /**
78
+ * Structural validator for a value claiming to be a {@link SymptomPredicate}
79
+ * — the runtime backstop `TMP014` (lexicons/temporal's post-synth check)
80
+ * re-checks a serialized rule table against, since nothing forces a rule
81
+ * assembled by hand (rather than through the builders below) to match this
82
+ * shape. Recurses through `allOf`/`anyOf`; `fieldWhitelist`, when given,
83
+ * additionally refuses a field name the symptom record doesn't actually
84
+ * produce (defense in depth over TypeScript's `keyof S` check, which only
85
+ * applies at the author's original call site — not to a rule table replayed
86
+ * from JSON).
87
+ */
88
+ export function isWellFormedPredicate(value: unknown, fieldWhitelist?: ReadonlySet<string>): boolean {
89
+ if (typeof value !== "object" || value === null) return false;
90
+ const v = value as Record<string, unknown>;
91
+ switch (v.kind) {
92
+ case "field-comparison":
93
+ return (
94
+ typeof v.field === "string" &&
95
+ (!fieldWhitelist || fieldWhitelist.has(v.field)) &&
96
+ typeof v.op === "string" &&
97
+ FIELD_COMPARISON_OPS.has(v.op) &&
98
+ (typeof v.value === "string" || typeof v.value === "number" || typeof v.value === "boolean")
99
+ );
100
+ case "field-truthiness":
101
+ return (
102
+ typeof v.field === "string" &&
103
+ (!fieldWhitelist || fieldWhitelist.has(v.field)) &&
104
+ typeof v.op === "string" &&
105
+ FIELD_TRUTHINESS_OPS.has(v.op)
106
+ );
107
+ case "all-of":
108
+ case "any-of":
109
+ return Array.isArray(v.predicates) && v.predicates.every((p) => isWellFormedPredicate(p, fieldWhitelist));
110
+ default:
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** Evaluate a {@link SymptomPredicate} against a symptom record. Pure — no I/O, no ambient reads. */
116
+ export function evaluatePredicate<S>(predicate: SymptomPredicate<S>, symptom: S): boolean {
117
+ switch (predicate.kind) {
118
+ case "field-comparison": {
119
+ const actual = symptom[predicate.field];
120
+ switch (predicate.op) {
121
+ case "eq":
122
+ return actual === predicate.value;
123
+ case "neq":
124
+ return actual !== predicate.value;
125
+ case "gt":
126
+ return typeof actual === "number" && typeof predicate.value === "number" && actual > predicate.value;
127
+ case "gte":
128
+ return typeof actual === "number" && typeof predicate.value === "number" && actual >= predicate.value;
129
+ case "lt":
130
+ return typeof actual === "number" && typeof predicate.value === "number" && actual < predicate.value;
131
+ case "lte":
132
+ return typeof actual === "number" && typeof predicate.value === "number" && actual <= predicate.value;
133
+ }
134
+ break;
135
+ }
136
+ case "field-truthiness": {
137
+ const actual = symptom[predicate.field];
138
+ return predicate.op === "truthy" ? !!actual : !actual;
139
+ }
140
+ case "all-of":
141
+ return predicate.predicates.every((p) => evaluatePredicate(p, symptom));
142
+ case "any-of":
143
+ return predicate.predicates.some((p) => evaluatePredicate(p, symptom));
144
+ }
145
+ // Unreachable for a well-formed predicate; a malformed one (bypassing the
146
+ // builders and isWellFormedPredicate) never matches rather than throwing
147
+ // mid-tick — the build-time check is what refuses it before this runs.
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Does a predicate reference the given symptom field anywhere in it,
153
+ * recursing through `allOf`/`anyOf`? The build-time backstop for a claim
154
+ * about a *category* of rule (e.g. "a rule reading `adoptCount`") rather
155
+ * than a single comparison — `TMP014`'s adopt-safety check
156
+ * (../../../lexicons/temporal/src/lint/post-synth/tmp014-converge-rule-refusals.ts)
157
+ * uses this to refuse a rule that both reads `adoptCount` and dispatches a
158
+ * mutating op, rather than merely documenting that authors shouldn't write
159
+ * one.
160
+ */
161
+ export function predicateReferencesField<S>(predicate: SymptomPredicate<S>, field: string): boolean {
162
+ switch (predicate.kind) {
163
+ case "field-comparison":
164
+ case "field-truthiness":
165
+ return predicate.field === field;
166
+ case "all-of":
167
+ case "any-of":
168
+ return predicate.predicates.some((p) => predicateReferencesField(p, field));
169
+ }
170
+ }
171
+
172
+ // ── Predicate builders ──────────────────────────────────────────────────────
173
+
174
+ export function eq<S>(field: keyof S & string, value: string | number | boolean): FieldComparisonPredicate<S> {
175
+ return { kind: "field-comparison", field, op: "eq", value };
176
+ }
177
+ export function neq<S>(field: keyof S & string, value: string | number | boolean): FieldComparisonPredicate<S> {
178
+ return { kind: "field-comparison", field, op: "neq", value };
179
+ }
180
+ export function gt<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
181
+ return { kind: "field-comparison", field, op: "gt", value };
182
+ }
183
+ export function gte<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
184
+ return { kind: "field-comparison", field, op: "gte", value };
185
+ }
186
+ export function lt<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
187
+ return { kind: "field-comparison", field, op: "lt", value };
188
+ }
189
+ export function lte<S>(field: keyof S & string, value: number): FieldComparisonPredicate<S> {
190
+ return { kind: "field-comparison", field, op: "lte", value };
191
+ }
192
+ export function truthy<S>(field: keyof S & string): FieldTruthinessPredicate<S> {
193
+ return { kind: "field-truthiness", field, op: "truthy" };
194
+ }
195
+ export function falsy<S>(field: keyof S & string): FieldTruthinessPredicate<S> {
196
+ return { kind: "field-truthiness", field, op: "falsy" };
197
+ }
198
+ export function allOf<S>(...predicates: SymptomPredicate<S>[]): AllOfPredicate<S> {
199
+ return { kind: "all-of", predicates };
200
+ }
201
+ export function anyOf<S>(...predicates: SymptomPredicate<S>[]): AnyOfPredicate<S> {
202
+ return { kind: "any-of", predicates };
203
+ }
204
+
205
+ // ── Actions ──────────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Dispatch a declared Op by name. `chant run <op>` — the existing local
209
+ * runner (../op's `runOpLocally` under the hood via the CLI) — takes no
210
+ * caller-supplied arguments today, so a dispatched Op reads whatever it
211
+ * needs from its own declared `env`/config rather than from this rule.
212
+ */
213
+ export interface RunAction {
214
+ kind: "run";
215
+ op: string;
216
+ }
217
+
218
+ /**
219
+ * Report only — never a mutation. The log-and-ledger channel is the only one
220
+ * v1 implements (issue open question 5): reusing `ReconcileOp`'s
221
+ * `onDrift: "pull-request" | "issue" | "report"` channels is "obviously
222
+ * right" per the issue, but wiring `pull-request`/`issue` through requires
223
+ * the same reconcile plumbing `ReconcileOp` already owns — out of scope for
224
+ * the smallest honest `ConvergeOp`. A future `channel` field can extend this
225
+ * without a shape change.
226
+ */
227
+ export interface ReportAction {
228
+ kind: "report";
229
+ reason: string;
230
+ }
231
+
232
+ export type RuleAction = RunAction | ReportAction;
233
+
234
+ export function run(op: string): RunAction {
235
+ return { kind: "run", op };
236
+ }
237
+
238
+ export function report(reason: string): ReportAction {
239
+ return { kind: "report", reason };
240
+ }
241
+
242
+ // ── Rules ────────────────────────────────────────────────────────────────────
243
+
244
+ /** A rule table's default flap-damping threshold (issue: "with a default"). */
245
+ export const DEFAULT_FLAP_THRESHOLD = 3;
246
+
247
+ /**
248
+ * One rule: a symptom predicate, the action to take when it matches, and its
249
+ * mandatory rationale. "Every rule carries its why" (Accessible Ops factor
250
+ * III) — `why` is a required field, not an optional one, and `when()` below
251
+ * throws at construction (build time — the same "throw in the factory" shape
252
+ * `ApplyOp`'s `compensate` refusal already uses) if it's missing or blank.
253
+ */
254
+ export interface ConvergeRule<S> {
255
+ /** Stable identifier — used for flap-damping counters, so it must not change across a rule's lifetime just because the table was reordered. */
256
+ id: string;
257
+ when: SymptomPredicate<S>;
258
+ then: RuleAction;
259
+ /** Why this rule exists — refused at build if blank. */
260
+ why: string;
261
+ /** Consecutive ticks the symptom may fire before this rule escalates to report-and-stop. @default {@link DEFAULT_FLAP_THRESHOLD} */
262
+ flapThreshold?: number;
263
+ }
264
+
265
+ /**
266
+ * Declare one converge rule. Throws immediately (build time — this runs
267
+ * while `chant build` evaluates the authoring `*.op.ts` file, exactly like
268
+ * `ApplyOp`'s `compensate` refusal) when `id` or `why` is missing or blank,
269
+ * or `flapThreshold` isn't a positive integer.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * when(eq("status", "drifted"), run("fountain-apply"), {
274
+ * id: "drift-apply",
275
+ * why: "Live config drifted from declared source; re-apply converges it back.",
276
+ * })
277
+ * ```
278
+ */
279
+ export function when<S>(
280
+ predicate: SymptomPredicate<S>,
281
+ action: RuleAction,
282
+ opts: { id: string; why: string; flapThreshold?: number },
283
+ ): ConvergeRule<S> {
284
+ if (!opts.id || opts.id.trim().length === 0) {
285
+ throw new Error("when(): a rule must have a non-empty `id` (used for flap-damping and diagnostics)");
286
+ }
287
+ if (!opts.why || opts.why.trim().length === 0) {
288
+ throw new Error(`when("${opts.id}"): a rule must carry its \`why\` — every rule's rationale is required, refused at build otherwise`);
289
+ }
290
+ if (opts.flapThreshold !== undefined && (!Number.isInteger(opts.flapThreshold) || opts.flapThreshold < 1)) {
291
+ throw new Error(`when("${opts.id}"): flapThreshold must be a positive integer`);
292
+ }
293
+ return {
294
+ id: opts.id,
295
+ when: predicate,
296
+ then: action,
297
+ why: opts.why,
298
+ ...(opts.flapThreshold !== undefined ? { flapThreshold: opts.flapThreshold } : {}),
299
+ };
300
+ }
301
+
302
+ /** Every rule id in a table must be unique — flap-damping counters and dispatch/report accounting are keyed by id. */
303
+ export function duplicateRuleIds<S>(rules: ConvergeRule<S>[]): string[] {
304
+ const seen = new Set<string>();
305
+ const dupes = new Set<string>();
306
+ for (const r of rules) {
307
+ if (seen.has(r.id)) dupes.add(r.id);
308
+ seen.add(r.id);
309
+ }
310
+ return [...dupes].sort();
311
+ }