@intentius/chant 0.91.0 → 0.94.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 (361) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/graph.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts +6 -0
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  9. package/dist/cli/mcp/workspace-tools.d.ts +2 -1
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/plugins.d.ts.map +1 -1
  12. package/dist/cli/registry.d.ts +12 -2
  13. package/dist/cli/registry.d.ts.map +1 -1
  14. package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
  15. package/dist/config.d.ts +22 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/lexicon-module.d.ts +22 -0
  18. package/dist/lexicon-module.d.ts.map +1 -1
  19. package/dist/lifecycle/converge-ledger.d.ts +19 -0
  20. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  21. package/dist/lifecycle/run-ledger.d.ts +2 -0
  22. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/symptoms.d.ts +51 -0
  24. package/dist/lifecycle/symptoms.d.ts.map +1 -1
  25. package/dist/lint/rules/index.d.ts +4 -2
  26. package/dist/lint/rules/index.d.ts.map +1 -1
  27. package/dist/lint/rules/op/index.d.ts +14 -3
  28. package/dist/lint/rules/op/index.d.ts.map +1 -1
  29. package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
  30. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
  31. package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
  32. package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
  33. package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
  34. package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
  35. package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
  36. package/dist/op/activities/activity-contracts.d.ts +201 -0
  37. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  38. package/dist/op/activities/apply.d.ts.map +1 -1
  39. package/dist/op/activities/change-coverage.d.ts +44 -0
  40. package/dist/op/activities/change-coverage.d.ts.map +1 -0
  41. package/dist/op/activities/converge.d.ts +24 -1
  42. package/dist/op/activities/converge.d.ts.map +1 -1
  43. package/dist/op/activities/decide.d.ts +107 -0
  44. package/dist/op/activities/decide.d.ts.map +1 -0
  45. package/dist/op/activities/index.d.ts +10 -0
  46. package/dist/op/activities/index.d.ts.map +1 -1
  47. package/dist/op/activities/shell.d.ts +32 -0
  48. package/dist/op/activities/shell.d.ts.map +1 -1
  49. package/dist/op/activities/source-release.d.ts +86 -0
  50. package/dist/op/activities/source-release.d.ts.map +1 -0
  51. package/dist/op/activities/source-rollback.d.ts +100 -0
  52. package/dist/op/activities/source-rollback.d.ts.map +1 -0
  53. package/dist/op/activities/work-evidence.d.ts +42 -0
  54. package/dist/op/activities/work-evidence.d.ts.map +1 -0
  55. package/dist/op/activity-contract.d.ts +1 -0
  56. package/dist/op/activity-contract.d.ts.map +1 -1
  57. package/dist/op/activity-profiles.d.ts +16 -0
  58. package/dist/op/activity-profiles.d.ts.map +1 -1
  59. package/dist/op/builders.d.ts +84 -2
  60. package/dist/op/builders.d.ts.map +1 -1
  61. package/dist/op/composites/converge-op.d.ts +31 -4
  62. package/dist/op/composites/converge-op.d.ts.map +1 -1
  63. package/dist/op/decide-backend.d.ts +116 -0
  64. package/dist/op/decide-backend.d.ts.map +1 -0
  65. package/dist/op/decide-config.d.ts +120 -0
  66. package/dist/op/decide-config.d.ts.map +1 -0
  67. package/dist/op/decide-read-inputs.d.ts +40 -0
  68. package/dist/op/decide-read-inputs.d.ts.map +1 -0
  69. package/dist/op/gate-wait.d.ts +43 -0
  70. package/dist/op/gate-wait.d.ts.map +1 -0
  71. package/dist/op/gate.d.ts +5 -2
  72. package/dist/op/gate.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +11 -4
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/op/local-executor.d.ts +29 -4
  76. package/dist/op/local-executor.d.ts.map +1 -1
  77. package/dist/op/local-output.d.ts +2 -0
  78. package/dist/op/local-output.d.ts.map +1 -1
  79. package/dist/op/op-ir.d.ts +2 -0
  80. package/dist/op/op-ir.d.ts.map +1 -1
  81. package/dist/op/op-verb-class.d.ts.map +1 -1
  82. package/dist/op/operator.d.ts +97 -1
  83. package/dist/op/operator.d.ts.map +1 -1
  84. package/dist/op/runtime.d.ts +37 -5
  85. package/dist/op/runtime.d.ts.map +1 -1
  86. package/dist/op/runtimes/local.d.ts.map +1 -1
  87. package/dist/op/source-archive.d.ts +63 -0
  88. package/dist/op/source-archive.d.ts.map +1 -0
  89. package/dist/op/steward-points.d.ts +136 -0
  90. package/dist/op/steward-points.d.ts.map +1 -0
  91. package/dist/op/steward-turn.d.ts +53 -0
  92. package/dist/op/steward-turn.d.ts.map +1 -0
  93. package/dist/op/steward.d.ts +22 -1
  94. package/dist/op/steward.d.ts.map +1 -1
  95. package/dist/op/types.d.ts +7 -0
  96. package/dist/op/types.d.ts.map +1 -1
  97. package/dist/op/work-lease-run.d.ts +1 -1
  98. package/dist/op/work-lease-run.d.ts.map +1 -1
  99. package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
  100. package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
  101. package/dist/workspace/answers-ledger.d.ts +76 -0
  102. package/dist/workspace/answers-ledger.d.ts.map +1 -0
  103. package/dist/workspace/box-intent.d.ts +85 -0
  104. package/dist/workspace/box-intent.d.ts.map +1 -0
  105. package/dist/workspace/changes-cli.d.ts +16 -0
  106. package/dist/workspace/changes-cli.d.ts.map +1 -0
  107. package/dist/workspace/changes.d.ts +200 -0
  108. package/dist/workspace/changes.d.ts.map +1 -0
  109. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
  110. package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
  111. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
  112. package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
  113. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
  114. package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
  115. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
  116. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
  117. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
  118. package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
  119. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
  120. package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
  121. package/dist/workspace/chant-migrations.d.ts +104 -0
  122. package/dist/workspace/chant-migrations.d.ts.map +1 -0
  123. package/dist/workspace/checks/boxes.d.ts +47 -1
  124. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  125. package/dist/workspace/checks/diagrams.d.ts +31 -0
  126. package/dist/workspace/checks/diagrams.d.ts.map +1 -0
  127. package/dist/workspace/checks/records.d.ts +7 -0
  128. package/dist/workspace/checks/records.d.ts.map +1 -1
  129. package/dist/workspace/checks.d.ts +7 -0
  130. package/dist/workspace/checks.d.ts.map +1 -1
  131. package/dist/workspace/compose-graph.d.ts +2 -0
  132. package/dist/workspace/compose-graph.d.ts.map +1 -1
  133. package/dist/workspace/conformance/index.d.ts.map +1 -1
  134. package/dist/workspace/decide.d.ts +49 -4
  135. package/dist/workspace/decide.d.ts.map +1 -1
  136. package/dist/workspace/decision-points.schema.json +3 -3
  137. package/dist/workspace/declaration.d.ts +58 -0
  138. package/dist/workspace/declaration.d.ts.map +1 -1
  139. package/dist/workspace/declaration.schema.json +119 -1
  140. package/dist/workspace/declared-kinds.d.ts +20 -0
  141. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  142. package/dist/workspace/generated-files.d.ts +7 -0
  143. package/dist/workspace/generated-files.d.ts.map +1 -1
  144. package/dist/workspace/intent-joins.d.ts +5 -5
  145. package/dist/workspace/intent.d.ts.map +1 -1
  146. package/dist/workspace/lineage-check.d.ts +3 -0
  147. package/dist/workspace/lineage-check.d.ts.map +1 -1
  148. package/dist/workspace/lineage-upgrade.d.ts +6 -1
  149. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  150. package/dist/workspace/ls.d.ts +39 -1
  151. package/dist/workspace/ls.d.ts.map +1 -1
  152. package/dist/workspace/point-answer.schema.json +95 -0
  153. package/dist/workspace/points-cli.d.ts +2 -0
  154. package/dist/workspace/points-cli.d.ts.map +1 -1
  155. package/dist/workspace/points.d.ts +4 -6
  156. package/dist/workspace/points.d.ts.map +1 -1
  157. package/dist/workspace/reason-codes.d.ts +16 -2
  158. package/dist/workspace/reason-codes.d.ts.map +1 -1
  159. package/dist/workspace/records-cli.d.ts +11 -1
  160. package/dist/workspace/records-cli.d.ts.map +1 -1
  161. package/dist/workspace/records-close.d.ts +1 -1
  162. package/dist/workspace/records-close.d.ts.map +1 -1
  163. package/dist/workspace/records-write.d.ts +11 -5
  164. package/dist/workspace/records-write.d.ts.map +1 -1
  165. package/dist/workspace/records.d.ts +34 -4
  166. package/dist/workspace/records.d.ts.map +1 -1
  167. package/dist/workspace/status-gates.d.ts +7 -2
  168. package/dist/workspace/status-gates.d.ts.map +1 -1
  169. package/dist/workspace/status-stewards.d.ts +65 -1
  170. package/dist/workspace/status-stewards.d.ts.map +1 -1
  171. package/dist/workspace/status.d.ts +24 -0
  172. package/dist/workspace/status.d.ts.map +1 -1
  173. package/dist/workspace/work-cli.d.ts +113 -2
  174. package/dist/workspace/work-cli.d.ts.map +1 -1
  175. package/dist/workspace/work-evidence.d.ts +74 -0
  176. package/dist/workspace/work-evidence.d.ts.map +1 -0
  177. package/dist/workspace/work.d.ts +43 -1
  178. package/dist/workspace/work.d.ts.map +1 -1
  179. package/package.json +1 -1
  180. package/src/cli/commands/audit.test.ts +9 -1
  181. package/src/cli/commands/build.ts +3 -2
  182. package/src/cli/commands/lint.ts +7 -1
  183. package/src/cli/handlers/graph.test.ts +20 -0
  184. package/src/cli/handlers/graph.ts +16 -2
  185. package/src/cli/handlers/operator.ts +34 -2
  186. package/src/cli/handlers/run.test.ts +154 -2
  187. package/src/cli/handlers/run.ts +121 -2
  188. package/src/cli/main.test.ts +10 -0
  189. package/src/cli/main.ts +23 -3
  190. package/src/cli/mcp/workspace-plugins.ts +6 -6
  191. package/src/cli/mcp/workspace-tools.test.ts +35 -19
  192. package/src/cli/mcp/workspace-tools.ts +62 -4
  193. package/src/cli/plugins.ts +11 -3
  194. package/src/cli/registry.ts +12 -2
  195. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  196. package/src/codegen/pinned-upgrade.ts +2 -1
  197. package/src/codegen/release-wiring.test.ts +49 -2
  198. package/src/components/capability-plugin-loader.ts +3 -3
  199. package/src/components/cli-support.ts +2 -2
  200. package/src/config.ts +11 -0
  201. package/src/lexicon-module.ts +56 -1
  202. package/src/lexicon-package.test.ts +96 -0
  203. package/src/lifecycle/converge-ledger.ts +20 -0
  204. package/src/lifecycle/run-ledger.ts +6 -0
  205. package/src/lifecycle/symptoms.ts +95 -0
  206. package/src/lint/rules/index.ts +6 -2
  207. package/src/lint/rules/op/index.ts +17 -4
  208. package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
  209. package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
  210. package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
  211. package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
  212. package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
  213. package/src/lint/rules/sys001-no-literal-key.ts +67 -0
  214. package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
  215. package/src/op/activities/activity-contracts.ts +190 -1
  216. package/src/op/activities/apply.ts +7 -6
  217. package/src/op/activities/change-coverage.ts +60 -0
  218. package/src/op/activities/converge.test.ts +55 -1
  219. package/src/op/activities/converge.ts +208 -29
  220. package/src/op/activities/decide.live.test.ts +78 -0
  221. package/src/op/activities/decide.test.ts +308 -0
  222. package/src/op/activities/decide.ts +267 -0
  223. package/src/op/activities/index.ts +21 -0
  224. package/src/op/activities/shell.test.ts +91 -1
  225. package/src/op/activities/shell.ts +91 -2
  226. package/src/op/activities/source-release.test.ts +118 -0
  227. package/src/op/activities/source-release.ts +168 -0
  228. package/src/op/activities/source-rollback.test.ts +131 -0
  229. package/src/op/activities/source-rollback.ts +239 -0
  230. package/src/op/activities/work-evidence.ts +62 -0
  231. package/src/op/activity-contract-registry.test.ts +2 -0
  232. package/src/op/activity-contract-registry.ts +2 -2
  233. package/src/op/activity-contract.test.ts +12 -0
  234. package/src/op/activity-contract.ts +6 -1
  235. package/src/op/activity-profiles.ts +29 -0
  236. package/src/op/activity-registry.test.ts +1 -1
  237. package/src/op/activity-registry.ts +2 -2
  238. package/src/op/builders.ts +119 -5
  239. package/src/op/command-gate.test.ts +192 -0
  240. package/src/op/composites/converge-op.test.ts +27 -1
  241. package/src/op/composites/converge-op.ts +69 -15
  242. package/src/op/decide-backend.test.ts +44 -0
  243. package/src/op/decide-backend.ts +212 -0
  244. package/src/op/decide-config.test.ts +52 -0
  245. package/src/op/decide-config.ts +80 -0
  246. package/src/op/decide-read-inputs.ts +117 -0
  247. package/src/op/gate-approval.ts +2 -2
  248. package/src/op/gate-wait.ts +57 -0
  249. package/src/op/gate.ts +8 -3
  250. package/src/op/generate-pipeline.ts +2 -2
  251. package/src/op/index.ts +18 -3
  252. package/src/op/local-executor.ts +151 -15
  253. package/src/op/local-output.test.ts +33 -0
  254. package/src/op/local-output.ts +20 -2
  255. package/src/op/op-ir.test.ts +10 -0
  256. package/src/op/op-ir.ts +4 -0
  257. package/src/op/op-verb-class.ts +1 -0
  258. package/src/op/operator.ts +290 -11
  259. package/src/op/runtime.ts +31 -8
  260. package/src/op/runtimes/local.ts +3 -1
  261. package/src/op/source-archive.ts +161 -0
  262. package/src/op/steward-points.test.ts +364 -0
  263. package/src/op/steward-points.ts +194 -0
  264. package/src/op/steward-turn.ts +79 -0
  265. package/src/op/steward.test.ts +87 -3
  266. package/src/op/steward.ts +25 -1
  267. package/src/op/types.ts +7 -0
  268. package/src/op/work-lease-run.test.ts +20 -1
  269. package/src/op/work-lease-run.ts +2 -2
  270. package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
  271. package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
  272. package/src/workspace/answers-ledger.ts +118 -0
  273. package/src/workspace/box-intent.test.ts +205 -0
  274. package/src/workspace/box-intent.ts +159 -0
  275. package/src/workspace/changes-cli.ts +56 -0
  276. package/src/workspace/changes.schema.json +472 -0
  277. package/src/workspace/changes.test.ts +315 -0
  278. package/src/workspace/changes.ts +512 -0
  279. package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
  280. package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
  281. package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
  282. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
  283. package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
  284. package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
  285. package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
  286. package/src/workspace/chant-migrations.ts +169 -0
  287. package/src/workspace/check-contract.test.ts +4 -2
  288. package/src/workspace/check.schema.json +19 -5
  289. package/src/workspace/checks/boxes.test.ts +82 -2
  290. package/src/workspace/checks/boxes.ts +202 -1
  291. package/src/workspace/checks/diagrams.test.ts +134 -0
  292. package/src/workspace/checks/diagrams.ts +108 -0
  293. package/src/workspace/checks/records.ts +47 -0
  294. package/src/workspace/checks.ts +20 -2
  295. package/src/workspace/compose-graph.ts +2 -0
  296. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
  297. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
  298. package/src/workspace/conformance/index.ts +5 -0
  299. package/src/workspace/decide.test.ts +1 -1
  300. package/src/workspace/decide.ts +156 -23
  301. package/src/workspace/decision-points.schema.json +3 -3
  302. package/src/workspace/declaration.schema.json +119 -1
  303. package/src/workspace/declaration.test.ts +58 -0
  304. package/src/workspace/declaration.ts +112 -0
  305. package/src/workspace/declared-kinds.test.ts +31 -5
  306. package/src/workspace/declared-kinds.ts +46 -1
  307. package/src/workspace/generated-files.ts +14 -0
  308. package/src/workspace/graph-cli.ts +1 -1
  309. package/src/workspace/graph.schema.json +9 -1
  310. package/src/workspace/intent-joins.test.ts +5 -5
  311. package/src/workspace/intent-joins.ts +5 -5
  312. package/src/workspace/intent.schema.json +8 -2
  313. package/src/workspace/intent.ts +3 -10
  314. package/src/workspace/lineage-check.ts +16 -1
  315. package/src/workspace/lineage-upgrade-cli.ts +1 -1
  316. package/src/workspace/lineage-upgrade.test.ts +70 -0
  317. package/src/workspace/lineage-upgrade.ts +81 -10
  318. package/src/workspace/ls-contract.test.ts +57 -0
  319. package/src/workspace/ls.schema.json +46 -1
  320. package/src/workspace/ls.ts +62 -3
  321. package/src/workspace/points-cli.ts +17 -3
  322. package/src/workspace/points-write.schema.json +11 -1
  323. package/src/workspace/points.schema.json +51 -0
  324. package/src/workspace/points.test.ts +16 -15
  325. package/src/workspace/points.ts +5 -17
  326. package/src/workspace/read-contract.test.ts +9 -1
  327. package/src/workspace/reason-codes.test.ts +18 -8
  328. package/src/workspace/reason-codes.ts +21 -2
  329. package/src/workspace/records-amend.schema.json +2 -0
  330. package/src/workspace/records-cli.ts +27 -4
  331. package/src/workspace/records-close.schema.json +2 -0
  332. package/src/workspace/records-formats.test.ts +15 -15
  333. package/src/workspace/records-new.schema.json +2 -0
  334. package/src/workspace/records-review.schema.json +2 -0
  335. package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
  336. package/src/workspace/records-sessions-write.test.ts +2 -34
  337. package/src/workspace/records-since.test.ts +8 -7
  338. package/src/workspace/records-write.e2e.test.ts +108 -0
  339. package/src/workspace/records-write.test.ts +32 -41
  340. package/src/workspace/records-write.ts +33 -15
  341. package/src/workspace/records.schema.json +36 -2
  342. package/src/workspace/records.test.ts +49 -4
  343. package/src/workspace/records.ts +109 -3
  344. package/src/workspace/status-contract.test.ts +14 -8
  345. package/src/workspace/status-gates.ts +9 -4
  346. package/src/workspace/status-stewards.ts +155 -5
  347. package/src/workspace/status.schema.json +129 -8
  348. package/src/workspace/status.ts +46 -1
  349. package/src/workspace/work-acceptance.test.ts +274 -0
  350. package/src/workspace/{work-cli.test.ts → work-cli.e2e.test.ts} +1 -1
  351. package/src/workspace/work-cli.ts +193 -10
  352. package/src/workspace/work-evidence.schema.json +212 -0
  353. package/src/workspace/work-evidence.ts +169 -0
  354. package/src/workspace/work-history.schema.json +349 -0
  355. package/src/workspace/work-history.test.ts +156 -0
  356. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  357. package/src/workspace/work.test.ts +20 -2
  358. package/src/workspace/work.ts +100 -1
  359. /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
  360. /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
  361. /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
