@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,140 @@
1
+ /**
2
+ * Gate resolution ledger (#1485, epic #1487) — the durable counterpart to a
3
+ * converge tick's gate-as-fact outcome (`./converge-ledger.ts`'s
4
+ * `ConvergeRuleOutcome.action === "gated"`). Same append-only, content-
5
+ * addressed shape as the converge/release ledgers, reusing
6
+ * `writeBlobToPath`/`readBlobFromPath` (./git.ts) directly — one line per
7
+ * resolution at `_gates/<op>.jsonl` on the `chant/lifecycle` orphan branch.
8
+ *
9
+ * Keyed by op name rather than environment (`_gates`, not `<env>/gates...`)
10
+ * because a gate belongs to the *dispatched* op — the thing a converge
11
+ * rule's `run()` action names — and that op's own env, if it declares one at
12
+ * all, isn't always the calling `ConvergeOp`'s env. `writeBlobToPath`'s own
13
+ * doc already establishes this generic-namespace pattern
14
+ * (`./build-ledger-store.ts`'s `_builds`); this is the same move for a
15
+ * second non-env top-level directory.
16
+ *
17
+ * `chant approve <op> <gate>` (`../cli/handlers/operator.ts`) is what
18
+ * appends here — issue #1485's "resolution is an out-of-band act that
19
+ * writes the counterpart fact". Per the issue's own leaning on open
20
+ * question 3 ("local trust in v1, signature as an additive follow-up"),
21
+ * this record is *not* itself an authorization check — anyone who can run
22
+ * `chant approve` locally can write one, the same trust boundary a local
23
+ * `git commit` already has. What it changes: `chant operator status` (and
24
+ * any future gate-aware dispatch retry) can tell a resolved gate from a
25
+ * still-pending one by finding a resolution newer than the tick that
26
+ * recorded it. It does **not** (v1) retroactively make a gated op's local
27
+ * dispatch succeed — the local executor still refuses any op containing a
28
+ * gate outright (`../op/local-executor.ts`'s `LocalGateUnsupportedError`),
29
+ * unconditionally, gate resolution or not. Wiring an approved gate back
30
+ * into the local executor's dispatch path is the GateStep semantic change
31
+ * issue #1485 itself flags as open question 1 ("suspension → fact... does
32
+ * it land as its own issue first? Leaning: yes, split it out") — deferred
33
+ * here for the same reason the issue defers it: it touches both the local
34
+ * executor and the generated Temporal workflow, and has migration impact on
35
+ * shipped ops. `chant approve` in v1 is the durable, queryable record of
36
+ * "this gate is cleared" that a human (or a future auto-resume path) reads;
37
+ * it is not itself the unblock.
38
+ */
39
+ import { sortedJsonReplacer } from "../utils";
40
+ import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
41
+
42
+ const DIR = "_gates";
43
+ const APPEND_RETRY_ATTEMPTS = 5;
44
+
45
+ /** One immutable gate-resolution record. */
46
+ export interface GateResolutionRecord {
47
+ /** Schema version, so an incompatible future shape is detected before being misread. */
48
+ version: 1;
49
+ /** The dispatched op the gate belongs to. */
50
+ op: string;
51
+ /** The gate's signal name (matches `ConvergeRuleOutcome.gateName`). */
52
+ gate: string;
53
+ /** Who resolved it — an actor name, the same convention `components release --actor` and `run signal --approver` use. */
54
+ resolvedBy: string;
55
+ /** ISO-8601 timestamp, caller-supplied (library code never calls `Date.now()` internally). */
56
+ timestamp: string;
57
+ /** Optional free-text context (e.g. a PR URL — "or a merged PR" is the issue's other resolution path; recording its link here keeps both paths visible from one ledger). */
58
+ note?: string;
59
+ }
60
+
61
+ export type GateResolutionInput = Omit<GateResolutionRecord, "version">;
62
+
63
+ function filename(op: string): string {
64
+ return `${op}.jsonl`;
65
+ }
66
+
67
+ /** Append one immutable gate-resolution record. Does not push to the remote — call `pushLifecycle` (./git.ts) afterward, same two-step shape every other ledger write here uses. Retries on `RefCASConflictError` the same way `appendConvergeRecord` does (./converge-ledger.ts) — a concurrent writer to a different op's/env's file on the same orphan branch is the ordinary case, not an edge case. The baseline read must be `readPathSha` + `readBlobBySha` rather than `readBlobFromPath`, so the exact sha `existing` came from can be passed as `expectPriorPathSha` — see `writeBlobToPath` (./git.ts) for the race that closes. */
68
+ export async function appendGateResolution(
69
+ input: GateResolutionInput,
70
+ opts?: { cwd?: string },
71
+ ): Promise<{ commit: string; record: GateResolutionRecord }> {
72
+ const record: GateResolutionRecord = { version: 1, ...input };
73
+ const json = JSON.stringify(record, sortedJsonReplacer);
74
+
75
+ let lastErr: unknown;
76
+ for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS; attempt++) {
77
+ try {
78
+ const priorSha = await readPathSha(DIR, filename(record.op), opts);
79
+ const existing = priorSha ? await readBlobBySha(priorSha, opts) : null;
80
+ const content = existing ? `${existing.replace(/\n$/, "")}\n${json}` : json;
81
+ const commit = await writeBlobToPath(DIR, filename(record.op), content, "Gate resolution record", {
82
+ ...opts,
83
+ expectPriorPathSha: priorSha,
84
+ });
85
+ return { commit, record };
86
+ } catch (err) {
87
+ if (!(err instanceof RefCASConflictError)) throw err;
88
+ lastErr = err;
89
+ }
90
+ }
91
+ throw lastErr;
92
+ }
93
+
94
+ /** Read every gate-resolution record for `op`, oldest first. Malformed lines are skipped, not thrown on, the same graceful-degradation stance `readConvergeLedger` takes. Returns `[]` (never throws) when `op` has no resolutions recorded yet. */
95
+ export async function readGateResolutions(
96
+ op: string,
97
+ opts?: { cwd?: string },
98
+ ): Promise<{ records: GateResolutionRecord[]; malformed: number }> {
99
+ const content = await readBlobFromPath(DIR, filename(op), opts);
100
+ if (!content) return { records: [], malformed: 0 };
101
+
102
+ const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
103
+ const records: GateResolutionRecord[] = [];
104
+ let malformed = 0;
105
+ for (const line of lines) {
106
+ try {
107
+ const parsed = JSON.parse(line) as Partial<GateResolutionRecord>;
108
+ if (
109
+ parsed.version !== 1 ||
110
+ typeof parsed.op !== "string" ||
111
+ typeof parsed.gate !== "string" ||
112
+ typeof parsed.resolvedBy !== "string" ||
113
+ typeof parsed.timestamp !== "string"
114
+ ) {
115
+ malformed++;
116
+ continue;
117
+ }
118
+ records.push(parsed as GateResolutionRecord);
119
+ } catch {
120
+ malformed++;
121
+ }
122
+ }
123
+ return { records, malformed };
124
+ }
125
+
126
+ /** The most recent resolution for `gate` recorded after `sinceIso` (a gated tick's own timestamp) — what `chant operator status` uses to tell a resolved gate from a still-pending one. `undefined` when no such resolution exists. */
127
+ export function latestResolutionSince(
128
+ records: GateResolutionRecord[],
129
+ gate: string,
130
+ sinceIso: string,
131
+ ): GateResolutionRecord | undefined {
132
+ const since = new Date(sinceIso).getTime();
133
+ let latest: GateResolutionRecord | undefined;
134
+ for (const r of records) {
135
+ if (r.gate !== gate) continue;
136
+ if (new Date(r.timestamp).getTime() < since) continue;
137
+ if (!latest || new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime()) latest = r;
138
+ }
139
+ return latest;
140
+ }
@@ -19,6 +19,16 @@ import {
19
19
  writeBlobToPath,
20
20
  readBlobFromPath,
21
21
  listFilesInDir,
22
+ readRefSha,
23
+ updateRefCAS,
24
+ deleteRefCAS,
25
+ writeBlob,
26
+ readBlobBySha,
27
+ pushRef,
28
+ fetchRef,
29
+ fetchRefInto,
30
+ RefCASConflictError,
31
+ StaleLockError,
22
32
  } from "./git";