@@ -124,3 +124,98 @@ export const CONVERGE_SYMPTOM_FIELDS: ReadonlySet<string> = new Set([
124
124
  "unobservedReasons",
125
125
  "totalCount",
126
126
  ]);
127
+
128
+ // ── Resources an observer step reports (#2778) ──────────────────────────────
129
+
130
+ /**
131
+ * One resource's verdict, as an observer step reports it: `in-sync` when it
132
+ * is what its declaration says, `drifted` when it isn't, and `unknown` when
133
+ * the observer could not tell.
134
+ */
135
+ export type ResourceStatus = "in-sync" | "drifted" | "unknown";
136
+
137
+ /** One resource an observer step looked at. */
138
+ export interface ObservedResource {
139
+ name: string;
140
+ status: ResourceStatus;
141
+ /** Why the observer gave that verdict, in one line. */
142
+ detail?: string;
143
+ }
144
+
145
+ /**
146
+ * What a `ConvergeOp`'s observer step returns (#2778), for resources no
147
+ * lexicon declares or observes: the processes a supervisor runs in a box,
148
+ * for example. `ConvergeOp({ observe })` evaluates its rules once per
149
+ * resource, against a {@link ResourceSymptom}.
150
+ */
151
+ export interface ResourceObservation {
152
+ resources: ObservedResource[];
153
+ }
154
+
155
+ /**
156
+ * The record a `ConvergeOp` with an observer step evaluates each rule
157
+ * against, once per observed resource (#2778). A rule reads `status` (and
158
+ * may read `resource` to single one out); a `run()` it fires is dispatched
159
+ * for that resource, which the dispatched Op reads from
160
+ * `CHANT_CONVERGE_RESOURCE`.
161
+ */
162
+ export interface ResourceSymptom {
163
+ env: string;
164
+ /** The resource's name, as the observer reported it. */
165
+ resource: string;
166
+ status: ResourceStatus;
167
+ /** The observer's one-line reason, or "". */
168
+ detail: string;
169
+ }
170
+
171
+ /**
172
+ * The environment variable a dispatched Op reads the resource from, when a
173
+ * ConvergeOp with an observer step fired its rule for one resource (#2778).
174
+ */
175
+ export const CONVERGE_RESOURCE_ENV = "CHANT_CONVERGE_RESOURCE";
176
+
177
+ /** Every field a {@link ResourceSymptom} produces: OPS014's whitelist for a ConvergeOp with an observer step. */
178
+ export const RESOURCE_SYMPTOM_FIELDS: ReadonlySet<string> = new Set(["env", "resource", "status", "detail"]);
179
+
180
+ const RESOURCE_STATUSES: ReadonlySet<string> = new Set(["in-sync", "drifted", "unknown"]);
181
+
182
+ /**
183
+ * Read an observer step's result as a {@link ResourceObservation}. The
184
+ * result is the observation itself, or a `shell()` step's result whose
185
+ * stdout is the observation as JSON. Throws, naming what is wrong, on
186
+ * anything else: a tick must not converge from a reading it can't parse.
187
+ */
188
+ export function parseResourceObservation(value: unknown): ResourceObservation {
189
+ let candidate: unknown = value;
190
+ if (candidate && typeof candidate === "object" && !("resources" in candidate) && typeof (candidate as { stdout?: unknown }).stdout === "string") {
191
+ const stdout = (candidate as { stdout: string }).stdout;
192
+ try {
193
+ candidate = JSON.parse(stdout);
194
+ } catch {
195
+ throw new Error(`the observer step's stdout is not JSON: ${stdout.slice(0, 120)}`);
196
+ }
197
+ }
198
+ const resources = (candidate as { resources?: unknown } | null)?.resources;
199
+ if (!Array.isArray(resources)) {
200
+ throw new Error("the observer step returned no `resources` array: it must return { resources: [{ name, status }] }, or print it as JSON");
201
+ }
202
+ const seen = new Set<string>();
203
+ const out: ObservedResource[] = [];
204
+ for (const [i, r] of resources.entries()) {
205
+ const entry = r as { name?: unknown; status?: unknown; detail?: unknown } | null;
206
+ if (!entry || typeof entry.name !== "string" || entry.name === "") {
207
+ throw new Error(`the observer step's resources[${i}] has no name`);
208
+ }
209
+ if (typeof entry.status !== "string" || !RESOURCE_STATUSES.has(entry.status)) {
210
+ throw new Error(`the observer step's resource "${entry.name}" has status ${JSON.stringify(entry.status)}: it must be in-sync, drifted or unknown`);
211
+ }
212
+ if (seen.has(entry.name)) throw new Error(`the observer step reported resource "${entry.name}" twice`);
213
+ seen.add(entry.name);
214
+ out.push({
215
+ name: entry.name,
216
+ status: entry.status as ResourceStatus,
217
+ ...(typeof entry.detail === "string" && entry.detail !== "" ? { detail: entry.detail } : {}),
218
+ });
219
+ }
220
+ return { resources: out };
221
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Core lint rules for chant projects (COR + EVL).
2
+ * Core lint rules for chant projects (COR + EVL, and SYS001 for the `decide`
3
+ * activity's backends, #2828).
3
4
  */
4
5
 
5
6
  import type { LintRule } from "../rule";
@@ -29,6 +30,7 @@ export { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-
29
30
  export { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
30
31
  export { cor022ReceiptLeafRule, collectReceiptVariables, receiptFactoryCall } from "./cor022-receipt-leaf";
31
32
  export { cor024ReceiptSecretPointerRule } from "./cor024-receipt-secret-pointer";
33
+ export { noLiteralKeyRule } from "./sys001-no-literal-key";
32
34
  export { isInsideCompositeFactory } from "./composite-scope";
33
35
 
34
36
  import { flatDeclarationsRule } from "./flat-declarations";
@@ -56,9 +58,10 @@ import { cor018CompositePreferLexiconTypeRule } from "./cor018-composite-prefer-
56
58
  import { cor021EnvLiteralNameRule } from "./cor021-env-literal-name";
57
59
  import { cor022ReceiptLeafRule } from "./cor022-receipt-leaf";
58
60
  import { cor024ReceiptSecretPointerRule } from "./cor024-receipt-secret-pointer";
61
+ import { noLiteralKeyRule } from "./sys001-no-literal-key";
59
62
 
60
63
  /**
61
- * Load all 24 core lint rules (COR + EVL).
64
+ * Load all core lint rules (COR + EVL + SYS001).
62
65
  */
63
66
  export function loadCoreRules(): LintRule[] {
64
67
  return [
@@ -87,5 +90,6 @@ export function loadCoreRules(): LintRule[] {
87
90
  cor021EnvLiteralNameRule,
88
91
  cor022ReceiptLeafRule,
89
92
  cor024ReceiptSecretPointerRule,
93
+ noLiteralKeyRule,
90
94
  ];
91
95
  }
@@ -5,7 +5,8 @@
5
5
  * `coreReceiptChecks`) so they run over the FULL build result regardless of
6
6
  * which lexicons are configured — an Op is recognized by entity type
7
7
  * (`../../../op/resource.ts`'s `OpResource`), not by which lexicon declared
8
- * it.
8
+ * it. SYS010 (#2828) joined them when the `decide` activity moved into core:
9
+ * it reads the decide steps, and the `decide.backends` the caller passes.
9
10
  */
10
11
 
11
12
  import type { PostSynthCheck } from "../../post-synth";
@@ -13,13 +14,25 @@ import { ops012 } from "./ops012-activity-contract";
13
14
  import { ops013 } from "./ops013-step-output-ref";
14
15
  import { ops014 } from "./ops014-converge-rule-refusals";
15
16
  import { ops015 } from "./ops015-gate-approval";
17
+ import { sys010Check } from "./sys010-key-over-plain-http";
18
+ import type { DecideBackend } from "../../../op/decide-config";
16
19
 
17
20
  export { ops012 } from "./ops012-activity-contract";
18
21
  export { ops013 } from "./ops013-step-output-ref";
19
22
  export { ops014 } from "./ops014-converge-rule-refusals";
20
23
  export { ops015 } from "./ops015-gate-approval";
24
+ export { sys010, sys010Check } from "./sys010-key-over-plain-http";
21
25
 
22
- /** Core's own post-synth checks over the Op model — OPS012, OPS013, OPS014, OPS015. */
23
- export function coreOpChecks(): PostSynthCheck[] {
24
- return [ops012, ops013, ops014, ops015];
26
+ export interface CoreOpCheckOptions {
27
+ /** `decide.backends` from the project's chant.config, for SYS010 (#2828). */
28
+ decideBackends?: Record<string, Pick<DecideBackend, "url" | "key">>;
29
+ }
30
+
31
+ /**
32
+ * Core's own post-synth checks over the Op model: OPS012, OPS013, OPS014,
33
+ * OPS015, and SYS010 over the `decide` steps and the configured decide
34
+ * backends.
35
+ */
36
+ export function coreOpChecks(opts: CoreOpCheckOptions = {}): PostSynthCheck[] {
37
+ return [ops012, ops013, ops014, ops015, sys010Check(opts.decideBackends)];
25
38
  }
@@ -8,7 +8,7 @@ import type { PostSynthContext } from "../../post-synth";
8
8
  import { DECLARABLE_MARKER } from "../../../declarable";
9
9
  import { when, eq, gt, allOf, run, report } from "../../../op";
10
10
  import type { ConvergeRule } from "../../../op";
11
- import type { ConvergeSymptom } from "../../../lifecycle/symptoms";
11
+ import type { ConvergeSymptom, ResourceSymptom } from "../../../lifecycle/symptoms";
12
12
  import { ops014 } from "./ops014-converge-rule-refusals";
13
13
 
14
14
  function makeEntity(entityType: string, props: Record<string, unknown>) {
@@ -261,3 +261,25 @@ describe("OPS014: converge-rule-refusals", () => {
261
261
  expect(ops014.check(ctx)).toHaveLength(0);
262
262
  });
263
263
  });
264
+
265
+ describe("OPS014 over a ConvergeOp with an observer step (#2778)", () => {
266
+ test("its rules are checked against ResourceSymptom's fields", () => {
267
+ const observed = (rules: unknown[]) =>
268
+ makeEntity("Chant::Op", {
269
+ name: "box-converge",
270
+ overview: "test",
271
+ labels: { Converge: "true", Env: "box", Dial: "apply", Observe: "step" },
272
+ phases: [
273
+ { name: "Observe", steps: [{ kind: "activity", fn: "spriteServicesObserve", args: {}, id: "observe" }] },
274
+ { name: "Converge", steps: [{ kind: "activity", fn: "convergeTick", args: { rules, observed: { kind: "step-output-ref", step: "observe" } } }] },
275
+ ],
276
+ });
277
+ const restartOp = opEntity("restart-service", [{ kind: "activity", fn: "spriteServiceRestart", args: {} }]);
278
+ const good = when<ResourceSymptom>(eq("status", "drifted"), run("restart-service"), { id: "restart", why: "x" });
279
+ const wrongField = { ...good, id: "adopt", when: { kind: "field-comparison", field: "adoptCount", op: "gt", value: 0 } };
280
+ const ok = ops014.check(makeCtxFromEntities(new Map<string, unknown>([["c", observed([good])], ["r", restartOp]])));
281
+ expect(ok).toEqual([]);
282
+ const bad = ops014.check(makeCtxFromEntities(new Map<string, unknown>([["c", observed([wrongField])], ["r", restartOp]])));
283
+ expect(bad.map((d) => d.message)).toEqual([expect.stringMatching(/rule "adopt": predicate is outside the evaluable subset/)]);
284
+ });
285
+ });
@@ -53,7 +53,7 @@
53
53
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
54
54
  import { classifyOpVerbClass, isWellFormedPredicate, predicateReferencesField } from "../../../op";
55
55
  import type { ConvergeRule, OpConfig } from "../../../op";
56
- import { CONVERGE_SYMPTOM_FIELDS, type ConvergeSymptom } from "../../../lifecycle/symptoms";
56
+ import { CONVERGE_SYMPTOM_FIELDS, RESOURCE_SYMPTOM_FIELDS, type ConvergeSymptom } from "../../../lifecycle/symptoms";
57
57
  import { isOpEntity } from "./support";
58
58
 
59
59
  function looksLikeOpProps(props: Record<string, unknown>): boolean {
@@ -61,12 +61,15 @@ function looksLikeOpProps(props: Record<string, unknown>): boolean {
61
61
  }
62
62
 
63
63
  /** Read a ConvergeOp's rule table back off its `convergeTick` step's `args.rules`. `undefined` if this doesn't look like a ConvergeOp-shaped Op (defensive — never crashes the build over an unexpected shape). */
64
- function findConvergeRules(props: OpConfig): ConvergeRule<ConvergeSymptom>[] | undefined {
64
+ function findConvergeRules(props: OpConfig): { rules: ConvergeRule<ConvergeSymptom>[]; fields: ReadonlySet<string> } | undefined {
65
65
  for (const phase of props.phases) {
66
66
  for (const step of phase.steps) {
67
67
  if (step.kind === "activity" && step.fn === "convergeTick") {
68
68
  const rules = step.args?.rules;
69
- return Array.isArray(rules) ? (rules as ConvergeRule<ConvergeSymptom>[]) : undefined;
69
+ // #2778: a ConvergeOp with an observer step passes its result as
70
+ // `observed`, and its rules read a ResourceSymptom.
71
+ const fields = step.args?.observed !== undefined ? RESOURCE_SYMPTOM_FIELDS : CONVERGE_SYMPTOM_FIELDS;
72
+ return Array.isArray(rules) ? { rules: rules as ConvergeRule<ConvergeSymptom>[], fields } : undefined;
70
73
  }
71
74
  }
72
75
  }
@@ -100,8 +103,9 @@ export const ops014: PostSynthCheck = {
100
103
  if (props.labels?.Converge !== "true") continue;
101
104
 
102
105
  const dial = props.labels?.Dial ?? "observe";
103
- const rules = findConvergeRules(props);
104
- if (!rules) continue;
106
+ const found = findConvergeRules(props);
107
+ if (!found) continue;
108
+ const { rules, fields } = found;
105
109
 
106
110
  for (const rule of rules) {
107
111
  const label = typeof rule?.id === "string" ? rule.id : "(unnamed rule)";
@@ -119,7 +123,7 @@ export const ops014: PostSynthCheck = {
119
123
  push("every rule must carry its why — refused at build");
120
124
  continue;
121
125
  }
122
- if (!isWellFormedPredicate(rule.when, CONVERGE_SYMPTOM_FIELDS)) {
126
+ if (!isWellFormedPredicate(rule.when, fields)) {
123
127
  push("predicate is outside the evaluable subset (build it from eq/neq/gt/gte/lt/lte/truthy/falsy/allOf/anyOf over a known symptom field)");
124
128
  continue;
125
129
  }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Op, phase, decide } from "../../../op";
3
+ import type { PostSynthContext } from "../../post-synth";
4
+ import { plainRemoteHost, sys010, sys010Check } from "./sys010-key-over-plain-http";
5
+ import { coreOpChecks } from "./index";
6
+
7
+ const ctx = (op?: unknown): PostSynthContext => ({ outputs: new Map(), entities: new Map(op ? [["op", op as never]] : []), buildResult: {} as never }) as unknown as PostSynthContext;
8
+
9
+ describe("SYS010", () => {
10
+ it("fires when a decide step sends a key over plain http to a remote host", () => {
11
+ const op = Op({ name: "ask", overview: "ask", phases: [phase("Ask", [decide("triage", { backends: { s: { url: "http://jev.example.com", key: { env: "K" } } } })])] });
12
+ const d = sys010.check(ctx(op));
13
+ expect(d).toHaveLength(1);
14
+ expect(d[0]).toMatchObject({ checkId: "SYS010", severity: "error" });
15
+ expect(d[0].message).toContain("jev.example.com");
16
+ });
17
+
18
+ it("is quiet for https, for loopback, and for a backend with no key", () => {
19
+ const op = Op({
20
+ name: "ask",
21
+ overview: "ask",
22
+ phases: [
23
+ phase("Ask", [
24
+ decide("a", { backends: { s: { url: "https://api.typesafe.ai", key: { env: "K" } } } }),
25
+ decide("b", { backends: { s: { url: "http://127.0.0.1:8080", key: { env: "K" } } } }),
26
+ decide("c", { backends: { s: { url: "http://jev.example.com" } } }),
27
+ decide("d"),
28
+ ]),
29
+ ],
30
+ });
31
+ expect(sys010.check(ctx(op))).toEqual([]);
32
+ });
33
+
34
+ it("fires on a backend configured in decide.backends, and is quiet for https", () => {
35
+ const check = sys010Check({ remote: { url: "http://jev.internal:8080", key: { env: "K" } }, ok: { url: "https://api.typesafe.ai", key: { env: "K" } } });
36
+ const d = check.check(ctx());
37
+ expect(d.map((x) => x.entity)).toEqual(["decide.backends.remote"]);
38
+ });
39
+
40
+ it("runs with core's Op checks, with the configured backends when given", () => {
41
+ expect(coreOpChecks().map((c) => c.id)).toContain("SYS010");
42
+ const configured = coreOpChecks({ decideBackends: { remote: { url: "http://10.0.0.2", key: { env: "K" } } } }).find((c) => c.id === "SYS010")!;
43
+ expect(configured.check(ctx())).toHaveLength(1);
44
+ });
45
+
46
+ it("reads hosts", () => {
47
+ expect(plainRemoteHost("http://localhost:1")).toBeUndefined();
48
+ expect(plainRemoteHost("http://[::1]:1")).toBeUndefined();
49
+ expect(plainRemoteHost("http://10.0.0.2")).toBe("10.0.0.2");
50
+ expect(plainRemoteHost("not a url")).toBeUndefined();
51
+ });
52
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * SYS010: a `decide` backend sends its key over plain HTTP (#2740, core's
3
+ * since #2828).
4
+ *
5
+ * A backend in `decide.backends` in chant.config, or in a `decide` step's own
6
+ * `backends`, may have a key and an `http://` URL. The bearer key then crosses
7
+ * the network in clear text. A loopback address (127.0.0.1, ::1, localhost)
8
+ * is exempt, since that is where a local server such as `von serve` or the
9
+ * stub runs, and where a broker that is itself the endpoint listens.
10
+ *
11
+ * The steps are read off the Op entities, which covers a step written with
12
+ * the `decide` builder and one written with `activity("decide", ...)`. The
13
+ * configured backends are not entities: `chant build` and `chant lint` read
14
+ * them from the project's config and hand them to {@link sys010Check}. The id
15
+ * keeps the SYS prefix it had in the systemone lexicon.
16
+ */
17
+
18
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "../../post-synth";
19
+ import type { OpConfig, PhaseDefinition, ActivityStep } from "../../../op/types";
20
+ import type { DecideBackend } from "../../../op/decide-config";
21
+ import { isOpEntity } from "./support";
22
+
23
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
24
+
25
+ function* decideSteps(phases: PhaseDefinition[] | undefined): Generator<ActivityStep> {
26
+ for (const phase of phases ?? []) {
27
+ for (const step of phase.steps ?? []) {
28
+ if (step.kind === "activity" && step.fn === "decide") yield step;
29
+ else if (step.kind === "effect") {
30
+ for (const nested of step.steps ?? []) if (nested.kind === "activity" && nested.fn === "decide") yield nested;
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ /** The host of an http:// URL that is not loopback, or undefined. */
37
+ export function plainRemoteHost(url: unknown): string | undefined {
38
+ if (typeof url !== "string") return undefined;
39
+ let parsed: URL;
40
+ try {
41
+ parsed = new URL(url);
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ if (parsed.protocol !== "http:") return undefined;
46
+ return LOOPBACK.has(parsed.hostname) ? undefined : parsed.hostname;
47
+ }
48
+
49
+ const DESCRIPTION = "A decide backend sends its key over plain HTTP to a host that is not loopback";
50
+
51
+ /**
52
+ * SYS010 over the Op entities and, when given, the backends configured in
53
+ * `decide.backends`. A configured backend's diagnostic names it as the entity
54
+ * `decide.backends.<name>`.
55
+ */
56
+ export function sys010Check(configured?: Record<string, Pick<DecideBackend, "url" | "key">>): PostSynthCheck {
57
+ return {
58
+ id: "SYS010",
59
+ description: DESCRIPTION,
60
+
61
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
62
+ const diagnostics: PostSynthDiagnostic[] = [];
63
+ for (const [name, b] of Object.entries(configured ?? {})) {
64
+ const host = b?.key !== undefined ? plainRemoteHost(b.url) : undefined;
65
+ if (!host) continue;
66
+ diagnostics.push({
67
+ checkId: "SYS010",
68
+ severity: "error",
69
+ message: `decide.backends.${name} sends its key over http:// to ${host}; use https://, or a loopback broker`,
70
+ entity: `decide.backends.${name}`,
71
+ });
72
+ }
73
+ for (const [entityKey, entity] of ctx.entities) {
74
+ if (!isOpEntity(entity)) continue;
75
+ const rec = entity as unknown as Record<string, unknown>;
76
+ const props = ((entity as { props?: Record<string, unknown> }).props ?? {}) as unknown as OpConfig;
77
+ for (const step of [...decideSteps(props.phases), ...decideSteps(props.onFailure)]) {
78
+ const backends = (step.args as { backends?: unknown } | undefined)?.backends;
79
+ if (backends === null || typeof backends !== "object") continue;
80
+ for (const [name, b] of Object.entries(backends as Record<string, { url?: unknown; key?: unknown }>)) {
81
+ if (b?.key === undefined) continue;
82
+ const host = plainRemoteHost(b.url);
83
+ if (!host) continue;
84
+ diagnostics.push({
85
+ checkId: "SYS010",
86
+ severity: "error",
87
+ message: `Op "${props.name}", decide ${JSON.stringify((step.args as { point?: unknown }).point)}: backend ${name} sends its key over http:// to ${host}; use https://, or a loopback broker`,
88
+ entity: entityKey,
89
+ lexicon: typeof rec.lexicon === "string" ? rec.lexicon : undefined,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ return diagnostics;
95
+ },
96
+ };
97
+ }
98
+
99
+ /** SYS010 over the Op entities alone, with no configured backends. */
100
+ export const sys010: PostSynthCheck = sys010Check();
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import ts from "typescript";
3
+ import { noLiteralKeyRule } from "./sys001-no-literal-key";
4
+ import { loadCoreRules } from "./index";
5
+
6
+ const run = (code: string) =>
7
+ noLiteralKeyRule.check({ sourceFile: ts.createSourceFile("x.ts", code, ts.ScriptTarget.Latest, true), filePath: "x.ts", entities: [] } as never);
8
+
9
+ describe("SYS001", () => {
10
+ it("is one of core's rules", () => {
11
+ expect(loadCoreRules().map((r) => r.id)).toContain("SYS001");
12
+ });
13
+
14
+ it("fires on a literal key in the config's decide block", () => {
15
+ const d = run(`export default { decide: { backends: { s: { url: "https://x", key: "sk-abc" } } } };`);
16
+ expect(d.map((x) => x.ruleId)).toEqual(["SYS001"]);
17
+ });
18
+
19
+ it("fires on a literal key in a decide step's backends", () => {
20
+ expect(run('decide("p", { backends: { s: { url: "https://x", key: `k` } } });')).toHaveLength(1);
21
+ });
22
+
23
+ it("is quiet for { env } and a brokered capability", () => {
24
+ expect(run(`export default { decide: { backends: { s: { url: "https://x", key: { env: "K" } }, b: { url: "http://127.0.0.1", key: { capability: "inference" } } } } };`)).toEqual([]);
25
+ });
26
+
27
+ it("is quiet for a key property outside decide, and for another tool's backends", () => {
28
+ expect(run(`const x = { key: "not a backend" };`)).toEqual([]);
29
+ expect(run(`const tf = { backends: { s3: { bucket: "b", key: "state/terraform.tfstate" } } };`)).toEqual([]);
30
+ });
31
+ });
@@ -0,0 +1,67 @@
1
+ import ts from "typescript";
2
+ import type { LintRule, LintDiagnostic, LintContext } from "../rule";
3
+
4
+ /**
5
+ * SYS001: a `decide` backend's key is never a literal (#2740, core's since
6
+ * #2828).
7
+ *
8
+ * A key is `{ env: "VARIABLE" }` or a brokered capability
9
+ * (`{ capability: "inference", member: "box" }`, #2726). A string written
10
+ * where a key goes is a credential in git history, and a box that holds one
11
+ * fails `workspace check` with WSP121 as well.
12
+ *
13
+ * Fires on a `key` property whose value is a string or template literal,
14
+ * inside a backend of a `backends` object that belongs to `decide`: the
15
+ * `decide` block of a config object (`decide: { backends: ... }`) or the
16
+ * options of a `decide(...)` step. A `backends` object anywhere else, such as
17
+ * a Terraform backend's `key`, is not a decide backend and is left alone.
18
+ * The id keeps the SYS prefix it had in the systemone lexicon.
19
+ */
20
+
21
+ function keyText(name: ts.PropertyName): string | undefined {
22
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
23
+ return undefined;
24
+ }
25
+
26
+ const isLiteral = (node: ts.Expression): boolean => ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node);
27
+
28
+ const isDecideCall = (node: ts.Node): node is ts.CallExpression =>
29
+ ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "decide";
30
+
31
+ /** Where the walk is: outside decide, inside decide's options or config block, or inside its `backends`. */
32
+ type Scope = "outside" | "decide" | "backends";
33
+
34
+ export const noLiteralKeyRule: LintRule = {
35
+ id: "SYS001",
36
+ severity: "error",
37
+ category: "security",
38
+ description: "A decide backend key is { env } or a brokered capability, never a literal string",
39
+
40
+ check(context: LintContext): LintDiagnostic[] {
41
+ const diagnostics: LintDiagnostic[] = [];
42
+ const source = context.sourceFile;
43
+ const visit = (node: ts.Node, scope: Scope) => {
44
+ let within = scope;
45
+ if (isDecideCall(node)) within = "decide";
46
+ else if (ts.isPropertyAssignment(node)) {
47
+ const key = keyText(node.name);
48
+ if (key === "decide") within = "decide";
49
+ else if (key === "backends" && scope === "decide") within = "backends";
50
+ if (scope === "backends" && key === "key" && isLiteral(node.initializer)) {
51
+ const { line, character } = source.getLineAndCharacterOfPosition(node.initializer.getStart(source));
52
+ diagnostics.push({
53
+ ruleId: "SYS001",
54
+ severity: "error",
55
+ message: 'Literal backend key: use { env: "VARIABLE" } or a brokered capability ({ capability, member }) instead',
56
+ file: context.filePath,
57
+ line: line + 1,
58
+ column: character + 1,
59
+ });
60
+ }
61
+ }
62
+ ts.forEachChild(node, (child) => visit(child, within));
63
+ };
64
+ visit(source, "outside");
65
+ return diagnostics;
66
+ },
67
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A stub `POST /v1/systemone` server for tests of the `decide` activity
3
+ * (`@intentius/chant/op/__fixtures__/decide-stub-backend`): a real HTTP server on
4
+ * 127.0.0.1 that checks a request the way a Jev-compatible server would and
5
+ * answers with what the test scripts. No model runs.
6
+ *
7
+ * ```ts
8
+ * const stub = await startStubBackend({ key: "k", answers: { slice_tier: { type: "choice", choice: "large", probabilities: {...}, confidence: 0.9 } } });
9
+ * // decide.backends, or a decide step's backends: { systemone: { url: stub.url, key: { env: "STUB_KEY" } } }
10
+ * await stub.close();
11
+ * ```
12
+ *
13
+ * It answers 401 when a key is set and the request's bearer differs, 422 when
14
+ * the body is not a request in the wire format, and `status` when the test
15
+ * forces one (429 or 529, say). Every request it read is kept in `requests`.
16
+ *
17
+ * It lives under `__fixtures__` because it listens on a port, and no module
18
+ * chant runs does (#2657, `test/no-listener.test.ts`): only tests start it.
19
+ */
20
+
21
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
22
+ import type { AddressInfo } from "node:net";
23
+ import type { WireAnswer } from "../../workspace/points";
24
+ import { SYSTEMONE_PATH, type BackendRequest } from "../decide-backend";
25
+
26
+ export interface StubQuestion {
27
+ name: string;
28
+ type: string;
29
+ instructions: string;
30
+ criteria: Record<string, string> | string[];
31
+ }
32
+
33
+ export interface StubBackendOptions {
34
+ /** The bearer key the stub requires. Without it, any request is taken. */
35
+ key?: string;
36
+ /** The model id the response reports. Defaults to the id the request sent. */
37
+ model?: string;
38
+ /** The answer to a question, by question name, or computed from the question. Defaults to {@link defaultAnswer}. */
39
+ answers?: Record<string, WireAnswer> | ((question: StubQuestion, request: BackendRequest) => WireAnswer);
40
+ /** Answer every request with this status and no answers. */
41
+ status?: number;
42
+ }
43
+
44
+ export interface StubBackend {
45
+ /** The base URL, without `/v1/systemone`. */
46
+ url: string;
47
+ /** Each request body the stub read, in order. */
48
+ requests: BackendRequest[];
49
+ /** The Authorization header of each request, or null. */
50
+ authorizations: (string | null)[];
51
+ close(): Promise<void>;
52
+ }
53
+
54
+ /** A confident answer of the question's own type: true, the first option, or the last level. */
55
+ export function defaultAnswer(question: StubQuestion): WireAnswer {
56
+ if (question.type === "noul") return { type: "noul", noul: 0.95 };
57
+ if (question.type === "choice") {
58
+ const options = Object.keys(question.criteria);
59
+ const rest = options.length > 1 ? 0.05 / (options.length - 1) : 0;
60
+ const probabilities = Object.fromEntries(options.map((o, i) => [o, i === 0 ? 0.95 : rest]));
61
+ return { type: "choice", choice: options[0], probabilities, confidence: 0.925 };
62
+ }
63
+ if (question.type === "score") {
64
+ const levels = question.criteria as string[];
65
+ const probabilities = Object.fromEntries(levels.map((l, i) => [l, i === levels.length - 1 ? 0.95 : 0.05 / Math.max(1, levels.length - 1)]));
66
+ return { type: "score", score: levels.length - 0.05, probabilities, confidence: 0.925 };
67
+ }
68
+ return { type: "unsupported" };
69
+ }
70
+
71
+ /** Why a body is not a request in the wire format, or null when it is one. */
72
+ export function requestProblem(body: unknown): string | null {
73
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return "the body is not a JSON object";
74
+ const b = body as Record<string, unknown>;
75
+ if (typeof b.model !== "string" || b.model === "") return "model is required";
76
+ if (b.state === undefined) return "state is required";
77
+ if (b.questions === null || typeof b.questions !== "object" || Array.isArray(b.questions) || Object.keys(b.questions).length === 0) return "questions must name at least one question";
78
+ for (const [name, q] of Object.entries(b.questions as Record<string, unknown>)) {
79
+ const question = q as Record<string, unknown> | null;
80
+ if (question === null || typeof question !== "object") return `questions.${name} is not an object`;
81
+ if (typeof question.instructions !== "string") return `questions.${name}.instructions is required`;
82
+ if (question.type === "noul" || question.type === "choice") {
83
+ if (question.criteria === null || typeof question.criteria !== "object" || Array.isArray(question.criteria)) return `questions.${name}.criteria must be an object`;
84
+ if (question.type === "noul" && !("true" in question.criteria && "false" in question.criteria)) return `questions.${name}.criteria must describe true and false`;
85
+ } else if (question.type === "score") {
86
+ if (!Array.isArray(question.criteria) || question.criteria.length < 2 || question.criteria.length > 10) return `questions.${name}.criteria must be 2 to 10 levels`;
87
+ } else {
88
+ return `questions.${name}.type must be noul, choice or score`;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function send(res: ServerResponse, status: number, body: unknown): void {
95
+ res.writeHead(status, { "content-type": "application/json" });
96
+ res.end(JSON.stringify(body));
97
+ }
98
+
99
+ async function readBody(req: IncomingMessage): Promise<string> {
100
+ const chunks: Buffer[] = [];
101
+ for await (const chunk of req) chunks.push(chunk as Buffer);
102
+ return Buffer.concat(chunks).toString("utf-8");
103
+ }
104
+
105
+ /** Start the stub on a free port on 127.0.0.1. */
106
+ export async function startStubBackend(opts: StubBackendOptions = {}): Promise<StubBackend> {
107
+ const requests: BackendRequest[] = [];
108
+ const authorizations: (string | null)[] = [];
109
+ const server = createServer((req, res) => {
110
+ void (async () => {
111
+ if (req.method !== "POST" || req.url !== SYSTEMONE_PATH) return send(res, 404, { error: { type: "not_found", message: `${req.method} ${req.url}` } });
112
+ const auth = req.headers.authorization ?? null;
113
+ authorizations.push(auth);
114
+ if (opts.key !== undefined && auth !== `Bearer ${opts.key}`) return send(res, 401, { error: { type: "authentication_error", message: "invalid key" } });
115
+ let body: unknown;
116
+ try {
117
+ body = JSON.parse(await readBody(req));
118
+ } catch {
119
+ return send(res, 422, { error: { type: "invalid_request", message: "the body is not JSON" } });
120
+ }
121
+ const problem = requestProblem(body);
122
+ if (problem) return send(res, 422, { error: { type: "invalid_request", message: problem } });
123
+ const request = body as BackendRequest;
124
+ requests.push(request);
125
+ if (opts.status !== undefined) return send(res, opts.status, { error: { type: "stub", message: `the stub answers ${opts.status}` } });
126
+ const answers: Record<string, WireAnswer> = {};
127
+ for (const [name, q] of Object.entries(request.questions)) {
128
+ const question: StubQuestion = { name, ...q };
129
+ const scripted = typeof opts.answers === "function" ? opts.answers(question, request) : opts.answers?.[name];
130
+ answers[name] = scripted ?? defaultAnswer(question);
131
+ }
132
+ send(res, 200, { model: opts.model ?? request.model, answers, usage: { input_tokens: JSON.stringify(request).length, output_tokens: 0 } });
133
+ })().catch((err: unknown) => send(res, 500, { error: { type: "stub", message: err instanceof Error ? err.message : String(err) } }));
134
+ });
135
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
136
+ const { port } = server.address() as AddressInfo;
137
+ return {
138
+ url: `http://127.0.0.1:${port}`,
139
+ requests,
140
+ authorizations,
141
+ close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
142
+ };
143
+ }