23
33
 
24
34
  function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
@@ -157,6 +167,181 @@ describe("lifecycle/git", () => {
157
167
  });
158
168
  });
159
169
 
170
+ // ── Concurrent writers (#1959 finding 1) ────────────────────────────────
171
+ //
172
+ // writeBlobToPath's own CAS-guarded ref update (#1485) closed the silent
173
+ // clobber two concurrent local writers used to hit, but replaced it with
174
+ // an outright throw on ANY conflict — breaking every pre-existing caller
175
+ // that never learned to retry the moment a live `chant operator` (or any
176
+ // other concurrent writer) touched the orphan branch in between. These
177
+ // tests pin the fix: a conflict caused by a DIFFERENT path is absorbed
178
+ // internally (every caller — even ones with no retry logic of their own —
179
+ // is safe); a conflict on the SAME path a read-modify-write caller is
180
+ // writing is NOT blindly retried (that would silently drop data), so it
181
+ // still surfaces for a content-aware caller's own retry to handle.
182
+ describe("writeBlobToPath — concurrent writers on the same branch tip (#1959 finding 1)", () => {
183
+ test("two interleaved writers to DIFFERENT paths both survive with no caller-level retry needed", async () => {
184
+ await withTestDir(async (dir) => {
185
+ await initRepo(dir);
186
+ // Seed the branch so both writers race against a real, non-null parent tip.
187
+ await writeBlobToPath("_seed", "seed.json", "{}", "seed", { cwd: dir });
188
+
189
+ // Two "callers" that — like writeObservationBaseline/writeSnapshot/
190
+ // persistBuildManifest — pass a self-contained `content`, computed
191
+ // before either write starts, and have NO retry loop of their own.
192
+ const [shaA, shaB] = await Promise.all([
193
+ writeBlobToPath("prod", "baseline.json", '{"owner":"A"}', "A's write", { cwd: dir }),
194
+ writeBlobToPath("staging", "baseline.json", '{"owner":"B"}', "B's write", { cwd: dir }),
195
+ ]);
196
+ expect(shaA).toMatch(/^[0-9a-f]{40}$/);
197
+ expect(shaB).toMatch(/^[0-9a-f]{40}$/);
198
+
199
+ // Both landed — neither writer's tree update was lost to the other's race.
200
+ expect(await readBlobFromPath("prod", "baseline.json", { cwd: dir })).toBe('{"owner":"A"}');
201
+ expect(await readBlobFromPath("staging", "baseline.json", { cwd: dir })).toBe('{"owner":"B"}');
202
+ });
203
+ });
204
+
205
+ test("a caller with no retry loop still throws RefCASConflictError (not a silent clobber) when TWO writers race the exact same path", async () => {
206
+ await withTestDir(async (dir) => {
207
+ await initRepo(dir);
208
+ await writeBlobToPath("prod", "solo.json", '{"v":0}', "seed", { cwd: dir });
209
+
210
+ // Both writers read the SAME starting content and race to replace it —
211
+ // exactly the scenario a bare (non-retrying) caller cannot safely
212
+ // resolve on its own, since neither knows about the other's write.
213
+ const results = await Promise.allSettled([
214
+ writeBlobToPath("prod", "solo.json", '{"v":"A"}', "A", { cwd: dir }),
215
+ writeBlobToPath("prod", "solo.json", '{"v":"B"}', "B", { cwd: dir }),
216
+ ]);
217
+
218
+ const fulfilled = results.filter((r) => r.status === "fulfilled");
219
+ const rejected = results.filter((r) => r.status === "rejected");
220
+ // At least one must land; if both raced hard enough that one lost,
221
+ // it must fail loudly as a RefCASConflictError, never silently.
222
+ expect(fulfilled.length).toBeGreaterThanOrEqual(1);
223
+ for (const r of rejected) {
224
+ expect((r as PromiseRejectedResult).reason).toBeInstanceOf(RefCASConflictError);
225
+ }
226
+ });
227
+ });
228
+
229
+ test("appendReleaseRecordLine: two interleaved writers appending to the SAME env's ledger both survive (#1959 finding 1)", async () => {
230
+ await withTestDir(async (dir) => {
231
+ await initRepo(dir);
232
+ const recordA = JSON.stringify({ version: 1, component: "svc-a", digest: "sha256:a" });
233
+ const recordB = JSON.stringify({ version: 1, component: "svc-b", digest: "sha256:b" });
234
+
235
+ await Promise.all([
236
+ appendReleaseRecordLine("prod", recordA, { cwd: dir }),
237
+ appendReleaseRecordLine("prod", recordB, { cwd: dir }),
238
+ ]);
239
+
240
+ const lines = await readReleaseLedgerLines("prod", { cwd: dir });
241
+ expect(lines).toHaveLength(2);
242
+ expect(lines).toContain(recordA);
243
+ expect(lines).toContain(recordB);
244
+ });
245
+ });
246
+
247
+ // Needs more writers than a single retry cycle serializes. A two-writer
248
+ // race is absorbed by `expectPriorPathSha` alone (the test above); it
249
+ // takes a third to expose a commit landing between the tree read and a
250
+ // re-resolved branch tip, where the CAS matches but the committed tree
251
+ // is stale.
252
+ test("appendReleaseRecordLine: five interleaved writers appending to the SAME env's ledger all survive (#1973)", async () => {
253
+ await withTestDir(async (dir) => {
254
+ await initRepo(dir);
255
+ const records = Array.from({ length: 5 }, (_, i) =>
256
+ JSON.stringify({ version: 1, component: `svc-${i}`, digest: `sha256:${i}` }),
257
+ );
258
+
259
+ await Promise.all(records.map((r) => appendReleaseRecordLine("prod", r, { cwd: dir })));
260
+
261
+ const lines = await readReleaseLedgerLines("prod", { cwd: dir });
262
+ expect(lines).toHaveLength(5);
263
+ for (const r of records) expect(lines).toContain(r);
264
+ });
265
+ });
266
+ });
267
+
268
+ // ── Backslash-escape content round-trips byte-identical (#1936) ────────────
269
+ //
270
+ // writeBlobToPath used to shell out to `sh -c "echo '<content>' | git
271
+ // hash-object -w --stdin"`. sh's `echo` reinterprets backslash-escape
272
+ // sequences (`\n`, `\t`, `\\`, ...) in single-quoted content, so any content
273
+ // embedding a literal two-character `\n` (as opposed to an actual newline
274
+ // byte) — e.g. a serialized `kubectl.kubernetes.io/last-applied-configuration`
275
+ // annotation, which is itself JSON whose string values are JSON-escaped —
276
+ // got silently corrupted in the stored blob. Content is now written
277
+ // directly to spawn's stdin, with no shell in the loop.
278
+ describe("writeBlobToPath preserves literal backslash-escape sequences (#1936)", () => {
279
+ test("literal \\n, \\t, \\\\ two-character sequences survive the write/read round trip", async () => {
280
+ await withTestDir(async (dir) => {
281
+ await initRepo(dir);
282
+ const content = String.raw`before\nmiddle\ttab\\backslash\nafter`;
283
+ await writeBlobToPath("prod", "raw.txt", content, "raw content", { cwd: dir });
284
+ const out = await readBlobFromPath("prod", "raw.txt", { cwd: dir });
285
+ expect(out).toBe(content);
286
+ });
287
+ });
288
+
289
+ test("single quotes combined with backslash sequences survive the round trip", async () => {
290
+ await withTestDir(async (dir) => {
291
+ await initRepo(dir);
292
+ const content = String.raw`it's a \path\to\thing with 'nested' quotes and \n \t escapes`;
293
+ await writeBlobToPath("prod", "mixed.txt", content, "mixed content", { cwd: dir });
294
+ const out = await readBlobFromPath("prod", "mixed.txt", { cwd: dir });
295
+ expect(out).toBe(content);
296
+ });
297
+ });
298
+
299
+ test("a realistic last-applied-configuration-style JSON payload survives byte-identical", async () => {
300
+ await withTestDir(async (dir) => {
301
+ await initRepo(dir);
302
+ // The annotation value is itself JSON-serialized, so a multi-line
303
+ // shell script embedded in the manifest shows up as literal `\n`
304
+ // and `\t` two-character sequences in the annotation string — the
305
+ // exact shape that `sh`'s `echo` used to mangle.
306
+ const innerManifest = {
307
+ apiVersion: "v1",
308
+ kind: "ConfigMap",
309
+ metadata: { name: "test-cm", namespace: "default" },
310
+ data: { "init.sh": "#!/bin/sh\necho 'hello world'\ncd /tmp\n\techo done" },
311
+ };
312
+ const manifest = {
313
+ apiVersion: "v1",
314
+ kind: "ConfigMap",
315
+ metadata: {
316
+ name: "test-cm",
317
+ namespace: "default",
318
+ annotations: {
319
+ "kubectl.kubernetes.io/last-applied-configuration": JSON.stringify(innerManifest),
320
+ },
321
+ },
322
+ };
323
+ const content = JSON.stringify(manifest);
324
+ // Sanity-check the fixture actually contains literal backslash-n /
325
+ // backslash-t sequences (the corruption trigger), not real newlines.
326
+ expect(content).toContain("\\n");
327
+ expect(content).toContain("\\t");
328
+
329
+ const sha = await writeBlobToPath("prod", "annotation.json", content, "annotated manifest", { cwd: dir });
330
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
331
+
332
+ const out = await readBlobFromPath("prod", "annotation.json", { cwd: dir });
333
+ expect(out).toBe(content);
334
+ expect(JSON.parse(out!)).toEqual(manifest);
335
+
336
+ // Cross-check via git plumbing directly (not just the readBlobFromPath
337
+ // helper), to rule out corruption in the stored blob itself.
338
+ const catFile = git(["cat-file", "-p", `chant/lifecycle:prod/annotation.json`], dir);
339
+ expect(catFile.exitCode).toBe(0);
340
+ expect(catFile.stdout).toBe(content);
341
+ });
342
+ });
343
+ });
344
+
160
345
  test("listSnapshots returns commit history of the orphan branch", async () => {
161
346
  await withTestDir(async (dir) => {
162
347
  await initRepo(dir);
@@ -327,4 +512,249 @@ describe("lifecycle/git", () => {
327
512
  });
328
513
  });
329
514
  });
515
+
516
+ // ── Generic ref CAS (#1485) ────────────────────────────────────────────────
517
+
518
+ describe("readRefSha / updateRefCAS / deleteRefCAS / writeBlob / readBlobBySha", () => {
519
+ const REF = "refs/chant/lease/test-op";
520
+
521
+ test("readRefSha returns null for a ref that doesn't exist", async () => {
522
+ await withTestDir(async (dir) => {
523
+ await initRepo(dir);
524
+ expect(await readRefSha(REF, { cwd: dir })).toBeNull();
525
+ });
526
+ });
527
+
528
+ test("writeBlob + updateRefCAS(old=null) creates a ref pointing at a bare blob (no tree, no commit)", async () => {
529
+ await withTestDir(async (dir) => {
530
+ await initRepo(dir);
531
+ const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
532
+ await updateRefCAS(REF, blobSha, null, { cwd: dir });
533
+
534
+ expect(await readRefSha(REF, { cwd: dir })).toBe(blobSha);
535
+ expect(await readBlobBySha(blobSha, { cwd: dir })).toBe('{"holder":"a"}');
536
+ });
537
+ });
538
+
539
+ test("updateRefCAS(old=null) fails if the ref already exists — RefCASConflictError, not a silent overwrite", async () => {
540
+ await withTestDir(async (dir) => {
541
+ await initRepo(dir);
542
+ const first = await writeBlob('{"holder":"a"}', { cwd: dir });
543
+ await updateRefCAS(REF, first, null, { cwd: dir });
544
+
545
+ const second = await writeBlob('{"holder":"b"}', { cwd: dir });
546
+ await expect(updateRefCAS(REF, second, null, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
547
+ // The first writer's value is untouched.
548
+ expect(await readRefSha(REF, { cwd: dir })).toBe(first);
549
+ });
550
+ });
551
+
552
+ test("updateRefCAS succeeds when oldValue matches the ref's current value (a correct renewal)", async () => {
553
+ await withTestDir(async (dir) => {
554
+ await initRepo(dir);
555
+ const first = await writeBlob('{"holder":"a","expiresAt":"t1"}', { cwd: dir });
556
+ await updateRefCAS(REF, first, null, { cwd: dir });
557
+
558
+ const renewed = await writeBlob('{"holder":"a","expiresAt":"t2"}', { cwd: dir });
559
+ await updateRefCAS(REF, renewed, first, { cwd: dir });
560
+
561
+ expect(await readRefSha(REF, { cwd: dir })).toBe(renewed);
562
+ });
563
+ });
564
+
565
+ test("updateRefCAS fails when oldValue is stale — the exact race writeBlobToPath's own final ref update now guards against", async () => {
566
+ await withTestDir(async (dir) => {
567
+ await initRepo(dir);
568
+ const first = await writeBlob('{"holder":"a"}', { cwd: dir });
569
+ await updateRefCAS(REF, first, null, { cwd: dir });
570
+
571
+ // Simulate a second writer who moved the ref concurrently.
572
+ const interloper = await writeBlob('{"holder":"b"}', { cwd: dir });
573
+ await updateRefCAS(REF, interloper, first, { cwd: dir });
574
+
575
+ // The first writer, still holding its stale `first` as `oldValue`,
576
+ // tries to write again — must fail loudly, not clobber `interloper`.
577
+ const staleWrite = await writeBlob('{"holder":"a","stale":true}', { cwd: dir });
578
+ await expect(updateRefCAS(REF, staleWrite, first, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
579
+ expect(await readRefSha(REF, { cwd: dir })).toBe(interloper);
580
+ });
581
+ });
582
+
583
+ test("deleteRefCAS removes the ref only when oldValue matches; a mismatch is a conflict, not a silent no-op", async () => {
584
+ await withTestDir(async (dir) => {
585
+ await initRepo(dir);
586
+ const sha = await writeBlob('{"holder":"a"}', { cwd: dir });
587
+ await updateRefCAS(REF, sha, null, { cwd: dir });
588
+
589
+ const wrongSha = await writeBlob("garbage", { cwd: dir });
590
+ await expect(deleteRefCAS(REF, wrongSha, { cwd: dir })).rejects.toBeInstanceOf(RefCASConflictError);
591
+ expect(await readRefSha(REF, { cwd: dir })).toBe(sha); // untouched
592
+
593
+ await deleteRefCAS(REF, sha, { cwd: dir });
594
+ expect(await readRefSha(REF, { cwd: dir })).toBeNull();
595
+ });
596
+ });
597
+
598
+ test("readBlobBySha returns null for a sha that was never written", async () => {
599
+ await withTestDir(async (dir) => {
600
+ await initRepo(dir);
601
+ expect(await readBlobBySha("0".repeat(40), { cwd: dir })).toBeNull();
602
+ });
603
+ });
604
+ });
605
+
606
+ // ── Failure classification (#1959 finding 2) ────────────────────────────
607
+ //
608
+ // Before this fix, updateRefCAS/deleteRefCAS turned ANY nonzero git exit
609
+ // into RefCASConflictError. That's wrong for at least two other real
610
+ // failure modes git itself distinguishes: a stale `.lock` file (what a
611
+ // killed process leaves behind — the exact crash this feature must
612
+ // recover from) and an outright bad ref name. These tests pin the
613
+ // corrected classification.
614
+ describe("updateRefCAS / deleteRefCAS — classifying real git failures, not just any nonzero exit (#1959 finding 2)", () => {
615
+ test("a stale .lock file is reported as StaleLockError, never as RefCASConflictError", async () => {
616
+ await withTestDir(async (dir) => {
617
+ await initRepo(dir);
618
+ const ref = "refs/chant/lease/stale-lock-op";
619
+ const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
620
+ await updateRefCAS(ref, blobSha, null, { cwd: dir });
621
+
622
+ // Simulate a `chant operator` killed mid-write: git's own
623
+ // lockfile-then-rename never completed, so the `.lock` file it
624
+ // created is still sitting there.
625
+ const { mkdirSync, writeFileSync: write } = await import("node:fs");
626
+ mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
627
+ write(join(dir, ".git", "refs", "chant", "lease", "stale-lock-op.lock"), "");
628
+
629
+ const renewed = await writeBlob('{"holder":"a","renewed":true}', { cwd: dir });
630
+ const err = await updateRefCAS(ref, renewed, blobSha, { cwd: dir }).catch((e) => e);
631
+ expect(err).toBeInstanceOf(StaleLockError);
632
+ expect(err).not.toBeInstanceOf(RefCASConflictError);
633
+ expect((err as StaleLockError).lockPath).toContain("stale-lock-op.lock");
634
+ expect((err as StaleLockError).message).toContain("stale-lock-op.lock");
635
+
636
+ // The ref itself is untouched — the write never happened.
637
+ expect(await readRefSha(ref, { cwd: dir })).toBe(blobSha);
638
+ });
639
+ });
640
+
641
+ test("deleteRefCAS also reports a stale .lock file as StaleLockError", async () => {
642
+ await withTestDir(async (dir) => {
643
+ await initRepo(dir);
644
+ const ref = "refs/chant/lease/stale-lock-delete-op";
645
+ const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
646
+ await updateRefCAS(ref, blobSha, null, { cwd: dir });
647
+
648
+ const { mkdirSync, writeFileSync: write } = await import("node:fs");
649
+ mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
650
+ write(join(dir, ".git", "refs", "chant", "lease", "stale-lock-delete-op.lock"), "");
651
+
652
+ const err = await deleteRefCAS(ref, blobSha, { cwd: dir }).catch((e) => e);
653
+ expect(err).toBeInstanceOf(StaleLockError);
654
+ expect(err).not.toBeInstanceOf(RefCASConflictError);
655
+ });
656
+ });
657
+
658
+ test("a genuine value mismatch is still RefCASConflictError (no lock file involved)", async () => {
659
+ await withTestDir(async (dir) => {
660
+ await initRepo(dir);
661
+ const ref = "refs/chant/lease/mismatch-op";
662
+ const first = await writeBlob('{"holder":"a"}', { cwd: dir });
663
+ await updateRefCAS(ref, first, null, { cwd: dir });
664
+ const interloper = await writeBlob('{"holder":"b"}', { cwd: dir });
665
+ await updateRefCAS(ref, interloper, first, { cwd: dir });
666
+
667
+ const staleWrite = await writeBlob('{"holder":"a","stale":true}', { cwd: dir });
668
+ const err = await updateRefCAS(ref, staleWrite, first, { cwd: dir }).catch((e) => e);
669
+ expect(err).toBeInstanceOf(RefCASConflictError);
670
+ expect(err).not.toBeInstanceOf(StaleLockError);
671
+ });
672
+ });
673
+
674
+ test("a bad ref name is a plain Error, not RefCASConflictError or StaleLockError — the ref's value never actually diverged from oldValue", async () => {
675
+ await withTestDir(async (dir) => {
676
+ await initRepo(dir);
677
+ const badRef = "refs/chant/lease/bad..name";
678
+ const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
679
+
680
+ const err = await updateRefCAS(badRef, blobSha, null, { cwd: dir }).catch((e) => e);
681
+ expect(err).toBeInstanceOf(Error);
682
+ expect(err).not.toBeInstanceOf(RefCASConflictError);
683
+ expect(err).not.toBeInstanceOf(StaleLockError);
684
+ });
685
+ });
686
+ });
687
+
688
+ describe("pushRef / fetchRef — a lease ref survives a round trip through a shared remote", () => {
689
+ test("pushRef pushes a non-branch ref; a second clone sees it via fetchRef", async () => {
690
+ const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
691
+ const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
692
+ try {
693
+ git(["clone", "-q", remotePath, cloneB], tmpdir());
694
+
695
+ const ref = "refs/chant/lease/fountain-converge";
696
+ const blobSha = await writeBlob('{"holder":"a","token":"t1"}', { cwd: cloneA });
697
+ await updateRefCAS(ref, blobSha, null, { cwd: cloneA });
698
+ expect(await pushRef(ref, { cwd: cloneA })).toBe(true);
699
+
700
+ expect(await fetchRef(ref, { cwd: cloneB })).toBe(true);
701
+ expect(await readRefSha(ref, { cwd: cloneB })).toBe(blobSha);
702
+ expect(await readBlobBySha(blobSha, { cwd: cloneB })).toBe('{"holder":"a","token":"t1"}');
703
+ } finally {
704
+ await cleanup();
705
+ const { rm } = await import("node:fs/promises");
706
+ await rm(cloneB, { recursive: true, force: true });
707
+ }
708
+ });
709
+
710
+ test("pushRef / fetchRef return false (never throw) when no remote is configured", async () => {
711
+ await withTestDir(async (dir) => {
712
+ await initRepo(dir);
713
+ const ref = "refs/chant/lease/local-only";
714
+ const blobSha = await writeBlob('{"holder":"a"}', { cwd: dir });
715
+ await updateRefCAS(ref, blobSha, null, { cwd: dir });
716
+ expect(await pushRef(ref, { cwd: dir })).toBe(false);
717
+ expect(await fetchRef(ref, { cwd: dir })).toBe(false);
718
+ });
719
+ });
720
+
721
+ test("fetchRefInto lands the remote's value under a DIFFERENT local ref name, leaving the same-named local ref (if any) untouched (#1959 finding 3)", async () => {
722
+ const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
723
+ const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
724
+ try {
725
+ git(["clone", "-q", remotePath, cloneB], tmpdir());
726
+
727
+ const ref = "refs/chant/lease/fountain-converge";
728
+ const remoteBlobSha = await writeBlob('{"holder":"a","token":"t1"}', { cwd: cloneA });
729
+ await updateRefCAS(ref, remoteBlobSha, null, { cwd: cloneA });
730
+ expect(await pushRef(ref, { cwd: cloneA })).toBe(true);
731
+
732
+ // cloneB already has its OWN local value at `ref` — simulating a
733
+ // just-acquired, not-yet-pushed local lease. fetchRefInto must not
734
+ // touch it.
735
+ const localBlobSha = await writeBlob('{"holder":"b","token":"t2"}', { cwd: cloneB });
736
+ await updateRefCAS(ref, localBlobSha, null, { cwd: cloneB });
737
+
738
+ const trackingRef = "refs/chant/lease-remote/fountain-converge";
739
+ expect(await fetchRefInto(ref, trackingRef, { cwd: cloneB })).toBe(true);
740
+
741
+ // The tracking ref reflects the remote's value...
742
+ expect(await readRefSha(trackingRef, { cwd: cloneB })).toBe(remoteBlobSha);
743
+ // ...but cloneB's own local `ref` is completely untouched.
744
+ expect(await readRefSha(ref, { cwd: cloneB })).toBe(localBlobSha);
745
+ } finally {
746
+ await cleanup();
747
+ const { rm } = await import("node:fs/promises");
748
+ await rm(cloneB, { recursive: true, force: true });
749
+ }
750
+ });
751
+ });
752
+
753
+ test("RefCASConflictError carries the ref and expected value", () => {
754
+ const err = new RefCASConflictError("refs/chant/lease/x", "abc123", "stale info");
755
+ expect(err.name).toBe("RefCASConflictError");
756
+ expect(err.ref).toBe("refs/chant/lease/x");
757
+ expect(err.expected).toBe("abc123");
758
+ expect(err.message).toContain("moved concurrently");
759
+ });
330
760
  });