@ybotok/loam 0.2.0-alpha.6 → 0.2.0-alpha.9

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 (691) hide show
  1. package/CHANGELOG.md +1477 -1
  2. package/COMPARISON.md +91 -3
  3. package/CONTRIBUTING.md +37 -0
  4. package/MIGRATING-from-OpenSpec.md +40 -22
  5. package/QUICKSTART.md +181 -9
  6. package/README.md +256 -57
  7. package/ROADMAP.md +108 -11
  8. package/SCHEMA.md +721 -130
  9. package/WORKFLOW.md +272 -62
  10. package/dist/cli.js +26 -8
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/adopt/adopt.js +5 -10
  13. package/dist/commands/adopt/adopt.js.map +1 -1
  14. package/dist/commands/archive/archive.js +15 -18
  15. package/dist/commands/archive/archive.js.map +1 -1
  16. package/dist/commands/archive/plan/contracts/asyncapi.js +27 -41
  17. package/dist/commands/archive/plan/contracts/asyncapi.js.map +1 -1
  18. package/dist/commands/archive/plan/contracts/consumers.js +56 -0
  19. package/dist/commands/archive/plan/contracts/consumers.js.map +1 -0
  20. package/dist/commands/archive/plan/contracts/openapi.js +25 -42
  21. package/dist/commands/archive/plan/contracts/openapi.js.map +1 -1
  22. package/dist/commands/archive/plan/contracts/verdict-lines.js +41 -0
  23. package/dist/commands/archive/plan/contracts/verdict-lines.js.map +1 -0
  24. package/dist/commands/archive/plan/gate.js +3 -7
  25. package/dist/commands/archive/plan/gate.js.map +1 -1
  26. package/dist/commands/archive/plan/landscape.js +4 -4
  27. package/dist/commands/archive/plan/landscape.js.map +1 -1
  28. package/dist/commands/archive/plan/refusal.js +8 -18
  29. package/dist/commands/archive/plan/refusal.js.map +1 -1
  30. package/dist/commands/archive/plan/specs.js +134 -25
  31. package/dist/commands/archive/plan/specs.js.map +1 -1
  32. package/dist/commands/archive/plan/state.js +1 -0
  33. package/dist/commands/archive/plan/state.js.map +1 -1
  34. package/dist/commands/archive/run.js +82 -33
  35. package/dist/commands/archive/run.js.map +1 -1
  36. package/dist/commands/archive/shipped.js +49 -0
  37. package/dist/commands/archive/shipped.js.map +1 -0
  38. package/dist/commands/context/context.js +4 -7
  39. package/dist/commands/context/context.js.map +1 -1
  40. package/dist/commands/context/print.js +2 -2
  41. package/dist/commands/context/print.js.map +1 -1
  42. package/dist/commands/delta/delta.js +44 -12
  43. package/dist/commands/delta/delta.js.map +1 -1
  44. package/dist/commands/delta/print.js +1 -1
  45. package/dist/commands/delta/print.js.map +1 -1
  46. package/dist/commands/dependencies.js +3 -7
  47. package/dist/commands/dependencies.js.map +1 -1
  48. package/dist/commands/diff/diff.js +20 -27
  49. package/dist/commands/diff/diff.js.map +1 -1
  50. package/dist/commands/explore.js +4 -8
  51. package/dist/commands/explore.js.map +1 -1
  52. package/dist/commands/gate/gate.js +45 -8
  53. package/dist/commands/gate/gate.js.map +1 -1
  54. package/dist/commands/gate/print.js +15 -3
  55. package/dist/commands/gate/print.js.map +1 -1
  56. package/dist/commands/gherkin/commit.js +13 -54
  57. package/dist/commands/gherkin/commit.js.map +1 -1
  58. package/dist/commands/gherkin/gherkin.js +36 -20
  59. package/dist/commands/gherkin/gherkin.js.map +1 -1
  60. package/dist/commands/gherkin/reconcile.js +10 -13
  61. package/dist/commands/gherkin/reconcile.js.map +1 -1
  62. package/dist/commands/init/first-hour.js +44 -0
  63. package/dist/commands/init/first-hour.js.map +1 -1
  64. package/dist/commands/init/init.js +18 -22
  65. package/dist/commands/init/init.js.map +1 -1
  66. package/dist/commands/list/campaign/campaign.js +4 -6
  67. package/dist/commands/list/campaign/campaign.js.map +1 -1
  68. package/dist/commands/list/json.js +0 -4
  69. package/dist/commands/list/json.js.map +1 -1
  70. package/dist/commands/list/list.js +38 -23
  71. package/dist/commands/list/list.js.map +1 -1
  72. package/dist/commands/list/print.js +38 -6
  73. package/dist/commands/list/print.js.map +1 -1
  74. package/dist/commands/list/suites/suites.js +140 -0
  75. package/dist/commands/list/suites/suites.js.map +1 -0
  76. package/dist/commands/list/views.js +28 -33
  77. package/dist/commands/list/views.js.map +1 -1
  78. package/dist/commands/mcp/dispatch.js +91 -36
  79. package/dist/commands/mcp/dispatch.js.map +1 -1
  80. package/dist/commands/mcp/mcp.js +56 -15
  81. package/dist/commands/mcp/mcp.js.map +1 -1
  82. package/dist/commands/mcp/serve.js.map +1 -1
  83. package/dist/commands/migrate-openspec/materialize/active.js +27 -22
  84. package/dist/commands/migrate-openspec/materialize/active.js.map +1 -1
  85. package/dist/commands/migrate-openspec/materialize/stage.js +4 -3
  86. package/dist/commands/migrate-openspec/materialize/stage.js.map +1 -1
  87. package/dist/commands/migrate-openspec/materialize/target.js +2 -2
  88. package/dist/commands/migrate-openspec/materialize/target.js.map +1 -1
  89. package/dist/commands/migrate-openspec/openspec/decisions.js +9 -14
  90. package/dist/commands/migrate-openspec/openspec/decisions.js.map +1 -1
  91. package/dist/commands/migrate-openspec/openspec/error.js +7 -8
  92. package/dist/commands/migrate-openspec/openspec/error.js.map +1 -1
  93. package/dist/commands/migrate-openspec/openspec/mapping.js +1 -9
  94. package/dist/commands/migrate-openspec/openspec/mapping.js.map +1 -1
  95. package/dist/commands/migrate-openspec/openspec/paths.js +3 -6
  96. package/dist/commands/migrate-openspec/openspec/paths.js.map +1 -1
  97. package/dist/commands/new/capability-template.js +118 -0
  98. package/dist/commands/new/capability-template.js.map +1 -0
  99. package/dist/commands/new/commit.js +8 -22
  100. package/dist/commands/new/commit.js.map +1 -1
  101. package/dist/commands/new/modifies.js +206 -0
  102. package/dist/commands/new/modifies.js.map +1 -0
  103. package/dist/commands/new/new.js +59 -14
  104. package/dist/commands/new/new.js.map +1 -1
  105. package/dist/commands/new/templates.js +48 -124
  106. package/dist/commands/new/templates.js.map +1 -1
  107. package/dist/commands/open.js +3 -7
  108. package/dist/commands/open.js.map +1 -1
  109. package/dist/commands/policy/gate.js +27 -0
  110. package/dist/commands/policy/gate.js.map +1 -1
  111. package/dist/commands/rebase/plan.js +2 -2
  112. package/dist/commands/rebase/plan.js.map +1 -1
  113. package/dist/commands/rebase/rebase.js +60 -35
  114. package/dist/commands/rebase/rebase.js.map +1 -1
  115. package/dist/commands/seed/commit.js +55 -26
  116. package/dist/commands/seed/commit.js.map +1 -1
  117. package/dist/commands/seed/plan.js +64 -13
  118. package/dist/commands/seed/plan.js.map +1 -1
  119. package/dist/commands/seed/seed.js +6 -9
  120. package/dist/commands/seed/seed.js.map +1 -1
  121. package/dist/commands/show/show.js +3 -7
  122. package/dist/commands/show/show.js.map +1 -1
  123. package/dist/commands/status/print.js +13 -4
  124. package/dist/commands/status/print.js.map +1 -1
  125. package/dist/commands/status/status.js +3 -7
  126. package/dist/commands/status/status.js.map +1 -1
  127. package/dist/commands/steps/steps.js +3 -7
  128. package/dist/commands/steps/steps.js.map +1 -1
  129. package/dist/commands/subsystem/edit.js +7 -7
  130. package/dist/commands/subsystem/edit.js.map +1 -1
  131. package/dist/commands/subsystem/move.js +60 -25
  132. package/dist/commands/subsystem/move.js.map +1 -1
  133. package/dist/commands/subsystem/query.js +2 -4
  134. package/dist/commands/subsystem/query.js.map +1 -1
  135. package/dist/commands/subsystem/subsystem.js +4 -7
  136. package/dist/commands/subsystem/subsystem.js.map +1 -1
  137. package/dist/commands/subsystem/sync.js +141 -71
  138. package/dist/commands/subsystem/sync.js.map +1 -1
  139. package/dist/commands/subsystem/txn/report.js +55 -5
  140. package/dist/commands/subsystem/txn/report.js.map +1 -1
  141. package/dist/commands/subsystem/txn/txn.js +121 -45
  142. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  143. package/dist/commands/subsystem/txn/views.js +82 -12
  144. package/dist/commands/subsystem/txn/views.js.map +1 -1
  145. package/dist/commands/tasks/render.js +116 -0
  146. package/dist/commands/tasks/render.js.map +1 -0
  147. package/dist/commands/tasks/tasks.js +113 -0
  148. package/dist/commands/tasks/tasks.js.map +1 -0
  149. package/dist/commands/unarchive/manifest.js +6 -9
  150. package/dist/commands/unarchive/manifest.js.map +1 -1
  151. package/dist/commands/unarchive/restore.js +17 -28
  152. package/dist/commands/unarchive/restore.js.map +1 -1
  153. package/dist/commands/unarchive/unarchive.js +22 -30
  154. package/dist/commands/unarchive/unarchive.js.map +1 -1
  155. package/dist/commands/validate/arch-coverage.js +6 -15
  156. package/dist/commands/validate/arch-coverage.js.map +1 -1
  157. package/dist/commands/validate/checks/fleet-shape.js +4 -49
  158. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  159. package/dist/commands/validate/checks/health-deps.js +9 -6
  160. package/dist/commands/validate/checks/health-deps.js.map +1 -1
  161. package/dist/commands/validate/checks/requirements.js +18 -8
  162. package/dist/commands/validate/checks/requirements.js.map +1 -1
  163. package/dist/commands/validate/checks/sources.js +6 -2
  164. package/dist/commands/validate/checks/sources.js.map +1 -1
  165. package/dist/commands/validate/feature.js +23 -19
  166. package/dist/commands/validate/feature.js.map +1 -1
  167. package/dist/commands/validate/fleet/census.js +26 -3
  168. package/dist/commands/validate/fleet/census.js.map +1 -1
  169. package/dist/commands/validate/fleet/landscape.js +35 -32
  170. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  171. package/dist/commands/validate/fleet/map/attest.js +7 -12
  172. package/dist/commands/validate/fleet/map/attest.js.map +1 -1
  173. package/dist/commands/validate/fleet/map/bindings.js +16 -5
  174. package/dist/commands/validate/fleet/map/bindings.js.map +1 -1
  175. package/dist/commands/validate/fleet/map/consumers.js +43 -122
  176. package/dist/commands/validate/fleet/map/consumers.js.map +1 -1
  177. package/dist/commands/validate/fleet/map/usecases.js +5 -11
  178. package/dist/commands/validate/fleet/map/usecases.js.map +1 -1
  179. package/dist/commands/validate/fleet/obligations.js +1 -5
  180. package/dist/commands/validate/fleet/obligations.js.map +1 -1
  181. package/dist/commands/validate/fleet/scorecard/print.js +5 -1
  182. package/dist/commands/validate/fleet/scorecard/print.js.map +1 -1
  183. package/dist/commands/validate/fleet/scorecard/scorecard.js +17 -11
  184. package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -1
  185. package/dist/commands/validate/fleet/usecases/usecases.js +6 -4
  186. package/dist/commands/validate/fleet/usecases/usecases.js.map +1 -1
  187. package/dist/commands/validate/fleet/views/fleet-project.js +1 -1
  188. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -1
  189. package/dist/commands/validate/fleet/views/ids.js +33 -7
  190. package/dist/commands/validate/fleet/views/ids.js.map +1 -1
  191. package/dist/commands/validate/fleet/views/root-documents.js +17 -35
  192. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -1
  193. package/dist/commands/validate/fleet/views/stale.js +31 -14
  194. package/dist/commands/validate/fleet/views/stale.js.map +1 -1
  195. package/dist/commands/validate/links/corpus.js +1 -4
  196. package/dist/commands/validate/links/corpus.js.map +1 -1
  197. package/dist/commands/validate/links/glossary.js.map +1 -1
  198. package/dist/commands/validate/report.js +5 -4
  199. package/dist/commands/validate/report.js.map +1 -1
  200. package/dist/commands/validate/scope.js +113 -0
  201. package/dist/commands/validate/scope.js.map +1 -0
  202. package/dist/commands/validate/service/api.js +16 -104
  203. package/dist/commands/validate/service/api.js.map +1 -1
  204. package/dist/commands/validate/service/contracts/generated.js +134 -0
  205. package/dist/commands/validate/service/contracts/generated.js.map +1 -0
  206. package/dist/commands/validate/service/events/events.js +1 -1
  207. package/dist/commands/validate/service/events/events.js.map +1 -1
  208. package/dist/commands/validate/service/events/message-producers.js +1 -1
  209. package/dist/commands/validate/service/events/message-producers.js.map +1 -1
  210. package/dist/commands/validate/service/evidence-pins.js +56 -10
  211. package/dist/commands/validate/service/evidence-pins.js.map +1 -1
  212. package/dist/commands/validate/service/service.js +16 -26
  213. package/dist/commands/validate/service/service.js.map +1 -1
  214. package/dist/commands/validate/service/specs.js +31 -38
  215. package/dist/commands/validate/service/specs.js.map +1 -1
  216. package/dist/commands/validate/service/spine.js +8 -11
  217. package/dist/commands/validate/service/spine.js.map +1 -1
  218. package/dist/commands/validate/service/usecases/usecases.js +3 -3
  219. package/dist/commands/validate/service/usecases/usecases.js.map +1 -1
  220. package/dist/commands/validate/validate.js +44 -26
  221. package/dist/commands/validate/validate.js.map +1 -1
  222. package/dist/commands/verify/cross/diff.js +2 -6
  223. package/dist/commands/verify/cross/diff.js.map +1 -1
  224. package/dist/commands/verify/evidence/contract.js +1 -1
  225. package/dist/commands/verify/evidence/contract.js.map +1 -1
  226. package/dist/commands/verify/evidence/read.js +12 -3
  227. package/dist/commands/verify/evidence/read.js.map +1 -1
  228. package/dist/commands/verify/frozen.js +45 -23
  229. package/dist/commands/verify/frozen.js.map +1 -1
  230. package/dist/commands/verify/record.js +69 -17
  231. package/dist/commands/verify/record.js.map +1 -1
  232. package/dist/commands/verify/report.js +27 -44
  233. package/dist/commands/verify/report.js.map +1 -1
  234. package/dist/commands/verify/results.js +112 -30
  235. package/dist/commands/verify/results.js.map +1 -1
  236. package/dist/commands/verify/verify.js +54 -51
  237. package/dist/commands/verify/verify.js.map +1 -1
  238. package/dist/commands/vouch/pack/pack.js +2 -2
  239. package/dist/commands/vouch/pack/pack.js.map +1 -1
  240. package/dist/commands/vouch/pack/print.js +1 -6
  241. package/dist/commands/vouch/pack/print.js.map +1 -1
  242. package/dist/commands/vouch/pack/sections.js +1 -2
  243. package/dist/commands/vouch/pack/sections.js.map +1 -1
  244. package/dist/commands/vouch/run.js +26 -81
  245. package/dist/commands/vouch/run.js.map +1 -1
  246. package/dist/commands/vouch/sample/plan.js +20 -34
  247. package/dist/commands/vouch/sample/plan.js.map +1 -1
  248. package/dist/commands/vouch/vet/verify.js +39 -7
  249. package/dist/commands/vouch/vet/verify.js.map +1 -1
  250. package/dist/commands/vouch/vouch.js +10 -17
  251. package/dist/commands/vouch/vouch.js.map +1 -1
  252. package/dist/core/agent/agents-md/command-map.js +57 -102
  253. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  254. package/dist/core/agent/agents-md/map/explain.js +4 -3
  255. package/dist/core/agent/agents-md/map/explain.js.map +1 -1
  256. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  257. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  258. package/dist/core/agent/agents-md/map/lenses/diff.js +33 -20
  259. package/dist/core/agent/agents-md/map/lenses/diff.js.map +1 -1
  260. package/dist/core/agent/agents-md/map/lenses/gate.js +13 -5
  261. package/dist/core/agent/agents-md/map/lenses/gate.js.map +1 -1
  262. package/dist/core/agent/agents-md/map/mcp.js +10 -7
  263. package/dist/core/agent/agents-md/map/mcp.js.map +1 -1
  264. package/dist/core/agent/agents-md/map/status.js +9 -3
  265. package/dist/core/agent/agents-md/map/status.js.map +1 -1
  266. package/dist/core/agent/agents-md/map/subsystem.js +31 -17
  267. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  268. package/dist/core/agent/agents-md/map/usecases.js +24 -6
  269. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  270. package/dist/core/agent/agents-md/refusals.js +37 -21
  271. package/dist/core/agent/agents-md/refusals.js.map +1 -1
  272. package/dist/core/agent/agents-md.js +25 -10
  273. package/dist/core/agent/agents-md.js.map +1 -1
  274. package/dist/core/agent/agents-stamp.js +10 -0
  275. package/dist/core/agent/agents-stamp.js.map +1 -1
  276. package/dist/core/agent/scaffold.js +10 -15
  277. package/dist/core/agent/scaffold.js.map +1 -1
  278. package/dist/core/agent/tools/dialects.js +1 -0
  279. package/dist/core/agent/tools/dialects.js.map +1 -1
  280. package/dist/core/agent/tools/registry.js +41 -76
  281. package/dist/core/agent/tools/registry.js.map +1 -1
  282. package/dist/core/agent/workflows/adopt.js +5 -3
  283. package/dist/core/agent/workflows/adopt.js.map +1 -1
  284. package/dist/core/agent/workflows/check.js +25 -22
  285. package/dist/core/agent/workflows/check.js.map +1 -1
  286. package/dist/core/agent/workflows/closing.js +54 -21
  287. package/dist/core/agent/workflows/closing.js.map +1 -1
  288. package/dist/core/agent/workflows/feature.js +79 -11
  289. package/dist/core/agent/workflows/feature.js.map +1 -1
  290. package/dist/core/agent/workflows/implement.js +91 -13
  291. package/dist/core/agent/workflows/implement.js.map +1 -1
  292. package/dist/core/agent/workflows/reference/authoring.js +3 -1
  293. package/dist/core/agent/workflows/reference/authoring.js.map +1 -1
  294. package/dist/core/agent/workflows/reference/codes.js +8 -6
  295. package/dist/core/agent/workflows/reference/codes.js.map +1 -1
  296. package/dist/core/agent/workflows/reference/done-check.js +37 -15
  297. package/dist/core/agent/workflows/reference/done-check.js.map +1 -1
  298. package/dist/core/agent/workflows/reference/reference.js.map +1 -1
  299. package/dist/core/agent/workflows/reference/spine.js +62 -24
  300. package/dist/core/agent/workflows/reference/spine.js.map +1 -1
  301. package/dist/core/asyncapi/baseline/plan.js +14 -81
  302. package/dist/core/asyncapi/baseline/plan.js.map +1 -1
  303. package/dist/core/asyncapi/digest.js +30 -25
  304. package/dist/core/asyncapi/digest.js.map +1 -1
  305. package/dist/core/asyncapi/merge/components.js +5 -4
  306. package/dist/core/asyncapi/merge/components.js.map +1 -1
  307. package/dist/core/asyncapi/merge/error.js +7 -2
  308. package/dist/core/asyncapi/merge/error.js.map +1 -1
  309. package/dist/core/asyncapi/merge/markers.js +30 -50
  310. package/dist/core/asyncapi/merge/markers.js.map +1 -1
  311. package/dist/core/asyncapi/merge/merge.js +31 -53
  312. package/dist/core/asyncapi/merge/merge.js.map +1 -1
  313. package/dist/core/asyncapi/merge/pin.js +10 -36
  314. package/dist/core/asyncapi/merge/pin.js.map +1 -1
  315. package/dist/core/asyncapi/model.js.map +1 -1
  316. package/dist/core/asyncapi/read.js +36 -12
  317. package/dist/core/asyncapi/read.js.map +1 -1
  318. package/dist/core/brief/brief.js +30 -25
  319. package/dist/core/brief/brief.js.map +1 -1
  320. package/dist/core/brief/landscape.js +8 -12
  321. package/dist/core/brief/landscape.js.map +1 -1
  322. package/dist/core/brief/map/extend.js +8 -5
  323. package/dist/core/brief/map/extend.js.map +1 -1
  324. package/dist/core/brief/map/owed.js +8 -26
  325. package/dist/core/brief/map/owed.js.map +1 -1
  326. package/dist/core/brief/targets.js +79 -5
  327. package/dist/core/brief/targets.js.map +1 -1
  328. package/dist/core/brief/unchecked.js +1 -1
  329. package/dist/core/brief/unchecked.js.map +1 -1
  330. package/dist/core/c4/likec4.js +147 -16
  331. package/dist/core/c4/likec4.js.map +1 -1
  332. package/dist/core/c4/locate/unlocated.js +150 -0
  333. package/dist/core/c4/locate/unlocated.js.map +1 -0
  334. package/dist/core/c4/parsed/values.js +54 -1
  335. package/dist/core/c4/parsed/values.js.map +1 -1
  336. package/dist/core/c4/prefetch/fleet.js +1 -9
  337. package/dist/core/c4/prefetch/fleet.js.map +1 -1
  338. package/dist/core/c4/project/architecture.js +31 -9
  339. package/dist/core/c4/project/architecture.js.map +1 -1
  340. package/dist/core/c4/project/documents.js +36 -10
  341. package/dist/core/c4/project/documents.js.map +1 -1
  342. package/dist/core/c4/project/load.js +14 -21
  343. package/dist/core/c4/project/load.js.map +1 -1
  344. package/dist/core/c4/project/staged.js +7 -3
  345. package/dist/core/c4/project/staged.js.map +1 -1
  346. package/dist/core/c4/resolve/external.js +51 -0
  347. package/dist/core/c4/resolve/external.js.map +1 -0
  348. package/dist/core/c4/resolve/service.js +25 -1
  349. package/dist/core/c4/resolve/service.js.map +1 -1
  350. package/dist/core/c4/seed/template.js +15 -1
  351. package/dist/core/c4/seed/template.js.map +1 -1
  352. package/dist/core/c4/service-model/shape.js +2 -22
  353. package/dist/core/c4/service-model/shape.js.map +1 -1
  354. package/dist/core/c4/source-mask.js +288 -4
  355. package/dist/core/c4/source-mask.js.map +1 -1
  356. package/dist/core/c4/source-scan.js +203 -147
  357. package/dist/core/c4/source-scan.js.map +1 -1
  358. package/dist/core/c4/splice/authored-source.js +19 -0
  359. package/dist/core/c4/splice/authored-source.js.map +1 -1
  360. package/dist/core/c4/splice/delta-blocks.js +5 -18
  361. package/dist/core/c4/splice/delta-blocks.js.map +1 -1
  362. package/dist/core/c4/splice/landscape-merge.js +13 -8
  363. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  364. package/dist/core/c4/splice/model/merge.js +10 -6
  365. package/dist/core/c4/splice/model/merge.js.map +1 -1
  366. package/dist/core/c4/splice/placement.js +108 -18
  367. package/dist/core/c4/splice/placement.js.map +1 -1
  368. package/dist/core/c4/workspace.js +13 -22
  369. package/dist/core/c4/workspace.js.map +1 -1
  370. package/dist/core/capabilities/capabilities.js +6 -6
  371. package/dist/core/capabilities/capabilities.js.map +1 -1
  372. package/dist/core/capabilities/delta/doc.js +7 -10
  373. package/dist/core/capabilities/delta/doc.js.map +1 -1
  374. package/dist/core/capabilities/findings.js +15 -20
  375. package/dist/core/capabilities/findings.js.map +1 -1
  376. package/dist/core/capabilities/realizes/findings.js +12 -19
  377. package/dist/core/capabilities/realizes/findings.js.map +1 -1
  378. package/dist/core/capabilities/realizes/join.js +0 -20
  379. package/dist/core/capabilities/realizes/join.js.map +1 -1
  380. package/dist/core/capabilities/rollup.js +6 -0
  381. package/dist/core/capabilities/rollup.js.map +1 -1
  382. package/dist/core/capabilities/tree.js +6 -28
  383. package/dist/core/capabilities/tree.js.map +1 -1
  384. package/dist/core/capabilities/usecase-join.js +3 -13
  385. package/dist/core/capabilities/usecase-join.js.map +1 -1
  386. package/dist/core/coherence/authoring/scaffold.js +11 -3
  387. package/dist/core/coherence/authoring/scaffold.js.map +1 -1
  388. package/dist/core/coherence/authoring/sentinels.js +2 -1
  389. package/dist/core/coherence/authoring/sentinels.js.map +1 -1
  390. package/dist/core/coherence/coherence.js +30 -16
  391. package/dist/core/coherence/coherence.js.map +1 -1
  392. package/dist/core/coherence/consumed/operations.js +70 -0
  393. package/dist/core/coherence/consumed/operations.js.map +1 -0
  394. package/dist/core/coherence/consumed/refusal.js +38 -0
  395. package/dist/core/coherence/consumed/refusal.js.map +1 -0
  396. package/dist/core/coherence/declared.js +60 -34
  397. package/dist/core/coherence/declared.js.map +1 -1
  398. package/dist/core/coherence/events/declared.js +44 -59
  399. package/dist/core/coherence/events/declared.js.map +1 -1
  400. package/dist/core/coherence/events/events.js +119 -8
  401. package/dist/core/coherence/events/events.js.map +1 -1
  402. package/dist/core/coherence/events/grades.js +31 -8
  403. package/dist/core/coherence/events/grades.js.map +1 -1
  404. package/dist/core/coherence/events/lookups.js +14 -82
  405. package/dist/core/coherence/events/lookups.js.map +1 -1
  406. package/dist/core/coherence/lookups.js +18 -78
  407. package/dist/core/coherence/lookups.js.map +1 -1
  408. package/dist/core/coherence/retired/archived.js +285 -0
  409. package/dist/core/coherence/retired/archived.js.map +1 -0
  410. package/dist/core/coherence/retired/event-pair.js +62 -0
  411. package/dist/core/coherence/retired/event-pair.js.map +1 -0
  412. package/dist/core/coherence/retired/merged.js +79 -0
  413. package/dist/core/coherence/retired/merged.js.map +1 -0
  414. package/dist/core/coherence/retired/retirement.js +49 -0
  415. package/dist/core/coherence/retired/retirement.js.map +1 -0
  416. package/dist/core/coherence/retired/revival/arrivals.js +131 -0
  417. package/dist/core/coherence/retired/revival/arrivals.js.map +1 -0
  418. package/dist/core/coherence/retired/revival/revived.js +297 -0
  419. package/dist/core/coherence/retired/revival/revived.js.map +1 -0
  420. package/dist/core/consumers/after.js +113 -0
  421. package/dist/core/consumers/after.js.map +1 -0
  422. package/dist/core/consumers/consumers.js +296 -0
  423. package/dist/core/consumers/consumers.js.map +1 -0
  424. package/dist/core/consumers/current.js +211 -0
  425. package/dist/core/consumers/current.js.map +1 -0
  426. package/dist/core/consumers/scan.js +46 -0
  427. package/dist/core/consumers/scan.js.map +1 -0
  428. package/dist/core/consumers/state.js +27 -0
  429. package/dist/core/consumers/state.js.map +1 -0
  430. package/dist/core/delta/delta.js +1 -1
  431. package/dist/core/delta/delta.js.map +1 -1
  432. package/dist/core/deployment/delta.js +15 -16
  433. package/dist/core/deployment/delta.js.map +1 -1
  434. package/dist/core/diff/base-git.js +94 -12
  435. package/dist/core/diff/base-git.js.map +1 -1
  436. package/dist/core/diff/base-state.js +28 -16
  437. package/dist/core/diff/base-state.js.map +1 -1
  438. package/dist/core/diff/joins.js +136 -0
  439. package/dist/core/diff/joins.js.map +1 -0
  440. package/dist/core/diff/scope/changed-paths.js +22 -6
  441. package/dist/core/diff/scope/changed-paths.js.map +1 -1
  442. package/dist/core/diff/scope/edges.js +82 -0
  443. package/dist/core/diff/scope/edges.js.map +1 -0
  444. package/dist/core/diff/semantic.js +100 -31
  445. package/dist/core/diff/semantic.js.map +1 -1
  446. package/dist/core/doctor/agents.js +34 -18
  447. package/dist/core/doctor/agents.js.map +1 -1
  448. package/dist/core/doctor/report.js.map +1 -1
  449. package/dist/core/document/apply.js +10 -2
  450. package/dist/core/document/apply.js.map +1 -1
  451. package/dist/core/document/frontmatter.js +55 -0
  452. package/dist/core/document/frontmatter.js.map +1 -1
  453. package/dist/core/document/parse.js +29 -6
  454. package/dist/core/document/parse.js.map +1 -1
  455. package/dist/core/document/scenarios.js +17 -0
  456. package/dist/core/document/scenarios.js.map +1 -1
  457. package/dist/core/document/spec.js +4 -11
  458. package/dist/core/document/spec.js.map +1 -1
  459. package/dist/core/envelope/config.js +10 -0
  460. package/dist/core/envelope/config.js.map +1 -1
  461. package/dist/core/envelope/json.js +34 -0
  462. package/dist/core/envelope/json.js.map +1 -1
  463. package/dist/core/explain/families.js +17 -12
  464. package/dist/core/explain/families.js.map +1 -1
  465. package/dist/core/explain/refusals.js +3 -3
  466. package/dist/core/explain/refusals.js.map +1 -1
  467. package/dist/core/explain/terms.js +18 -9
  468. package/dist/core/explain/terms.js.map +1 -1
  469. package/dist/core/explore/explore.js +2 -7
  470. package/dist/core/explore/explore.js.map +1 -1
  471. package/dist/core/fleet-context.js +34 -95
  472. package/dist/core/fleet-context.js.map +1 -1
  473. package/dist/core/gate/checks.js +10 -3
  474. package/dist/core/gate/checks.js.map +1 -1
  475. package/dist/core/gate/partners.js +64 -19
  476. package/dist/core/gate/partners.js.map +1 -1
  477. package/dist/core/gate/report.js.map +1 -1
  478. package/dist/core/gate/verification.js +75 -12
  479. package/dist/core/gate/verification.js.map +1 -1
  480. package/dist/core/gherkin/stale.js +13 -37
  481. package/dist/core/gherkin/stale.js.map +1 -1
  482. package/dist/core/glossary/tree.js +6 -19
  483. package/dist/core/glossary/tree.js.map +1 -1
  484. package/dist/core/kernel/concurrency.js +19 -0
  485. package/dist/core/kernel/concurrency.js.map +1 -1
  486. package/dist/core/kernel/records.js +11 -0
  487. package/dist/core/kernel/records.js.map +1 -1
  488. package/dist/core/links/backlinks.js +1 -3
  489. package/dist/core/links/backlinks.js.map +1 -1
  490. package/dist/core/links/corpus.js +5 -36
  491. package/dist/core/links/corpus.js.map +1 -1
  492. package/dist/core/mcp/argv.js +101 -0
  493. package/dist/core/mcp/argv.js.map +1 -1
  494. package/dist/core/mcp/author-tools.js +2 -1
  495. package/dist/core/mcp/author-tools.js.map +1 -1
  496. package/dist/core/mcp/protocol.js +2 -2
  497. package/dist/core/mcp/protocol.js.map +1 -1
  498. package/dist/core/mcp/tools.js +43 -33
  499. package/dist/core/mcp/tools.js.map +1 -1
  500. package/dist/core/openapi/baseline/gate.js +53 -12
  501. package/dist/core/openapi/baseline/gate.js.map +1 -1
  502. package/dist/core/openapi/baseline/plan.js +22 -8
  503. package/dist/core/openapi/baseline/plan.js.map +1 -1
  504. package/dist/core/openapi/baseline/record.js +0 -1
  505. package/dist/core/openapi/baseline/record.js.map +1 -1
  506. package/dist/core/openapi/contract-text.js +33 -0
  507. package/dist/core/openapi/contract-text.js.map +1 -0
  508. package/dist/core/openapi/contract-tree.js +56 -0
  509. package/dist/core/openapi/contract-tree.js.map +1 -0
  510. package/dist/core/openapi/depth.js +38 -24
  511. package/dist/core/openapi/depth.js.map +1 -1
  512. package/dist/core/openapi/digest.js +52 -2
  513. package/dist/core/openapi/digest.js.map +1 -1
  514. package/dist/core/openapi/doc.js +12 -4
  515. package/dist/core/openapi/doc.js.map +1 -1
  516. package/dist/core/openapi/merge/components.js +25 -60
  517. package/dist/core/openapi/merge/components.js.map +1 -1
  518. package/dist/core/openapi/merge/error.js +3 -13
  519. package/dist/core/openapi/merge/error.js.map +1 -1
  520. package/dist/core/openapi/merge/markers.js +26 -30
  521. package/dist/core/openapi/merge/markers.js.map +1 -1
  522. package/dist/core/openapi/merge/merge.js +26 -52
  523. package/dist/core/openapi/merge/merge.js.map +1 -1
  524. package/dist/core/openapi/merge/pin.js +15 -35
  525. package/dist/core/openapi/merge/pin.js.map +1 -1
  526. package/dist/core/openspec/decide/verdict.js.map +1 -1
  527. package/dist/core/openspec/inventory.js +4 -5
  528. package/dist/core/openspec/inventory.js.map +1 -1
  529. package/dist/core/openspec/model/mapping.js +30 -28
  530. package/dist/core/openspec/model/mapping.js.map +1 -1
  531. package/dist/core/openspec/model/model.js +12 -7
  532. package/dist/core/openspec/model/model.js.map +1 -1
  533. package/dist/core/openspec/scan/changes.js +10 -4
  534. package/dist/core/openspec/scan/changes.js.map +1 -1
  535. package/dist/core/openspec/scan/config.js +7 -16
  536. package/dist/core/openspec/scan/config.js.map +1 -1
  537. package/dist/core/openspec/scan/shape.js +63 -19
  538. package/dist/core/openspec/scan/shape.js.map +1 -1
  539. package/dist/core/openspec/scan/walk.js +21 -38
  540. package/dist/core/openspec/scan/walk.js.map +1 -1
  541. package/dist/core/pack/living.js +2 -16
  542. package/dist/core/pack/living.js.map +1 -1
  543. package/dist/core/provenance/findings.js +48 -12
  544. package/dist/core/provenance/findings.js.map +1 -1
  545. package/dist/core/provenance/git.js +33 -57
  546. package/dist/core/provenance/git.js.map +1 -1
  547. package/dist/core/provenance/gitq/moves.js +71 -114
  548. package/dist/core/provenance/gitq/moves.js.map +1 -1
  549. package/dist/core/provenance/gitq/vouched-ancestor.js +2 -2
  550. package/dist/core/provenance/gitq/vouched-ancestor.js.map +1 -1
  551. package/dist/core/provenance/sources.js +23 -6
  552. package/dist/core/provenance/sources.js.map +1 -1
  553. package/dist/core/provenance/stamp.js +5 -17
  554. package/dist/core/provenance/stamp.js.map +1 -1
  555. package/dist/core/repo/entries.js.map +1 -1
  556. package/dist/core/repo/paths.js +34 -29
  557. package/dist/core/repo/paths.js.map +1 -1
  558. package/dist/core/repo/repo.js +24 -14
  559. package/dist/core/repo/repo.js.map +1 -1
  560. package/dist/core/repo/tree/find.js +12 -3
  561. package/dist/core/repo/tree/find.js.map +1 -1
  562. package/dist/core/repo/tree/fs.js +27 -1
  563. package/dist/core/repo/tree/fs.js.map +1 -1
  564. package/dist/core/repo/tree/render/body.js +45 -6
  565. package/dist/core/repo/tree/render/body.js.map +1 -1
  566. package/dist/core/repo/tree/render/service/header.js +125 -0
  567. package/dist/core/repo/tree/render/service/header.js.map +1 -0
  568. package/dist/core/repo/tree/render/service/peers.js +28 -0
  569. package/dist/core/repo/tree/render/service/peers.js.map +1 -0
  570. package/dist/core/repo/tree/render/service/view-id.js +67 -0
  571. package/dist/core/repo/tree/render/service/view-id.js.map +1 -0
  572. package/dist/core/repo/tree/render/service/views.js +78 -0
  573. package/dist/core/repo/tree/render/service/views.js.map +1 -0
  574. package/dist/core/repo/tree/render/stale.js +19 -2
  575. package/dist/core/repo/tree/render/stale.js.map +1 -1
  576. package/dist/core/repo/tree/render/views.js +102 -33
  577. package/dist/core/repo/tree/render/views.js.map +1 -1
  578. package/dist/core/repo/tree/walk.js +8 -30
  579. package/dist/core/repo/tree/walk.js.map +1 -1
  580. package/dist/core/results.js +47 -12
  581. package/dist/core/results.js.map +1 -1
  582. package/dist/core/staging/commit.js +10 -12
  583. package/dist/core/staging/commit.js.map +1 -1
  584. package/dist/core/staging/lock.js +74 -12
  585. package/dist/core/staging/lock.js.map +1 -1
  586. package/dist/core/staging/recovery/intent.js +2 -5
  587. package/dist/core/staging/recovery/intent.js.map +1 -1
  588. package/dist/core/staging/recovery/recover.js +60 -1
  589. package/dist/core/staging/recovery/recover.js.map +1 -1
  590. package/dist/core/staging/snapshot/manifest.js +152 -0
  591. package/dist/core/staging/snapshot/manifest.js.map +1 -0
  592. package/dist/core/staging/{snapshot.js → snapshot/snapshot.js} +10 -34
  593. package/dist/core/staging/snapshot/snapshot.js.map +1 -0
  594. package/dist/core/status/actions/execution.js +11 -0
  595. package/dist/core/status/actions/execution.js.map +1 -1
  596. package/dist/core/status/feature/artifacts.js +2 -2
  597. package/dist/core/status/feature/artifacts.js.map +1 -1
  598. package/dist/core/status/feature/feature.js +5 -11
  599. package/dist/core/status/feature/feature.js.map +1 -1
  600. package/dist/core/status/fleet/fleet.js +6 -2
  601. package/dist/core/status/fleet/fleet.js.map +1 -1
  602. package/dist/core/status/fleet/next.js +26 -3
  603. package/dist/core/status/fleet/next.js.map +1 -1
  604. package/dist/core/status/report.js.map +1 -1
  605. package/dist/core/status/verification.js +30 -15
  606. package/dist/core/status/verification.js.map +1 -1
  607. package/dist/core/tasks/packet.js +174 -0
  608. package/dist/core/tasks/packet.js.map +1 -0
  609. package/dist/core/tasks/record.js +43 -0
  610. package/dist/core/tasks/record.js.map +1 -0
  611. package/dist/core/tasks/sources.js +181 -0
  612. package/dist/core/tasks/sources.js.map +1 -0
  613. package/dist/core/tasks/stamps.js +76 -0
  614. package/dist/core/tasks/stamps.js.map +1 -0
  615. package/dist/core/tasks/unit.js +0 -0
  616. package/dist/core/tasks/unit.js.map +1 -0
  617. package/dist/core/usecases/capability.js +11 -14
  618. package/dist/core/usecases/capability.js.map +1 -1
  619. package/dist/core/usecases/delta/flows.js +5 -7
  620. package/dist/core/usecases/delta/flows.js.map +1 -1
  621. package/dist/core/usecases/fleet.js +178 -31
  622. package/dist/core/usecases/fleet.js.map +1 -1
  623. package/dist/core/usecases/operations.js +5 -4
  624. package/dist/core/usecases/operations.js.map +1 -1
  625. package/dist/core/usecases/service/flows.js +9 -6
  626. package/dist/core/usecases/service/flows.js.map +1 -1
  627. package/dist/core/usecases/suites/participants.js +83 -0
  628. package/dist/core/usecases/suites/participants.js.map +1 -0
  629. package/dist/core/usecases/suites/suites.js +45 -0
  630. package/dist/core/usecases/suites/suites.js.map +1 -0
  631. package/dist/core/verify/answers.js +12 -5
  632. package/dist/core/verify/answers.js.map +1 -1
  633. package/dist/core/verify/build.js +102 -82
  634. package/dist/core/verify/build.js.map +1 -1
  635. package/dist/core/verify/checklist.js +45 -10
  636. package/dist/core/verify/checklist.js.map +1 -1
  637. package/dist/core/verify/location.js +67 -0
  638. package/dist/core/verify/location.js.map +1 -0
  639. package/dist/core/verify/pins/lint.js +3 -2
  640. package/dist/core/verify/pins/lint.js.map +1 -1
  641. package/dist/core/verify/pins/pin.js +1 -1
  642. package/dist/core/verify/pins/pin.js.map +1 -1
  643. package/dist/core/verify/read/file.js +224 -0
  644. package/dist/core/verify/read/file.js.map +1 -0
  645. package/dist/core/verify/{file.js → read/shape.js} +16 -104
  646. package/dist/core/verify/read/shape.js.map +1 -0
  647. package/dist/core/verify/read/union.js +105 -0
  648. package/dist/core/verify/read/union.js.map +1 -0
  649. package/dist/core/verify/record.js +35 -12
  650. package/dist/core/verify/record.js.map +1 -1
  651. package/dist/core/verify/store/commit.js +40 -30
  652. package/dist/core/verify/store/commit.js.map +1 -1
  653. package/dist/core/verify/store/render.js +8 -4
  654. package/dist/core/verify/store/render.js.map +1 -1
  655. package/dist/core/vocabulary/issue.js +1 -0
  656. package/dist/core/vocabulary/issue.js.map +1 -1
  657. package/dist/core/vocabulary/maturity.js +53 -3
  658. package/dist/core/vocabulary/maturity.js.map +1 -1
  659. package/examples/README.md +28 -9
  660. package/examples/docs/architecture/landscape.likec4 +21 -7
  661. package/examples/docs/architecture/services.likec4 +70 -0
  662. package/examples/docs/architecture/subsystems.likec4 +3 -0
  663. package/examples/docs/architecture/usecases/order-notification.likec4 +6 -2
  664. package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-service/asyncapi.yaml +11 -8
  665. package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/openapi.yaml +6 -6
  666. package/examples/docs/features/archive/FEAT-088-refunds/specs/payment-service/openapi.yaml +13 -10
  667. package/examples/docs/services/order-service/asyncapi.yaml +3 -2
  668. package/examples/docs/services/order-service/model.likec4 +10 -8
  669. package/examples/docs/services/payment-service/model.likec4 +6 -4
  670. package/examples/docs/services/platform/identity-service/model.likec4 +9 -7
  671. package/examples/docs/services/platform/notification-service/asyncapi.yaml +2 -1
  672. package/examples/docs/services/platform/notification-service/model.likec4 +4 -5
  673. package/package.json +1 -1
  674. package/dist/commands/validate/checks/vocabulary.js +0 -16
  675. package/dist/commands/validate/checks/vocabulary.js.map +0 -1
  676. package/dist/core/agent/agents-md/artifacts.js +0 -221
  677. package/dist/core/agent/agents-md/artifacts.js.map +0 -1
  678. package/dist/core/agent/agents-md/cycle.js +0 -180
  679. package/dist/core/agent/agents-md/cycle.js.map +0 -1
  680. package/dist/core/agent/agents-md/shipped/archive-gate.js +0 -98
  681. package/dist/core/agent/agents-md/shipped/archive-gate.js.map +0 -1
  682. package/dist/core/agent/agents-md/spine.js +0 -40
  683. package/dist/core/agent/agents-md/spine.js.map +0 -1
  684. package/dist/core/coherence/events/merged.js +0 -40
  685. package/dist/core/coherence/events/merged.js.map +0 -1
  686. package/dist/core/diff/current-state.js +0 -96
  687. package/dist/core/diff/current-state.js.map +0 -1
  688. package/dist/core/diff/victims.js +0 -256
  689. package/dist/core/diff/victims.js.map +0 -1
  690. package/dist/core/staging/snapshot.js.map +0 -1
  691. package/dist/core/verify/file.js.map +0 -1
@@ -23,6 +23,10 @@
23
23
  * reports @Before/@After hook results in per-element `before[]`/`after[]`
24
24
  * arrays (a teardown assertion fails there while every step stays `passed`),
25
25
  * and behave carries an element-level `status` that a hook failure flips.
26
+ * cucumber-js puts its hooks among the `steps[]` instead, as entries marked
27
+ * `hidden` with the keyword `Before`/`After`, and they are read as hooks
28
+ * there too — the number of real steps is contract now, because a run is
29
+ * held to the step count of the scenario it claims to be (`runnerAnswers`).
26
30
  * Only those fields are contract: unknown fields, tagless elements
27
31
  * (backgrounds, hand-written scenarios) and stray entries are skipped without
28
32
  * comment. What is NOT tolerated is a file that is not that array at all: a
@@ -127,6 +131,7 @@ export function readScenarioReport(doc, reportName) {
127
131
  where: str(entry["test"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,
128
132
  digests: [digest],
129
133
  steps: [status],
134
+ stepwise: false,
130
135
  hooks: [],
131
136
  });
132
137
  }
@@ -137,7 +142,8 @@ export function readCucumberReport(doc, reportName) {
137
142
  return {
138
143
  ok: false,
139
144
  message: `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +
140
- "that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits.",
145
+ "that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits. " +
146
+ `A runner with no cucumber formatter answers through loam's own shape instead: {"${SCENARIO_REPORT_MARKER}": 1, "results": [{"digest": "…", "status": "passed"}]}.`,
141
147
  };
142
148
  }
143
149
  const scenarios = [];
@@ -165,17 +171,24 @@ export function readCucumberReport(doc, reportName) {
165
171
  // scenario, another tool's output. Invisible here, deliberately.
166
172
  if (digests.length === 0)
167
173
  continue;
168
- const resultStatuses = (raw) => {
169
- if (!Array.isArray(raw))
170
- return [];
171
- return raw.map((s) => {
172
- const status = isRecord(s) && isRecord(s["result"]) ? str(s["result"]["status"]) : undefined;
173
- return (status ?? "unknown").toLowerCase();
174
- });
174
+ const statusOf = (s) => {
175
+ const status = isRecord(s) && isRecord(s["result"]) ? str(s["result"]["status"]) : undefined;
176
+ return (status ?? "unknown").toLowerCase();
175
177
  };
176
- const steps = resultStatuses(el["steps"]);
178
+ const resultStatuses = (raw) => (Array.isArray(raw) ? raw.map(statusOf) : []);
179
+ // cucumber-js reports its Before/After hooks INSIDE `steps`, as entries
180
+ // marked `hidden` with the keyword `Before`/`After`; they are hooks, not
181
+ // steps of the scenario, and counting them would make every hooked run
182
+ // look like a different scenario from the one stamped.
183
+ const listed = Array.isArray(el["steps"]) ? el["steps"] : [];
184
+ const isHook = (s) => isRecord(s) && (s["hidden"] === true || /^(before|after)$/i.test(str(s["keyword"])?.trim() ?? ""));
185
+ const steps = listed.filter((s) => !isHook(s)).map(statusOf);
177
186
  // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.
178
- const hooks = [...resultStatuses(el["before"]), ...resultStatuses(el["after"])];
187
+ const hooks = [
188
+ ...resultStatuses(el["before"]),
189
+ ...listed.filter(isHook).map(statusOf),
190
+ ...resultStatuses(el["after"]),
191
+ ];
179
192
  // behave's element-level status — a hook failure flips it while the
180
193
  // steps that already ran keep "passed".
181
194
  const status = str(el["status"])?.toLowerCase();
@@ -183,6 +196,7 @@ export function readCucumberReport(doc, reportName) {
183
196
  where: `${featureName} › ${str(el["name"]) ?? "(unnamed scenario)"}`,
184
197
  digests,
185
198
  steps,
199
+ stepwise: true,
186
200
  hooks,
187
201
  ...(status === undefined ? {} : { status }),
188
202
  });
@@ -236,6 +250,14 @@ export function contestedDigests(claims) {
236
250
  * occurrence wins as failure, a skipped-only run is "skipped" not green, and
237
251
  * no match at all is "not found in report". A digest two services share is
238
252
  * refused before any of that: see {@link contestedDigests}.
253
+ *
254
+ * And a cucumber occurrence must have run as many steps as the stamped
255
+ * scenario has (`Claim.steps`). The digest is only a tag, and a tag is text:
256
+ * a one-step stub named "a step" carrying the right digest confirmed
257
+ * five-step scenarios, because nothing compared what ran with what was
258
+ * stamped. The count is the check every dialect can be held to; loam's own
259
+ * runner-neutral shape carries no steps to count, so its entries are held to
260
+ * their status alone, as that shape has always promised.
239
261
  */
240
262
  export function runnerAnswers(claims, scenarios, reportName) {
241
263
  const contested = contestedDigests(claims);
@@ -250,13 +272,16 @@ export function runnerAnswers(claims, scenarios, reportName) {
250
272
  const tag = c.digest === undefined ? "a digest tag" : scenarioDigestTag(c.digest);
251
273
  return answer(c.id, "unconfirmed", [], `not found in report — no scenario in ${reportName} carries ${tag}`);
252
274
  }
253
- const failing = runs.filter((r) => !passed(r));
275
+ const failing = runs.filter((r) => !passed(r) || !sameShape(r, c.steps));
254
276
  if (failing.length === 0) {
255
277
  return answer(c.id, "confirmed", runs.map((r) => `${reportName}: ${r.where}`));
256
278
  }
257
279
  const first = failing[0];
258
280
  const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : "";
259
- return answer(c.id, "unconfirmed", [], `${reason(first)} (${reportName}: ${first.where})${rerun}`);
281
+ const why = passed(first)
282
+ ? `ran ${first.steps.length} step(s) where the stamped scenario has ${c.steps} — the report's scenario is not the one \`loam gherkin\` stamped with this digest`
283
+ : reason(first);
284
+ return answer(c.id, "unconfirmed", [], `${why} (${reportName}: ${first.where})${rerun}`);
260
285
  });
261
286
  }
262
287
  function answer(id, verdict, evidence, note) {
@@ -276,6 +301,16 @@ function passed(r) {
276
301
  r.hooks.every((h) => h === "passed") &&
277
302
  (r.status === undefined || r.status === "passed"));
278
303
  }
304
+ /**
305
+ * Did this run execute the scenario the claim stamped — as far as the report
306
+ * can say? A cucumber run must have run exactly the stamped number of steps.
307
+ * An entry with no step structure (loam's own shape), and a claim carrying no
308
+ * count (only `scenario.tested` claims carry one, and only they carry a digest
309
+ * to match), are held to their status alone.
310
+ */
311
+ function sameShape(r, expected) {
312
+ return !r.stepwise || expected === undefined || r.steps.length === expected;
313
+ }
279
314
  /** Why a run did not pass, naming the step, hook or element status that says so. */
280
315
  function reason(r) {
281
316
  if (r.steps.length === 0)
@@ -1 +1 @@
1
- {"version":3,"file":"results.js","sourceRoot":"","sources":["../../src/core/results.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam verify --results` — the cucumber report as an answer sheet.\n *\n * `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and\n * cucumber's JSON format carries tags per scenario — so a test report says,\n * mechanically, which spec scenarios ran green. This module reads that report\n * and answers `scenario.tested` claims from it. The digest is the ONLY\n * identity: a claim's `digest` and the tag are the same 16 hex of the same\n * body hash, and names match nothing — rewording a scenario breaks the match,\n * which is the point, because an agent must not be able to SAY a scenario is\n * tested; only a green run may.\n *\n * That identity is CONTENT, and content stops one step short of a fleet: two\n * services whose specs word a scenario identically share a digest, so one\n * repository's report matches the other repository's claim. Those claims are\n * refused rather than answered — see {@link contestedDigests}.\n *\n * Parsing is deliberately tolerant of everything except the shape it matches\n * on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and\n * SpecFlow emit — an array of features, each `elements[]` (scenarios) with\n * `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two\n * places those dialects put a failure the steps never see: cucumber-jvm\n * reports @Before/@After hook results in per-element `before[]`/`after[]`\n * arrays (a teardown assertion fails there while every step stays `passed`),\n * and behave carries an element-level `status` that a hook failure flips.\n * Only those fields are contract: unknown fields, tagless elements\n * (backgrounds, hand-written scenarios) and stray entries are skipped without\n * comment. What is NOT tolerated is a file that is not that array at all: a\n * report loam cannot recognize must refuse rather than quietly answer every\n * claim \"not found\".\n */\nimport { DIGEST_TAG_RE, scenarioDigestTag } from \"./gherkin/stamp.js\";\nimport { isRecord } from \"./kernel/records.js\";\nimport type { Answer } from \"./verify/answers.js\";\nimport type { Claim } from \"./verify/checklist.js\";\n\n/** One report scenario, reduced to what matching needs. */\nexport interface ReportScenario {\n /** `<feature uri or name> › <scenario name>` — where the run can be seen. */\n where: string;\n /** Digests carried as `@loam-digest-…` tags. Usually one; hand-tagging can add more. */\n digests: string[];\n /** Step statuses, lowercased, in order. */\n steps: string[];\n /** Hook statuses from cucumber-jvm's `before[]`/`after[]`, lowercased, in order. */\n hooks: string[];\n /** behave's element-level status, lowercased — absent in the other dialects. */\n status?: string;\n}\n\nexport type ReportRead =\n | { ok: true; scenarios: ReportScenario[] }\n | { ok: false; message: string };\n\n/**\n * Read a parsed cucumber JSON document down to its digest-tagged scenarios.\n * `reportName` is how the caller spelled the file, for the refusal message.\n */\n/** The marker key that makes a JSON document loam's own scenario shape, and the version this loam reads. */\nexport const SCENARIO_REPORT_MARKER = \"loamScenarioReport\";\nexport const SCENARIO_REPORT_VERSION = 1;\n\n/**\n * Is this document loam's own scenario-report shape rather than a cucumber one?\n *\n * Marker-only, deliberately: the caller must be able to choose a READER before\n * either has validated anything, so that a malformed `loamScenarioReport` file\n * refuses as a malformed report of ITS kind rather than as \"not a cucumber\n * array\". A file that claims the shape and then fails it must never fall\n * through to the other parser and be told it is the wrong sort of document.\n */\nexport function isScenarioReport(doc: unknown): boolean {\n return isRecord(doc) && doc[SCENARIO_REPORT_MARKER] !== undefined;\n}\n\n/**\n * `{\"loamScenarioReport\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}`\n * — the runner-neutral answer sheet for `scenario.tested` claims.\n *\n * WHY THIS EXISTS. `loam gherkin` stamps every generated scenario\n * `@loam-digest-<16hex>`, and until now only cucumber's JSON carried that tag\n * back. A fleet on JUnit, pytest, Playwright, Vitest or a house runner could\n * therefore never reach `verified` — not because its evidence was weaker, but\n * because of a file format. The example fleet's own shipped record is the proof\n * of the cost: every claim in it is `answered_by: agent`, so the product's\n * showcase demonstrates only the lesser verdict.\n *\n * THE STANDARD OF PROOF IS UNCHANGED, and that is the whole argument. The\n * contract was never the JSON dialect: it is the digest — content-derived, so\n * rewording a `Given` breaks the match — plus a status that says a real run\n * reported it green. Both halves are here. An answer from this file is\n * `answered_by: runner` exactly as a cucumber one is, because it is the same\n * claim answered to the same standard by the same identity.\n *\n * It also adds no forgeability. loam cannot prove any JSON came from executing\n * a commit — SCHEMA says so, and the record stores the file's sha256 and mtime\n * precisely because that is all it can honestly claim. A hand-written cucumber\n * array was always exactly as easy to write as this is.\n *\n * The strict/tolerant split mirrors `verify/evidence/contract.ts` for its\n * reason: a cucumber report is another tool's file full of entries loam has no\n * business judging, while this shape exists for one purpose, so a malformed\n * entry is a malformed report rather than scenery to skip. `status` must be\n * exactly `passed` or `failed`; `skipped`, `pending` and vendor spellings are\n * refused rather than guessed at, because guessing is how a not-run scenario\n * becomes a confirmation.\n */\nexport function readScenarioReport(doc: unknown, reportName: string): ReportRead {\n if (!isRecord(doc)) {\n return { ok: false, message: `${reportName} is not a JSON object.` };\n }\n const version = doc[SCENARIO_REPORT_MARKER];\n if (version !== SCENARIO_REPORT_VERSION) {\n return {\n ok: false,\n message:\n `${reportName} declares \\`${SCENARIO_REPORT_MARKER}: ${JSON.stringify(version)}\\`, and this loam reads ` +\n `version ${SCENARIO_REPORT_VERSION}. A report loam cannot read is refused rather than answered in part.`,\n };\n }\n const results = doc[\"results\"];\n if (!Array.isArray(results)) {\n return {\n ok: false,\n message: `${reportName} has no \\`results\\` array — the shape is {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const [i, entry] of results.entries()) {\n const at = `${reportName} results[${i}]`;\n if (!isRecord(entry)) return { ok: false, message: `${at} is not an object.` };\n const digest = str(entry[\"digest\"]);\n if (digest === undefined || !/^[0-9a-f]{16}$/.test(digest)) {\n return {\n ok: false,\n message: `${at} has no \\`digest\\` of 16 lowercase hex characters — that is the \\`@loam-digest-…\\` tag \\`loam gherkin\\` stamped on the scenario.`,\n };\n }\n const status = str(entry[\"status\"]);\n if (status !== \"passed\" && status !== \"failed\") {\n return {\n ok: false,\n message:\n `${at} has \\`status: ${JSON.stringify(entry[\"status\"])}\\`; this shape accepts only \"passed\" or \"failed\". ` +\n \"A scenario that did not run has no place in an answer sheet — omit it, and its claim stays unanswered rather than becoming a confirmation.\",\n };\n }\n scenarios.push({\n // The runner's own name for the test, when it gave one — this is where a\n // reader goes to see the run, and it is the only free text here.\n where: str(entry[\"test\"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,\n digests: [digest],\n steps: [status],\n hooks: [],\n });\n }\n return { ok: true, scenarios };\n}\n\nexport function readCucumberReport(doc: unknown, reportName: string): ReportRead {\n if (!Array.isArray(doc)) {\n return {\n ok: false,\n message:\n `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +\n \"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits.\",\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const feature of doc) {\n if (!isRecord(feature)) continue;\n const featureName = str(feature[\"uri\"]) ?? str(feature[\"name\"]) ?? \"(unnamed feature)\";\n const elements = feature[\"elements\"];\n if (!Array.isArray(elements)) continue;\n for (const el of elements) {\n if (!isRecord(el)) continue;\n const digests: string[] = [];\n const tags = el[\"tags\"];\n if (Array.isArray(tags)) {\n for (const t of tags) {\n const name = typeof t === \"string\" ? t : isRecord(t) ? str(t[\"name\"]) : undefined;\n const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);\n if (m) digests.push(m[1]!);\n }\n }\n // No digest tag, nothing to match: a background, a hand-written\n // scenario, another tool's output. Invisible here, deliberately.\n if (digests.length === 0) continue;\n const resultStatuses = (raw: unknown): string[] => {\n if (!Array.isArray(raw)) return [];\n return raw.map((s) => {\n const status = isRecord(s) && isRecord(s[\"result\"]) ? str(s[\"result\"][\"status\"]) : undefined;\n return (status ?? \"unknown\").toLowerCase();\n });\n };\n const steps = resultStatuses(el[\"steps\"]);\n // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.\n const hooks = [...resultStatuses(el[\"before\"]), ...resultStatuses(el[\"after\"])];\n // behave's element-level status — a hook failure flips it while the\n // steps that already ran keep \"passed\".\n const status = str(el[\"status\"])?.toLowerCase();\n scenarios.push({\n where: `${featureName} › ${str(el[\"name\"]) ?? \"(unnamed scenario)\"}`,\n digests,\n steps,\n hooks,\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return { ok: true, scenarios };\n}\n\n/**\n * The digests that more than one service claims, with the services that claim\n * them — a scenario worded identically in two services' specs.\n *\n * The digest is a hash of the scenario body and of nothing else, so `loam\n * gherkin` stamps the same tag into both repositories and a report from either\n * one matches both claims. A report says which scenario ran; it never says\n * whose suite ran it, and the two repositories never ran each other's tests.\n * loam has no way to tell them apart, so it does not choose: the answer is\n * refused, with the services named, and `--service` is the way to give a report\n * an owner (it narrows the checklist to one repository's claims before the\n * matching starts, so nothing here is contested).\n *\n * Only the SERVICE boundary counts. One service's spec repeating a scenario\n * word for word is genuinely one test — that is the documented meaning of the\n * stamp — and both claims may be answered by the one run.\n */\nexport function contestedDigests(claims: readonly Claim[]): Map<string, string[]> {\n const owners = new Map<string, Set<string>>();\n for (const c of claims) {\n if (c.digest === undefined) continue;\n const seen = owners.get(c.digest) ?? new Set<string>();\n seen.add(c.subject);\n owners.set(c.digest, seen);\n }\n const contested = new Map<string, string[]>();\n for (const [digest, services] of owners) {\n if (services.size > 1) contested.set(digest, [...services].sort());\n }\n return contested;\n}\n\n/**\n * Answer every claim from the report — the runner's half of the record. Meant\n * for `scenario.tested` claims (the only kind carrying a digest); a claim\n * without one is answered `unconfirmed` rather than skipped, because silence\n * must never read as checked.\n *\n * Confirmation is strict on purpose: every matching occurrence — a digest the\n * report holds twice is a re-run, and all of them count — ran at least one\n * step, every step `passed`, every before/after hook `passed`, and the\n * element-level status (when the dialect reports one) is `passed`. One failed\n * occurrence wins as failure, a skipped-only run is \"skipped\" not green, and\n * no match at all is \"not found in report\". A digest two services share is\n * refused before any of that: see {@link contestedDigests}.\n */\nexport function runnerAnswers(\n claims: Claim[],\n scenarios: ReportScenario[],\n reportName: string,\n): Answer[] {\n const contested = contestedDigests(claims);\n return claims.map((c) => {\n const rivals = c.digest === undefined ? undefined : contested.get(c.digest);\n if (rivals !== undefined) {\n return answer(\n c.id,\n \"unconfirmed\",\n [],\n `${scenarioDigestTag(c.digest!)} is worded identically in ${rivals.join(\", \")}, so ${reportName} cannot say whose suite ran it. ` +\n \"Record each service's claims from its own repo with --service.\",\n );\n }\n const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest!));\n if (runs.length === 0) {\n const tag = c.digest === undefined ? \"a digest tag\" : scenarioDigestTag(c.digest);\n return answer(c.id, \"unconfirmed\", [], `not found in report — no scenario in ${reportName} carries ${tag}`);\n }\n const failing = runs.filter((r) => !passed(r));\n if (failing.length === 0) {\n return answer(c.id, \"confirmed\", runs.map((r) => `${reportName}: ${r.where}`));\n }\n const first = failing[0]!;\n const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : \"\";\n return answer(c.id, \"unconfirmed\", [], `${reason(first)} (${reportName}: ${first.where})${rerun}`);\n });\n}\n\nfunction answer(id: string, verdict: Answer[\"verdict\"], evidence: string[], note?: string): Answer {\n return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: \"runner\" };\n}\n\n/**\n * Green means green: at least one step, every step `passed`, every before/\n * after hook `passed`, and the element-level status (when the dialect carries\n * one) `passed` too. The last two exist because a run the runner itself\n * reports as FAILED — a teardown assertion in an @After hook, exactly the\n * shape the arch axis's outbox checks take — used to read as confirmed here:\n * the steps all passed, and the hook failure lived in fields nobody read.\n */\nfunction passed(r: ReportScenario): boolean {\n return (\n r.steps.length > 0 &&\n r.steps.every((s) => s === \"passed\") &&\n r.hooks.every((h) => h === \"passed\") &&\n (r.status === undefined || r.status === \"passed\")\n );\n}\n\n/** Why a run did not pass, naming the step, hook or element status that says so. */\nfunction reason(r: ReportScenario): string {\n if (r.steps.length === 0) return \"no steps ran\";\n const i = r.steps.findIndex((s) => s !== \"passed\" && s !== \"skipped\");\n if (i >= 0) return `${r.steps[i]} at step ${i + 1}`;\n if (r.steps.some((s) => s === \"skipped\")) return \"skipped\";\n const h = r.hooks.findIndex((s) => s !== \"passed\");\n if (h >= 0) return `${r.hooks[h]} in a before/after hook`;\n return `scenario status '${r.status ?? \"unknown\"}'`;\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\n"]}
1
+ {"version":3,"file":"results.js","sourceRoot":"","sources":["../../src/core/results.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam verify --results` — the cucumber report as an answer sheet.\n *\n * `loam gherkin` tags every generated scenario `@loam-digest-<16hex>`, and\n * cucumber's JSON format carries tags per scenario — so a test report says,\n * mechanically, which spec scenarios ran green. This module reads that report\n * and answers `scenario.tested` claims from it. The digest is the ONLY\n * identity: a claim's `digest` and the tag are the same 16 hex of the same\n * body hash, and names match nothing — rewording a scenario breaks the match,\n * which is the point, because an agent must not be able to SAY a scenario is\n * tested; only a green run may.\n *\n * That identity is CONTENT, and content stops one step short of a fleet: two\n * services whose specs word a scenario identically share a digest, so one\n * repository's report matches the other repository's claim. Those claims are\n * refused rather than answered — see {@link contestedDigests}.\n *\n * Parsing is deliberately tolerant of everything except the shape it matches\n * on. The format is what cucumber-js `--format json`, cucumber-jvm, behave and\n * SpecFlow emit — an array of features, each `elements[]` (scenarios) with\n * `name`, `tags[] {name}` and `steps[] {result {status}}`, plus the two\n * places those dialects put a failure the steps never see: cucumber-jvm\n * reports @Before/@After hook results in per-element `before[]`/`after[]`\n * arrays (a teardown assertion fails there while every step stays `passed`),\n * and behave carries an element-level `status` that a hook failure flips.\n * cucumber-js puts its hooks among the `steps[]` instead, as entries marked\n * `hidden` with the keyword `Before`/`After`, and they are read as hooks\n * there too — the number of real steps is contract now, because a run is\n * held to the step count of the scenario it claims to be (`runnerAnswers`).\n * Only those fields are contract: unknown fields, tagless elements\n * (backgrounds, hand-written scenarios) and stray entries are skipped without\n * comment. What is NOT tolerated is a file that is not that array at all: a\n * report loam cannot recognize must refuse rather than quietly answer every\n * claim \"not found\".\n */\nimport { DIGEST_TAG_RE, scenarioDigestTag } from \"./gherkin/stamp.js\";\nimport { isRecord } from \"./kernel/records.js\";\nimport type { Answer } from \"./verify/answers.js\";\nimport type { Claim } from \"./verify/checklist.js\";\n\n/** One report scenario, reduced to what matching needs. */\nexport interface ReportScenario {\n /** `<feature uri or name> › <scenario name>` — where the run can be seen. */\n where: string;\n /** Digests carried as `@loam-digest-…` tags. Usually one; hand-tagging can add more. */\n digests: string[];\n /** Step statuses, lowercased, in order — hooks excluded, wherever the dialect put them. */\n steps: string[];\n /**\n * Whether `steps` is the run's own step list — true for a cucumber report —\n * or a one-status stand-in: loam's runner-neutral shape carries a status per\n * scenario and no step structure at all, so there is nothing in it to hold\n * against the stamped scenario's step count.\n */\n stepwise: boolean;\n /** Hook statuses — cucumber-jvm's `before[]`/`after[]`, cucumber-js's hidden hook steps — lowercased, in order. */\n hooks: string[];\n /** behave's element-level status, lowercased — absent in the other dialects. */\n status?: string;\n}\n\nexport type ReportRead =\n | { ok: true; scenarios: ReportScenario[] }\n | { ok: false; message: string };\n\n/**\n * Read a parsed cucumber JSON document down to its digest-tagged scenarios.\n * `reportName` is how the caller spelled the file, for the refusal message.\n */\n/** The marker key that makes a JSON document loam's own scenario shape, and the version this loam reads. */\nexport const SCENARIO_REPORT_MARKER = \"loamScenarioReport\";\nexport const SCENARIO_REPORT_VERSION = 1;\n\n/**\n * Is this document loam's own scenario-report shape rather than a cucumber one?\n *\n * Marker-only, deliberately: the caller must be able to choose a READER before\n * either has validated anything, so that a malformed `loamScenarioReport` file\n * refuses as a malformed report of ITS kind rather than as \"not a cucumber\n * array\". A file that claims the shape and then fails it must never fall\n * through to the other parser and be told it is the wrong sort of document.\n */\nexport function isScenarioReport(doc: unknown): boolean {\n return isRecord(doc) && doc[SCENARIO_REPORT_MARKER] !== undefined;\n}\n\n/**\n * `{\"loamScenarioReport\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}`\n * — the runner-neutral answer sheet for `scenario.tested` claims.\n *\n * WHY THIS EXISTS. `loam gherkin` stamps every generated scenario\n * `@loam-digest-<16hex>`, and until now only cucumber's JSON carried that tag\n * back. A fleet on JUnit, pytest, Playwright, Vitest or a house runner could\n * therefore never reach `verified` — not because its evidence was weaker, but\n * because of a file format. The example fleet's own shipped record is the proof\n * of the cost: every claim in it is `answered_by: agent`, so the product's\n * showcase demonstrates only the lesser verdict.\n *\n * THE STANDARD OF PROOF IS UNCHANGED, and that is the whole argument. The\n * contract was never the JSON dialect: it is the digest — content-derived, so\n * rewording a `Given` breaks the match — plus a status that says a real run\n * reported it green. Both halves are here. An answer from this file is\n * `answered_by: runner` exactly as a cucumber one is, because it is the same\n * claim answered to the same standard by the same identity.\n *\n * It also adds no forgeability. loam cannot prove any JSON came from executing\n * a commit — SCHEMA says so, and the record stores the file's sha256 and mtime\n * precisely because that is all it can honestly claim. A hand-written cucumber\n * array was always exactly as easy to write as this is.\n *\n * The strict/tolerant split mirrors `verify/evidence/contract.ts` for its\n * reason: a cucumber report is another tool's file full of entries loam has no\n * business judging, while this shape exists for one purpose, so a malformed\n * entry is a malformed report rather than scenery to skip. `status` must be\n * exactly `passed` or `failed`; `skipped`, `pending` and vendor spellings are\n * refused rather than guessed at, because guessing is how a not-run scenario\n * becomes a confirmation.\n */\nexport function readScenarioReport(doc: unknown, reportName: string): ReportRead {\n if (!isRecord(doc)) {\n return { ok: false, message: `${reportName} is not a JSON object.` };\n }\n const version = doc[SCENARIO_REPORT_MARKER];\n if (version !== SCENARIO_REPORT_VERSION) {\n return {\n ok: false,\n message:\n `${reportName} declares \\`${SCENARIO_REPORT_MARKER}: ${JSON.stringify(version)}\\`, and this loam reads ` +\n `version ${SCENARIO_REPORT_VERSION}. A report loam cannot read is refused rather than answered in part.`,\n };\n }\n const results = doc[\"results\"];\n if (!Array.isArray(results)) {\n return {\n ok: false,\n message: `${reportName} has no \\`results\\` array — the shape is {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const [i, entry] of results.entries()) {\n const at = `${reportName} results[${i}]`;\n if (!isRecord(entry)) return { ok: false, message: `${at} is not an object.` };\n const digest = str(entry[\"digest\"]);\n if (digest === undefined || !/^[0-9a-f]{16}$/.test(digest)) {\n return {\n ok: false,\n message: `${at} has no \\`digest\\` of 16 lowercase hex characters — that is the \\`@loam-digest-…\\` tag \\`loam gherkin\\` stamped on the scenario.`,\n };\n }\n const status = str(entry[\"status\"]);\n if (status !== \"passed\" && status !== \"failed\") {\n return {\n ok: false,\n message:\n `${at} has \\`status: ${JSON.stringify(entry[\"status\"])}\\`; this shape accepts only \"passed\" or \"failed\". ` +\n \"A scenario that did not run has no place in an answer sheet — omit it, and its claim stays unanswered rather than becoming a confirmation.\",\n };\n }\n scenarios.push({\n // The runner's own name for the test, when it gave one — this is where a\n // reader goes to see the run, and it is the only free text here.\n where: str(entry[\"test\"]) ?? `(${SCENARIO_REPORT_MARKER} entry ${i})`,\n digests: [digest],\n steps: [status],\n stepwise: false,\n hooks: [],\n });\n }\n return { ok: true, scenarios };\n}\n\nexport function readCucumberReport(doc: unknown, reportName: string): ReportRead {\n if (!Array.isArray(doc)) {\n return {\n ok: false,\n message:\n `${reportName} is not a cucumber JSON report — expected the top-level array of features ` +\n \"that `cucumber-js --format json` (or cucumber-jvm, behave, SpecFlow) emits. \" +\n `A runner with no cucumber formatter answers through loam's own shape instead: {\"${SCENARIO_REPORT_MARKER}\": 1, \"results\": [{\"digest\": \"…\", \"status\": \"passed\"}]}.`,\n };\n }\n const scenarios: ReportScenario[] = [];\n for (const feature of doc) {\n if (!isRecord(feature)) continue;\n const featureName = str(feature[\"uri\"]) ?? str(feature[\"name\"]) ?? \"(unnamed feature)\";\n const elements = feature[\"elements\"];\n if (!Array.isArray(elements)) continue;\n for (const el of elements) {\n if (!isRecord(el)) continue;\n const digests: string[] = [];\n const tags = el[\"tags\"];\n if (Array.isArray(tags)) {\n for (const t of tags) {\n const name = typeof t === \"string\" ? t : isRecord(t) ? str(t[\"name\"]) : undefined;\n const m = name === undefined ? null : DIGEST_TAG_RE.exec(name);\n if (m) digests.push(m[1]!);\n }\n }\n // No digest tag, nothing to match: a background, a hand-written\n // scenario, another tool's output. Invisible here, deliberately.\n if (digests.length === 0) continue;\n const statusOf = (s: unknown): string => {\n const status = isRecord(s) && isRecord(s[\"result\"]) ? str(s[\"result\"][\"status\"]) : undefined;\n return (status ?? \"unknown\").toLowerCase();\n };\n const resultStatuses = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map(statusOf) : []);\n // cucumber-js reports its Before/After hooks INSIDE `steps`, as entries\n // marked `hidden` with the keyword `Before`/`After`; they are hooks, not\n // steps of the scenario, and counting them would make every hooked run\n // look like a different scenario from the one stamped.\n const listed = Array.isArray(el[\"steps\"]) ? (el[\"steps\"] as unknown[]) : [];\n const isHook = (s: unknown): boolean =>\n isRecord(s) && (s[\"hidden\"] === true || /^(before|after)$/i.test(str(s[\"keyword\"])?.trim() ?? \"\"));\n const steps = listed.filter((s) => !isHook(s)).map(statusOf);\n // cucumber-jvm's @Before/@After hooks: separate arrays, same result shape.\n const hooks = [\n ...resultStatuses(el[\"before\"]),\n ...listed.filter(isHook).map(statusOf),\n ...resultStatuses(el[\"after\"]),\n ];\n // behave's element-level status — a hook failure flips it while the\n // steps that already ran keep \"passed\".\n const status = str(el[\"status\"])?.toLowerCase();\n scenarios.push({\n where: `${featureName} › ${str(el[\"name\"]) ?? \"(unnamed scenario)\"}`,\n digests,\n steps,\n stepwise: true,\n hooks,\n ...(status === undefined ? {} : { status }),\n });\n }\n }\n return { ok: true, scenarios };\n}\n\n/**\n * The digests that more than one service claims, with the services that claim\n * them — a scenario worded identically in two services' specs.\n *\n * The digest is a hash of the scenario body and of nothing else, so `loam\n * gherkin` stamps the same tag into both repositories and a report from either\n * one matches both claims. A report says which scenario ran; it never says\n * whose suite ran it, and the two repositories never ran each other's tests.\n * loam has no way to tell them apart, so it does not choose: the answer is\n * refused, with the services named, and `--service` is the way to give a report\n * an owner (it narrows the checklist to one repository's claims before the\n * matching starts, so nothing here is contested).\n *\n * Only the SERVICE boundary counts. One service's spec repeating a scenario\n * word for word is genuinely one test — that is the documented meaning of the\n * stamp — and both claims may be answered by the one run.\n */\nexport function contestedDigests(claims: readonly Claim[]): Map<string, string[]> {\n const owners = new Map<string, Set<string>>();\n for (const c of claims) {\n if (c.digest === undefined) continue;\n const seen = owners.get(c.digest) ?? new Set<string>();\n seen.add(c.subject);\n owners.set(c.digest, seen);\n }\n const contested = new Map<string, string[]>();\n for (const [digest, services] of owners) {\n if (services.size > 1) contested.set(digest, [...services].sort());\n }\n return contested;\n}\n\n/**\n * Answer every claim from the report — the runner's half of the record. Meant\n * for `scenario.tested` claims (the only kind carrying a digest); a claim\n * without one is answered `unconfirmed` rather than skipped, because silence\n * must never read as checked.\n *\n * Confirmation is strict on purpose: every matching occurrence — a digest the\n * report holds twice is a re-run, and all of them count — ran at least one\n * step, every step `passed`, every before/after hook `passed`, and the\n * element-level status (when the dialect reports one) is `passed`. One failed\n * occurrence wins as failure, a skipped-only run is \"skipped\" not green, and\n * no match at all is \"not found in report\". A digest two services share is\n * refused before any of that: see {@link contestedDigests}.\n *\n * And a cucumber occurrence must have run as many steps as the stamped\n * scenario has (`Claim.steps`). The digest is only a tag, and a tag is text:\n * a one-step stub named \"a step\" carrying the right digest confirmed\n * five-step scenarios, because nothing compared what ran with what was\n * stamped. The count is the check every dialect can be held to; loam's own\n * runner-neutral shape carries no steps to count, so its entries are held to\n * their status alone, as that shape has always promised.\n */\nexport function runnerAnswers(\n claims: Claim[],\n scenarios: ReportScenario[],\n reportName: string,\n): Answer[] {\n const contested = contestedDigests(claims);\n return claims.map((c) => {\n const rivals = c.digest === undefined ? undefined : contested.get(c.digest);\n if (rivals !== undefined) {\n return answer(\n c.id,\n \"unconfirmed\",\n [],\n `${scenarioDigestTag(c.digest!)} is worded identically in ${rivals.join(\", \")}, so ${reportName} cannot say whose suite ran it. ` +\n \"Record each service's claims from its own repo with --service.\",\n );\n }\n const runs = c.digest === undefined ? [] : scenarios.filter((s) => s.digests.includes(c.digest!));\n if (runs.length === 0) {\n const tag = c.digest === undefined ? \"a digest tag\" : scenarioDigestTag(c.digest);\n return answer(c.id, \"unconfirmed\", [], `not found in report — no scenario in ${reportName} carries ${tag}`);\n }\n const failing = runs.filter((r) => !passed(r) || !sameShape(r, c.steps));\n if (failing.length === 0) {\n return answer(c.id, \"confirmed\", runs.map((r) => `${reportName}: ${r.where}`));\n }\n const first = failing[0]!;\n const rerun = runs.length > 1 ? ` — ${failing.length} of ${runs.length} matching runs failed` : \"\";\n const why = passed(first)\n ? `ran ${first.steps.length} step(s) where the stamped scenario has ${c.steps} — the report's scenario is not the one \\`loam gherkin\\` stamped with this digest`\n : reason(first);\n return answer(c.id, \"unconfirmed\", [], `${why} (${reportName}: ${first.where})${rerun}`);\n });\n}\n\nfunction answer(id: string, verdict: Answer[\"verdict\"], evidence: string[], note?: string): Answer {\n return { id, verdict, evidence, ...(note === undefined ? {} : { note }), answered_by: \"runner\" };\n}\n\n/**\n * Green means green: at least one step, every step `passed`, every before/\n * after hook `passed`, and the element-level status (when the dialect carries\n * one) `passed` too. The last two exist because a run the runner itself\n * reports as FAILED — a teardown assertion in an @After hook, exactly the\n * shape the arch axis's outbox checks take — used to read as confirmed here:\n * the steps all passed, and the hook failure lived in fields nobody read.\n */\nfunction passed(r: ReportScenario): boolean {\n return (\n r.steps.length > 0 &&\n r.steps.every((s) => s === \"passed\") &&\n r.hooks.every((h) => h === \"passed\") &&\n (r.status === undefined || r.status === \"passed\")\n );\n}\n\n/**\n * Did this run execute the scenario the claim stamped — as far as the report\n * can say? A cucumber run must have run exactly the stamped number of steps.\n * An entry with no step structure (loam's own shape), and a claim carrying no\n * count (only `scenario.tested` claims carry one, and only they carry a digest\n * to match), are held to their status alone.\n */\nfunction sameShape(r: ReportScenario, expected: number | undefined): boolean {\n return !r.stepwise || expected === undefined || r.steps.length === expected;\n}\n\n/** Why a run did not pass, naming the step, hook or element status that says so. */\nfunction reason(r: ReportScenario): string {\n if (r.steps.length === 0) return \"no steps ran\";\n const i = r.steps.findIndex((s) => s !== \"passed\" && s !== \"skipped\");\n if (i >= 0) return `${r.steps[i]} at step ${i + 1}`;\n if (r.steps.some((s) => s === \"skipped\")) return \"skipped\";\n const h = r.hooks.findIndex((s) => s !== \"passed\");\n if (h >= 0) return `${r.hooks[h]} in a before/after hook`;\n return `scenario status '${r.status ?? \"unknown\"}'`;\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" && v.trim().length > 0 ? v : undefined;\n}\n"]}
@@ -15,7 +15,8 @@
15
15
  */
16
16
  import { existsSync } from "node:fs";
17
17
  import { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
18
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
+ import { basename, dirname, join, resolve } from "node:path";
19
+ import { isPathInside } from "../kernel/path-safety.js";
19
20
  import { sameBytes, toBytes } from "./writes.js";
20
21
  /**
21
22
  * The target changed between the pre-image staging read and the swap.
@@ -169,20 +170,11 @@ async function discardStaged(staged) {
169
170
  await quietRm(s.tmp);
170
171
  const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir));
171
172
  for (const s of [...staged].reverse()) {
172
- const root = roots.find((candidate) => contains(candidate, s.write.path));
173
+ const root = roots.find((candidate) => isPathInside(candidate, s.write.path));
173
174
  if (root !== undefined)
174
175
  await quietPruneEmptyParents(dirname(s.write.path), root);
175
176
  }
176
177
  }
177
- function contains(parent, candidate) {
178
- const rel = relative(parent, resolve(candidate));
179
- return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
180
- }
181
- /**
182
- * Undo the directories a write had to create, from `start` up to and including
183
- * `boundary`. Remove only directories that are still EMPTY: anything else in
184
- * there arrived after we made it, and belongs to whoever put it there.
185
- */
186
178
  /** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */
187
179
  export async function quietRmdir(dir) {
188
180
  try {
@@ -192,10 +184,16 @@ export async function quietRmdir(dir) {
192
184
  // Not empty, or not ours.
193
185
  }
194
186
  }
187
+ /**
188
+ * Undo the directories a write had to create, from `start` up to and including
189
+ * `boundary` — or, for unarchive, the ones a restore's deletes left empty.
190
+ * Remove only directories that are still EMPTY: anything else in there arrived
191
+ * after we made it, and belongs to whoever put it there.
192
+ */
195
193
  export async function quietPruneEmptyParents(start, boundary) {
196
194
  let cursor = resolve(start);
197
195
  const root = resolve(boundary);
198
- while (contains(root, cursor)) {
196
+ while (isPathInside(root, cursor)) {
199
197
  try {
200
198
  await rmdir(cursor);
201
199
  }
@@ -1 +1 @@
1
- {"version":3,"file":"commit.js","sourceRoot":"","sources":["../../../src/core/staging/commit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC/F,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AACxF,OAAO,EAAE,SAAS,EAAE,OAAO,EAAuC,MAAM,aAAa,CAAC;AAEtF;;;;;;;;GAQG;AACH,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAGpB,IAAI;IAFP,IAAI,GAAG,kBAAkB,CAAC;IAE5C,YAAqB,IAAY;QAC/B,KAAK,CAAC,GAAG,IAAI,kFAAkF,CAAC,CAAC;oBAD9E,IAAI;IAEzB,CAAC;CACF;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAsB;IACtD,MAAM,MAAM,GAAkB,EAAE,CAAC;IACjC,IAAI,CAAC;QACH,KAAK,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,EAAE,EAAE,CAAC;YAC1C,wEAAwE;YACxE,oEAAoE;YACpE,MAAM,UAAU,GAAG,CAAC,MAAM,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,CAAC;YACnF,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAC1E,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YACvC,IAAI,GAAG,GAAkB,IAAI,CAAC;YAC9B,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;gBACrB,GAAG,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;gBAC9B,MAAM,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAChC,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3E,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC;QAC5B,MAAM,GAAG,CAAC;IACZ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,MAAqB;IACpD,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC3E,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC;YAAE,MAAM,IAAI,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACxE,IAAI,CAAC,CAAC,GAAG,KAAK,IAAI,EAAE,CAAC;YACnB,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI;gBAAE,MAAM,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpD,CAAC;aAAM,IAAI,CAAC,CAAC,KAAK,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;YACtC,2EAA2E;YAC3E,2EAA2E;YAC3E,MAAM,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAChC,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC;YACjB,MAAM,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC;YACb,SAAS;QACX,CAAC;aAAM,CAAC;YACN,MAAM,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,CAAC;QACD,CAAC,CAAC,OAAO,GAAG,IAAI,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,MAAqB;IACxD,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QACtC,IAAI,CAAC,CAAC,CAAC,OAAO;YAAE,SAAS;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAC3E,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/B,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,IAAI,sEAAsE,CAAC,CAAC;gBACrG,SAAS;YACX,CAAC;YACD,IAAI,CAAC,CAAC,MAAM,KAAK,IAAI;gBAAE,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;;gBAC1D,MAAM,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;YAC/C,CAAC,CAAC,OAAO,GAAG,KAAK,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACrD,CAAC;IACH,CAAC;IACD,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5B,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,2EAA2E;AAC3E,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,OAAe;IAC7D,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC9B,MAAM,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,CAAS;IAC9C,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;AAC9F,CAAC;AAED,0FAA0F;AAC1F,MAAM,CAAC,MAAM,YAAY,GAAG,+BAA+B,CAAC;AAE5D;;;;;;;;;;GAUG;AACH,KAAK,UAAU,aAAa,CAAC,MAAqB;IAChD,KAAK,MAAM,CAAC,IAAI,MAAM;QAAE,IAAI,CAAC,CAAC,GAAG,KAAK,IAAI;YAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACjE,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,KAAK,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,UAAW,CAAC,CAAC,CAAC;IAC7F,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;QAC1E,IAAI,IAAI,KAAK,SAAS;YAAE,MAAM,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,CAAC;IACpF,CAAC;AACH,CAAC;AAED,SAAS,QAAQ,CAAC,MAAc,EAAE,SAAiB;IACjD,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;IACjD,OAAO,GAAG,KAAK,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,GAAG,EAAE,CAAC,IAAI,GAAG,KAAK,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC;AACzF,CAAC;AAED;;;;GAIG;AACH,qGAAqG;AACrG,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAW;IAC1C,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;IACnB,CAAC;IAAC,MAAM,CAAC;QACP,0BAA0B;IAC5B,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,sBAAsB,CAAC,KAAa,EAAE,QAAgB;IAC1E,IAAI,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC/B,OAAO,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;QAC9B,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;QAAC,MAAM,CAAC;YACP,OAAO;QACT,CAAC;QACD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO;QAC5B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;QAC/B,IAAI,MAAM,KAAK,MAAM;YAAE,OAAO;QAC9B,MAAM,GAAG,MAAM,CAAC;IAClB,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAY;IACxC,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,+DAA+D;IACjE,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,GAAY,EAAE,QAAkB,EAAE,IAAY;IAC5E,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,mDAAmD,IAAI,+BAA+B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AACpI,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,aAAa,CAAC,GAAY,EAAE,QAAkB;IAC5D,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,IAAI,KAAK,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,yDAAyD,CAAC,CAAC;IAC7F,CAAC;IACD,OAAO,IAAI,KAAK,CACd,eAAe,CAAC,GAAG,EAAE,QAAQ,EAAE,QAAQ,CAAC,CACzC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,GAAY;IAClC,OAAO,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;AAC1D,CAAC","sourcesContent":["/**\n * The commit itself, in three steps: stage every new version beside its\n * target, swap them in, roll back what already went in when a later step fails.\n *\n * This is what `archive` and `unarchive` share, and it moved out of archive.ts\n * verbatim once unarchive became its second consumer. The MERGE computations\n * stayed with archive, which is still their only caller — the split was about a\n * second CONSUMER, never about archive being long.\n *\n * `StagingRaceError` belongs here rather than with the lock, though both are\n * about a second writer: the lock is taken before a plan is computed, while\n * this is the compare-and-set immediately before each rename — the second line\n * of defence for exactly the window two racers who both broke the same stale\n * lock would land in.\n */\nimport { existsSync } from \"node:fs\";\nimport { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, isAbsolute, join, relative, resolve, sep } from \"node:path\";\nimport { sameBytes, toBytes, type PlannedWrite, type StagedWrite } from \"./writes.js\";\n\n/**\n * The target changed between the pre-image staging read and the swap.\n *\n * The pre-image is what a rollback restores and what `unarchive` is handed, so\n * swapping over somebody else's newer bytes would both lose their work and\n * arm a rollback that writes a third version nobody wrote. Commands map this to\n * `merge-failed`: nothing of theirs went in, the docs are as the other writer\n * left them, and re-running can work.\n */\nexport class StagingRaceError extends Error {\n override readonly name = \"StagingRaceError\";\n\n constructor(readonly path: string) {\n super(`${path} changed while this command was planning its merge — nothing was written; re-run`);\n }\n}\n\n/**\n * Write every new version to a temp file beside its target, remembering what the\n * target said first. Nothing observable changes here: on failure the temp files\n * are removed and the docs are exactly as they were.\n *\n * The pre-image is read eagerly and deliberately: it is what a rollback restores\n * and what `unarchive` is given, so a file we cannot read is a file we cannot\n * safely rewrite, and the merge must stop before it starts.\n */\nexport async function stageWrites(writes: PlannedWrite[]): Promise<StagedWrite[]> {\n const staged: StagedWrite[] = [];\n try {\n for (const [i, write] of writes.entries()) {\n // mkdir(recursive) reports the topmost directory it had to create — the\n // handle a rollback needs to put the tree back the way it found it.\n const createdDir = (await mkdir(dirname(write.path), { recursive: true })) ?? null;\n const before = existsSync(write.path) ? await readFile(write.path) : null;\n const content = toBytes(write.content);\n let tmp: string | null = null;\n if (content !== null) {\n tmp = tempPath(write.path, i);\n await writeFile(tmp, content);\n }\n staged.push({ write, content, tmp, before, createdDir, swapped: false });\n }\n } catch (err) {\n await discardStaged(staged);\n throw err;\n }\n return staged;\n}\n\n/**\n * Swap the staged versions in, one file at a time. Each swap is a rename(2)\n * within a single directory, so a concurrent reader sees either the old bytes or\n * the new ones — never a half-written file. Across files it is not atomic: the\n * caller rolls back what has already gone in.\n *\n * Every swap re-reads its target first and compares it with the pre-image the\n * plan was computed from — a compare-and-set on the file's whole content. It is\n * half a CAS on purpose: read and rename cannot be one syscall, so a writer\n * landing inside that window still wins. What it does buy is the window that\n * actually matters — the seconds a plan spends parsing LikeC4 and merging YAML\n * between the pre-image read and this swap — and it costs one read per file.\n */\nexport async function swapStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.before)) throw new StagingRaceError(s.write.path);\n if (s.tmp === null) {\n if (s.before !== null) await unlink(s.write.path);\n } else if (s.write.exclusive === true) {\n // link(2) is the same-filesystem, atomic no-clobber counterpart to rename:\n // it fails with EEXIST instead of replacing a file created after planning.\n await link(s.tmp, s.write.path);\n s.swapped = true;\n await unlink(s.tmp);\n s.tmp = null;\n continue;\n } else {\n await rename(s.tmp, s.write.path);\n }\n s.swapped = true;\n }\n}\n\n/**\n * Put back every file that was already swapped, newest first, from the bytes read\n * before the swap. Returns the paths it could NOT restore — the caller must say so\n * out loud rather than report a clean failure over a half-merged repo.\n *\n * A rollback only ever un-does ITS OWN write: if the file no longer holds the\n * bytes this run put there, somebody else has written it since, and restoring\n * the pre-image would destroy their work while reporting a clean failure. That\n * file is left exactly as found and named in the failures, which is what turns\n * the caller's answer from `merge-failed` (\"trust the repo\") into\n * `rollback-incomplete` (\"look at it by hand\") — the honest answer, because the\n * repo now holds one run's writes half-reverted next to another's.\n */\nexport async function rollbackStaged(staged: StagedWrite[]): Promise<string[]> {\n const failures: string[] = [];\n for (const s of [...staged].reverse()) {\n if (!s.swapped) continue;\n try {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.content)) {\n failures.push(`${s.write.path} (changed by another writer after this run wrote it — left as found)`);\n continue;\n }\n if (s.before === null) await rm(s.write.path, { force: true });\n else await atomicWrite(s.write.path, s.before);\n s.swapped = false;\n } catch (err) {\n failures.push(`${s.write.path} (${message(err)})`);\n }\n }\n await discardStaged(staged);\n return failures;\n}\n\n/** Write `content` to `path` through a temp file in the same directory. */\nexport async function atomicWrite(path: string, content: Buffer): Promise<void> {\n const tmp = tempPath(path, 0);\n await writeFile(tmp, content);\n await rename(tmp, path);\n}\n\n/** A hidden sibling of `path`: same directory, so the rename never crosses a filesystem. */\nexport function tempPath(path: string, n: number): string {\n return join(dirname(path), `.${basename(path)}.loam-${process.pid}-${n}-${Date.now()}.tmp`);\n}\n\n/** The shape `tempPath` writes — what a scan for leftovers of a killed run matches on. */\nexport const TEMP_FILE_RE = /^\\..*\\.loam-\\d+-\\d+-\\d+\\.tmp$/;\n\n/**\n * Everything staging left on disk: the temp files, and the directories it made\n * for them.\n *\n * Directories go by rmdir, never by recursive remove, and the walk stops the\n * moment one is not empty. The recursive form used to be reached for because\n * \"we created this directory, so it is ours\" — but a directory created at plan\n * time is shared by the time the rollback runs: a second archive can have put a\n * whole feature inside `features/archive/` in between, and `rm -r` on our own\n * created root took their work with it, silently, on OUR failure path.\n */\nasync function discardStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) if (s.tmp !== null) await quietRm(s.tmp);\n const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir!));\n for (const s of [...staged].reverse()) {\n const root = roots.find((candidate) => contains(candidate, s.write.path));\n if (root !== undefined) await quietPruneEmptyParents(dirname(s.write.path), root);\n }\n}\n\nfunction contains(parent: string, candidate: string): boolean {\n const rel = relative(parent, resolve(candidate));\n return rel === \"\" || (!rel.startsWith(`..${sep}`) && rel !== \"..\" && !isAbsolute(rel));\n}\n\n/**\n * Undo the directories a write had to create, from `start` up to and including\n * `boundary`. Remove only directories that are still EMPTY: anything else in\n * there arrived after we made it, and belongs to whoever put it there.\n */\n/** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */\nexport async function quietRmdir(dir: string): Promise<void> {\n try {\n await rmdir(dir);\n } catch {\n // Not empty, or not ours.\n }\n}\n\nexport async function quietPruneEmptyParents(start: string, boundary: string): Promise<void> {\n let cursor = resolve(start);\n const root = resolve(boundary);\n while (contains(root, cursor)) {\n try {\n await rmdir(cursor);\n } catch {\n return;\n }\n if (cursor === root) return;\n const parent = dirname(cursor);\n if (parent === cursor) return;\n cursor = parent;\n }\n}\n\nexport async function quietRm(path: string): Promise<void> {\n try {\n await rm(path, { recursive: true, force: true });\n } catch {\n // Best effort — a leftover temp file is noise, not corruption.\n }\n}\n\n/**\n * The one spelling of the sentence a rollback failure prints, parameterised\n * only by the verb for what the files were mid-way through becoming. It\n * existed in three near-copies (archive's \"half-merged\", vouch's\n * \"half-stamped\", verify's \"half-written\") — the exact shape whose fix lands\n * in one copy while an operator greps their logs for another.\n */\nexport function rollbackMessage(err: unknown, failures: string[], what: string): string {\n return `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-${what} and need checking by hand: ${failures.join(\", \")}`;\n}\n\n/** Say what the failure cost: nothing, or a repo that needs looking at by hand. */\nexport function rollbackError(err: unknown, failures: string[]): Error {\n if (failures.length === 0) {\n return new Error(`${message(err)} — the living docs were rolled back, nothing was merged`);\n }\n return new Error(\n rollbackMessage(err, failures, \"merged\"),\n );\n}\n\nexport function message(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n"]}
1
+ {"version":3,"file":"commit.js","sourceRoot":"","sources":["../../../src/core/staging/commit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The commit itself, in three steps: stage every new version beside its\n * target, swap them in, roll back what already went in when a later step fails.\n *\n * This is what `archive` and `unarchive` share, and it moved out of archive.ts\n * verbatim once unarchive became its second consumer. The MERGE computations\n * stayed with archive, which is still their only caller — the split was about a\n * second CONSUMER, never about archive being long.\n *\n * `StagingRaceError` belongs here rather than with the lock, though both are\n * about a second writer: the lock is taken before a plan is computed, while\n * this is the compare-and-set immediately before each rename — the second line\n * of defence for exactly the window two racers who both broke the same stale\n * lock would land in.\n */\nimport { existsSync } from \"node:fs\";\nimport { link, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { isPathInside } from \"../kernel/path-safety.js\";\nimport { sameBytes, toBytes, type PlannedWrite, type StagedWrite } from \"./writes.js\";\n\n/**\n * The target changed between the pre-image staging read and the swap.\n *\n * The pre-image is what a rollback restores and what `unarchive` is handed, so\n * swapping over somebody else's newer bytes would both lose their work and\n * arm a rollback that writes a third version nobody wrote. Commands map this to\n * `merge-failed`: nothing of theirs went in, the docs are as the other writer\n * left them, and re-running can work.\n */\nexport class StagingRaceError extends Error {\n override readonly name = \"StagingRaceError\";\n\n constructor(readonly path: string) {\n super(`${path} changed while this command was planning its merge — nothing was written; re-run`);\n }\n}\n\n/**\n * Write every new version to a temp file beside its target, remembering what the\n * target said first. Nothing observable changes here: on failure the temp files\n * are removed and the docs are exactly as they were.\n *\n * The pre-image is read eagerly and deliberately: it is what a rollback restores\n * and what `unarchive` is given, so a file we cannot read is a file we cannot\n * safely rewrite, and the merge must stop before it starts.\n */\nexport async function stageWrites(writes: PlannedWrite[]): Promise<StagedWrite[]> {\n const staged: StagedWrite[] = [];\n try {\n for (const [i, write] of writes.entries()) {\n // mkdir(recursive) reports the topmost directory it had to create — the\n // handle a rollback needs to put the tree back the way it found it.\n const createdDir = (await mkdir(dirname(write.path), { recursive: true })) ?? null;\n const before = existsSync(write.path) ? await readFile(write.path) : null;\n const content = toBytes(write.content);\n let tmp: string | null = null;\n if (content !== null) {\n tmp = tempPath(write.path, i);\n await writeFile(tmp, content);\n }\n staged.push({ write, content, tmp, before, createdDir, swapped: false });\n }\n } catch (err) {\n await discardStaged(staged);\n throw err;\n }\n return staged;\n}\n\n/**\n * Swap the staged versions in, one file at a time. Each swap is a rename(2)\n * within a single directory, so a concurrent reader sees either the old bytes or\n * the new ones — never a half-written file. Across files it is not atomic: the\n * caller rolls back what has already gone in.\n *\n * Every swap re-reads its target first and compares it with the pre-image the\n * plan was computed from — a compare-and-set on the file's whole content. It is\n * half a CAS on purpose: read and rename cannot be one syscall, so a writer\n * landing inside that window still wins. What it does buy is the window that\n * actually matters — the seconds a plan spends parsing LikeC4 and merging YAML\n * between the pre-image read and this swap — and it costs one read per file.\n */\nexport async function swapStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.before)) throw new StagingRaceError(s.write.path);\n if (s.tmp === null) {\n if (s.before !== null) await unlink(s.write.path);\n } else if (s.write.exclusive === true) {\n // link(2) is the same-filesystem, atomic no-clobber counterpart to rename:\n // it fails with EEXIST instead of replacing a file created after planning.\n await link(s.tmp, s.write.path);\n s.swapped = true;\n await unlink(s.tmp);\n s.tmp = null;\n continue;\n } else {\n await rename(s.tmp, s.write.path);\n }\n s.swapped = true;\n }\n}\n\n/**\n * Put back every file that was already swapped, newest first, from the bytes read\n * before the swap. Returns the paths it could NOT restore — the caller must say so\n * out loud rather than report a clean failure over a half-merged repo.\n *\n * A rollback only ever un-does ITS OWN write: if the file no longer holds the\n * bytes this run put there, somebody else has written it since, and restoring\n * the pre-image would destroy their work while reporting a clean failure. That\n * file is left exactly as found and named in the failures, which is what turns\n * the caller's answer from `merge-failed` (\"trust the repo\") into\n * `rollback-incomplete` (\"look at it by hand\") — the honest answer, because the\n * repo now holds one run's writes half-reverted next to another's.\n */\nexport async function rollbackStaged(staged: StagedWrite[]): Promise<string[]> {\n const failures: string[] = [];\n for (const s of [...staged].reverse()) {\n if (!s.swapped) continue;\n try {\n const now = existsSync(s.write.path) ? await readFile(s.write.path) : null;\n if (!sameBytes(now, s.content)) {\n failures.push(`${s.write.path} (changed by another writer after this run wrote it — left as found)`);\n continue;\n }\n if (s.before === null) await rm(s.write.path, { force: true });\n else await atomicWrite(s.write.path, s.before);\n s.swapped = false;\n } catch (err) {\n failures.push(`${s.write.path} (${message(err)})`);\n }\n }\n await discardStaged(staged);\n return failures;\n}\n\n/** Write `content` to `path` through a temp file in the same directory. */\nexport async function atomicWrite(path: string, content: Buffer): Promise<void> {\n const tmp = tempPath(path, 0);\n await writeFile(tmp, content);\n await rename(tmp, path);\n}\n\n/** A hidden sibling of `path`: same directory, so the rename never crosses a filesystem. */\nexport function tempPath(path: string, n: number): string {\n return join(dirname(path), `.${basename(path)}.loam-${process.pid}-${n}-${Date.now()}.tmp`);\n}\n\n/** The shape `tempPath` writes — what a scan for leftovers of a killed run matches on. */\nexport const TEMP_FILE_RE = /^\\..*\\.loam-\\d+-\\d+-\\d+\\.tmp$/;\n\n/**\n * Everything staging left on disk: the temp files, and the directories it made\n * for them.\n *\n * Directories go by rmdir, never by recursive remove, and the walk stops the\n * moment one is not empty. The recursive form used to be reached for because\n * \"we created this directory, so it is ours\" — but a directory created at plan\n * time is shared by the time the rollback runs: a second archive can have put a\n * whole feature inside `features/archive/` in between, and `rm -r` on our own\n * created root took their work with it, silently, on OUR failure path.\n */\nasync function discardStaged(staged: StagedWrite[]): Promise<void> {\n for (const s of staged) if (s.tmp !== null) await quietRm(s.tmp);\n const roots = staged.filter((s) => s.createdDir !== null).map((s) => resolve(s.createdDir!));\n for (const s of [...staged].reverse()) {\n const root = roots.find((candidate) => isPathInside(candidate, s.write.path));\n if (root !== undefined) await quietPruneEmptyParents(dirname(s.write.path), root);\n }\n}\n\n/** Remove `dir` if it is empty. Best effort — a directory left standing is noise, not corruption. */\nexport async function quietRmdir(dir: string): Promise<void> {\n try {\n await rmdir(dir);\n } catch {\n // Not empty, or not ours.\n }\n}\n\n/**\n * Undo the directories a write had to create, from `start` up to and including\n * `boundary` — or, for unarchive, the ones a restore's deletes left empty.\n * Remove only directories that are still EMPTY: anything else in there arrived\n * after we made it, and belongs to whoever put it there.\n */\nexport async function quietPruneEmptyParents(start: string, boundary: string): Promise<void> {\n let cursor = resolve(start);\n const root = resolve(boundary);\n while (isPathInside(root, cursor)) {\n try {\n await rmdir(cursor);\n } catch {\n return;\n }\n if (cursor === root) return;\n const parent = dirname(cursor);\n if (parent === cursor) return;\n cursor = parent;\n }\n}\n\nexport async function quietRm(path: string): Promise<void> {\n try {\n await rm(path, { recursive: true, force: true });\n } catch {\n // Best effort — a leftover temp file is noise, not corruption.\n }\n}\n\n/**\n * The one spelling of the sentence a rollback failure prints, parameterised\n * only by the verb for what the files were mid-way through becoming. It\n * existed in three near-copies (archive's \"half-merged\", vouch's\n * \"half-stamped\", verify's \"half-written\") — the exact shape whose fix lands\n * in one copy while an operator greps their logs for another.\n */\nexport function rollbackMessage(err: unknown, failures: string[], what: string): string {\n return `${message(err)} — ROLLBACK INCOMPLETE, these files may be half-${what} and need checking by hand: ${failures.join(\", \")}`;\n}\n\n/** Say what the failure cost: nothing, or a repo that needs looking at by hand. */\nexport function rollbackError(err: unknown, failures: string[]): Error {\n if (failures.length === 0) {\n return new Error(`${message(err)} — the living docs were rolled back, nothing was merged`);\n }\n return new Error(\n rollbackMessage(err, failures, \"merged\"),\n );\n}\n\nexport function message(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n"]}
@@ -8,7 +8,7 @@
8
8
  * `lockIsStale` only reports it to `doctor`. They were written as twins and
9
9
  * stay together so they cannot drift into two definitions of a dead holder.
10
10
  */
11
- import { link, readFile, unlink, writeFile } from "node:fs/promises";
11
+ import { link, readFile, rename, writeFile } from "node:fs/promises";
12
12
  import { hostname } from "node:os";
13
13
  import { join } from "node:path";
14
14
  // Every `isRecord` below guards a record loam itself wrote and is reading back
@@ -61,18 +61,24 @@ export class DocsBusyError extends Error {
61
61
  * microsecond. A create that dies before its link leaves only a staging temp
62
62
  * `doctor` already names as litter. A holder that died without releasing would
63
63
  * otherwise wedge the repo forever, so a lock naming a pid on THIS host that
64
- * no longer exists is broken once and retried — two racers could both break
65
- * the same stale lock, which is why the pre-image check in `swapStaged`
66
- * remains the second line of defence rather than an optimisation.
64
+ * no longer exists is broken once and retried. Breaking removes only the
65
+ * instance that was judged dead (see `breakStaleLock`), never a successor's
66
+ * live lock. This comment used to accept that two racers could both break the
67
+ * same stale lock, trusting the pre-image check in `swapStaged` as the second
68
+ * line of defence. That check is still kept, and is still not an
69
+ * optimisation. But it guards only the swap. A second holder's RECOVERY reads
70
+ * the first holder's in-flight journal as an interrupted commit and sweeps
71
+ * its staged temps, and no compare-and-set sits in front of that.
67
72
  */
68
73
  /**
69
74
  * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with
70
75
  * pid and a millisecond timestamp, which is unique across processes and not
71
76
  * within one: two in-process acquirers in the same millisecond shared a temp
72
77
  * name, the first's cleanup deleted it under the second, and the second's
73
- * link(2) surfaced ENOENT as `internal`. A bare counter is module-level
74
- * mutable state, tolerated here because it feeds nothing but name uniqueness —
75
- * no behaviour ever branches on its value.
78
+ * link(2) surfaced ENOENT as `internal`. The private name a stale-lock break
79
+ * takes the lock to is salted from the same counter, for the same reason. A
80
+ * bare counter is module-level mutable state, tolerated here because it feeds
81
+ * nothing but name uniqueness — no behaviour ever branches on its value.
76
82
  */
77
83
  let lockAcquireSeq = 0;
78
84
  export async function acquireDocsLock(docsDir) {
@@ -166,20 +172,76 @@ function pidGone(pid) {
166
172
  return err.code === "ESRCH";
167
173
  }
168
174
  }
169
- /** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */
175
+ /**
176
+ * Remove a lock whose holder is a dead process on this same host, and only the
177
+ * lock INSTANCE that verdict was about. Returns whether it did.
178
+ *
179
+ * The verdict comes from a read, the removal acts on a path, and other writers
180
+ * move in between. The removal used to be a bare unlink(2) of the path. Under
181
+ * CPU load a waiter read the holder's payload, then the holder released
182
+ * NORMALLY and exited, then a third writer linked a fresh lock, and only then
183
+ * did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE
184
+ * lock. Two `loam gherkin` runs then shared one commit window, and the
185
+ * second's recovery read the first's in-flight journal as "interrupted" and
186
+ * swept its staged temps. The first's rename failed ENOENT
187
+ * (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each
188
+ * window on purpose).
189
+ *
190
+ * So the removal is bound to the judged instance, twice:
191
+ * - the lock is re-read AFTER the verdict and must still name the judged
192
+ * holder. A holder that released normally removed its file before it
193
+ * exited, so a dead holder's lock still in place can only be a crashed
194
+ * one's, which is the one lock a break is for;
195
+ * - it is then TAKEN by rename(2) to a private name, which exactly one of
196
+ * several concurrent breakers can win, and judged in hand. If what moved is
197
+ * not the judged instance (another waiter broke the same crashed lock first
198
+ * and linked its own in between), it goes back by link(2), which never
199
+ * overwrites.
200
+ * One gap stays open, between that take and the put-back. A third writer can
201
+ * link in it, and then the displaced lock stays down. Or the displaced holder
202
+ * can release in it, and then the put-back restores a lock nobody holds, which
203
+ * stands until that process exits. Either needs a crashed holder AND two
204
+ * breakers racing for its lock. The gap is named here rather than closed,
205
+ * because closing it needs a kernel lock (flock) that Node does not expose and
206
+ * loam will not add a dependency for.
207
+ */
170
208
  async function breakStaleLock(path) {
171
- const holder = await readLockHolder(path);
209
+ const judged = await readLockHolder(path);
172
210
  // Unreadable is deliberately NOT breakable: a lock we cannot understand is
173
211
  // the one case where guessing is worst. `doctor` reports it as damage.
174
- if (holder.kind !== "held" || holder.host !== hostname() || !pidGone(holder.pid))
212
+ if (judged.kind !== "held" || judged.host !== hostname() || !pidGone(judged.pid))
213
+ return false;
214
+ if (!sameHolder(await readLockHolder(path), judged))
175
215
  return false;
216
+ lockAcquireSeq += 1;
217
+ const taken = tempPath(path, lockAcquireSeq);
176
218
  try {
177
- await unlink(path);
178
- return true;
219
+ await rename(path, taken);
179
220
  }
180
221
  catch {
222
+ // Another breaker took it first, or the platform refused the rename (a
223
+ // Windows handle still open on it). Either way the next link(2) attempt
224
+ // decides, not this one.
181
225
  return false;
182
226
  }
227
+ if (sameHolder(await readLockHolder(taken), judged)) {
228
+ await quietRm(taken);
229
+ return true;
230
+ }
231
+ try {
232
+ await link(taken, path);
233
+ }
234
+ catch {
235
+ // The gap named above: a third writer linked in between, and putting the
236
+ // displaced lock back would overwrite a live one. It stays down. Its holder
237
+ // is unprotected from here, and nothing this process can do fixes that.
238
+ }
239
+ await quietRm(taken);
240
+ return false;
241
+ }
242
+ /** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */
243
+ function sameHolder(now, judged) {
244
+ return now.kind === "held" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;
183
245
  }
184
246
  export async function describeLock(path) {
185
247
  const holder = await readLockHolder(path);
@@ -1 +1 @@
1
- {"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, unlink, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried — two racers could both break\n * the same stale lock, which is why the pre-image check in `swapStaged`\n * remains the second line of defence rather than an optimisation.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. A bare counter is module-level\n * mutable state, tolerated here because it feeds nothing but name uniqueness —\n * no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/** Remove a lock whose holder is a dead process on this same host. Returns whether it did. */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const holder = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (holder.kind !== \"held\" || holder.host !== hostname() || !pidGone(holder.pid)) return false;\n try {\n await unlink(path);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
1
+ {"version":3,"file":"lock.js","sourceRoot":"","sources":["../../../src/core/staging/lock.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The docs repo's advisory lock: take it, describe its holder, and break it\n * when that holder is a pid on this host that no longer exists.\n *\n * A module of its own because it is the only rule here that spans the whole\n * fleet rather than one file, and because the two readers of a held lock have\n * to agree on what \"stale\" means — `breakStaleLock` acts on the answer,\n * `lockIsStale` only reports it to `doctor`. They were written as twins and\n * stay together so they cannot drift into two definitions of a dead holder.\n */\nimport { link, readFile, rename, writeFile } from \"node:fs/promises\";\nimport { hostname } from \"node:os\";\nimport { join } from \"node:path\";\n// Every `isRecord` below guards a record loam itself wrote and is reading back\n// — the lock, the snapshot manifest — whose fields are asked for on the very\n// next line, inside a REFUSAL path. `JSON.parse` answers with any JSON value,\n// not with an object, and that is where a thrown `TypeError` costs the most: it\n// does not make the command fail safely, it replaces a designed refusal with\n// `internal`.\nimport { isRecord } from \"../kernel/records.js\";\nimport { quietRm, tempPath } from \"./commit.js\";\n\n/** The lock file, in the docs repo root — one writer at a time across the whole fleet. */\nexport const DOCS_LOCK = \".loam-lock\";\n\n/**\n * How long a WAITING writer sits out a live holder before answering\n * `docs-busy`. One spelling for every writer that waits — including gherkin,\n * whose lock guards the SERVICE repo's emission root rather than the docs\n * repo: long enough to cover another writer's sub-second commit window with a\n * margin for a loaded host, bounded so a wedged lock cannot hang anyone.\n */\nexport const LOCK_WAIT_MS = 5_000;\n\n/** Another writer holds the docs repo. Commands map this to the `docs-busy` envelope code. */\nexport class DocsBusyError extends Error {\n override readonly name = \"DocsBusyError\";\n\n constructor(\n readonly lockPath: string,\n holder: string,\n ) {\n super(\n `another loam command is writing this docs repo (${holder}) — nothing was read or written. ` +\n `Wait for it to finish, or remove ${lockPath} if no such command is running.`,\n );\n }\n}\n\n/**\n * Take the docs repo's advisory lock for the whole plan+commit window, and\n * return the release.\n *\n * Advisory, and deliberately coarse: the destructive commands write the SAME\n * three shared files (a service spec, a service contract, the one landscape),\n * so two of them overlapping is not a merge to be resolved but a merge one of\n * them computed against a snapshot that no longer exists. Two archives used to\n * plan against the same landscape bytes and the second's splice silently\n * dropped the first's additions — exit 0 on both, and a `validate` that stayed\n * green because the surviving document was perfectly coherent.\n *\n * link(2) over a fully-written sibling is the primitive: as atomic as the\n * O_EXCL create it replaced, needs no daemon, and leaves a file a human can\n * read and delete — but unlike open-then-write, the lock file can never be\n * OBSERVED empty. `writeFile(path, …, \"wx\")` published existence first and\n * payload a syscall later, and in that window (or after a crash inside it) the\n * lock named no holder: `doctor` now grades exactly that shape as damage whose\n * fix is deletion, so the healthy path must never wear it, even for a\n * microsecond. A create that dies before its link leaves only a staging temp\n * `doctor` already names as litter. A holder that died without releasing would\n * otherwise wedge the repo forever, so a lock naming a pid on THIS host that\n * no longer exists is broken once and retried. Breaking removes only the\n * instance that was judged dead (see `breakStaleLock`), never a successor's\n * live lock. This comment used to accept that two racers could both break the\n * same stale lock, trusting the pre-image check in `swapStaged` as the second\n * line of defence. That check is still kept, and is still not an\n * optimisation. But it guards only the swap. A second holder's RECOVERY reads\n * the first holder's in-flight journal as an interrupted commit and sweeps\n * its staged temps, and no compare-and-set sits in front of that.\n */\n/**\n * Distinguishes concurrent acquires INSIDE one process. `tempPath` salts with\n * pid and a millisecond timestamp, which is unique across processes and not\n * within one: two in-process acquirers in the same millisecond shared a temp\n * name, the first's cleanup deleted it under the second, and the second's\n * link(2) surfaced ENOENT as `internal`. The private name a stale-lock break\n * takes the lock to is salted from the same counter, for the same reason. A\n * bare counter is module-level mutable state, tolerated here because it feeds\n * nothing but name uniqueness — no behaviour ever branches on its value.\n */\nlet lockAcquireSeq = 0;\n\nexport async function acquireDocsLock(docsDir: string): Promise<() => Promise<void>> {\n const path = join(docsDir, DOCS_LOCK);\n const payload = `${JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() })}\\n`;\n lockAcquireSeq += 1;\n const tmp = tempPath(path, lockAcquireSeq);\n await writeFile(tmp, payload);\n try {\n for (let attempt = 0; ; attempt += 1) {\n try {\n await link(tmp, path);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (attempt === 0 && (await breakStaleLock(path))) continue;\n throw new DocsBusyError(path, await describeLock(path));\n }\n let released = false;\n return async () => {\n if (released) return;\n released = true;\n await quietRm(path);\n };\n }\n } finally {\n await quietRm(tmp);\n }\n}\n\n/**\n * Take the docs lock, waiting out a live holder instead of refusing on first\n * contact. Same lock, different caller contract: `archive` refuses fast because\n * its holder may be mid-merge for seconds and its caller can re-run, but two\n * `verify --record` runs for different services of one feature are BOTH\n * supposed to land — the second must wait for the first's sub-second window and\n * then merge over the record it left, or one service's attestation is refused\n * for no reason a user can see. The wait is bounded: a holder that outlives\n * `waitMs` yields the same `DocsBusyError` the fast form throws, so the caller\n * maps it to `docs-busy` either way and nothing hangs on a wedged lock.\n */\nexport async function acquireDocsLockWaiting(docsDir: string, waitMs: number): Promise<() => Promise<void>> {\n const deadline = Date.now() + waitMs;\n for (;;) {\n try {\n return await acquireDocsLock(docsDir);\n } catch (err) {\n if (!(err instanceof DocsBusyError) || Date.now() >= deadline) throw err;\n }\n // Short enough that a released lock is picked up before a human notices;\n // long enough that the poll does not busy-spin the directory.\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\n/**\n * One read of the lock file, one interpretation, shared by every question\n * asked about it. These used to be four separate parse-and-guard copies\n * (`breakStaleLock`, `describeLock`, `lockIsStale`, `lockUnreadable`), each\n * catching to a different answer — which is how `doctor` came to read the file\n * three times in a row and report three different states of a lock that was\n * released between its reads.\n *\n * `absent` and `unreadable` are kept apart on purpose: a lock that is GONE is\n * the ordinary end of every command, while a lock that exists and cannot name\n * a holder — empty bytes from a crash between create and flush, JSON that is\n * not a record, a record with no string `host` or numeric `pid` — is damage:\n * `breakStaleLock` rightly refuses to guess about it, so nothing will ever\n * release it. A different host's lock is legible and simply not ours to judge.\n */\ntype LockHolder =\n | { kind: \"absent\" }\n | { kind: \"unreadable\" }\n | { kind: \"held\"; host: string; pid: number; at: string | undefined };\n\nasync function readLockHolder(path: string): Promise<LockHolder> {\n let text: string;\n try {\n text = await readFile(path, \"utf8\");\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ENOENT\" ? { kind: \"absent\" } : { kind: \"unreadable\" };\n }\n let holder: unknown;\n try {\n holder = JSON.parse(text);\n } catch {\n return { kind: \"unreadable\" };\n }\n // A lock file holding `null` (or a bare string, or an array) is JSON this\n // parses and nothing else understands. The dereference below used to run\n // outside the guard in `acquireDocsLock`'s breaker and threw, replacing the\n // `docs-busy` refusal — the designed answer — with an `internal` envelope.\n if (!isRecord(holder) || typeof holder.host !== \"string\" || typeof holder.pid !== \"number\") {\n return { kind: \"unreadable\" };\n }\n return { kind: \"held\", host: holder.host, pid: holder.pid, at: typeof holder.at === \"string\" ? holder.at : undefined };\n}\n\n/** Does this pid no longer exist on this host? Signal 0 asks without delivering; EPERM is alive-but-somebody-else's. */\nfunction pidGone(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return false;\n } catch (err) {\n return (err as NodeJS.ErrnoException).code === \"ESRCH\";\n }\n}\n\n/**\n * Remove a lock whose holder is a dead process on this same host, and only the\n * lock INSTANCE that verdict was about. Returns whether it did.\n *\n * The verdict comes from a read, the removal acts on a path, and other writers\n * move in between. The removal used to be a bare unlink(2) of the path. Under\n * CPU load a waiter read the holder's payload, then the holder released\n * NORMALLY and exited, then a third writer linked a fresh lock, and only then\n * did kill(pid, 0) answer ESRCH. The unlink deleted that third writer's LIVE\n * lock. Two `loam gherkin` runs then shared one commit window, and the\n * second's recovery read the first's in-flight journal as \"interrupted\" and\n * swept its staged temps. The first's rename failed ENOENT\n * (test/stress-gherkin.test.ts found it; test/lock-break.test.ts opens each\n * window on purpose).\n *\n * So the removal is bound to the judged instance, twice:\n * - the lock is re-read AFTER the verdict and must still name the judged\n * holder. A holder that released normally removed its file before it\n * exited, so a dead holder's lock still in place can only be a crashed\n * one's, which is the one lock a break is for;\n * - it is then TAKEN by rename(2) to a private name, which exactly one of\n * several concurrent breakers can win, and judged in hand. If what moved is\n * not the judged instance (another waiter broke the same crashed lock first\n * and linked its own in between), it goes back by link(2), which never\n * overwrites.\n * One gap stays open, between that take and the put-back. A third writer can\n * link in it, and then the displaced lock stays down. Or the displaced holder\n * can release in it, and then the put-back restores a lock nobody holds, which\n * stands until that process exits. Either needs a crashed holder AND two\n * breakers racing for its lock. The gap is named here rather than closed,\n * because closing it needs a kernel lock (flock) that Node does not expose and\n * loam will not add a dependency for.\n */\nasync function breakStaleLock(path: string): Promise<boolean> {\n const judged = await readLockHolder(path);\n // Unreadable is deliberately NOT breakable: a lock we cannot understand is\n // the one case where guessing is worst. `doctor` reports it as damage.\n if (judged.kind !== \"held\" || judged.host !== hostname() || !pidGone(judged.pid)) return false;\n if (!sameHolder(await readLockHolder(path), judged)) return false;\n lockAcquireSeq += 1;\n const taken = tempPath(path, lockAcquireSeq);\n try {\n await rename(path, taken);\n } catch {\n // Another breaker took it first, or the platform refused the rename (a\n // Windows handle still open on it). Either way the next link(2) attempt\n // decides, not this one.\n return false;\n }\n if (sameHolder(await readLockHolder(taken), judged)) {\n await quietRm(taken);\n return true;\n }\n try {\n await link(taken, path);\n } catch {\n // The gap named above: a third writer linked in between, and putting the\n // displaced lock back would overwrite a live one. It stays down. Its holder\n // is unprotected from here, and nothing this process can do fixes that.\n }\n await quietRm(taken);\n return false;\n}\n\n/** Is `now` the very lock instance `judged` was read from? Pid, host and timestamp are its identity. */\nfunction sameHolder(now: LockHolder, judged: Extract<LockHolder, { kind: \"held\" }>): boolean {\n return now.kind === \"held\" && now.pid === judged.pid && now.host === judged.host && now.at === judged.at;\n}\n\nexport async function describeLock(path: string): Promise<string> {\n const holder = await readLockHolder(path);\n return holder.kind === \"held\" ? `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}` : `see ${path}`;\n}\n\n/**\n * The lock's whole state for `doctor`, from ONE read. Null means no lock.\n * Three sequential reads used to answer this — exists, describe, stale — and a\n * lock released between them made `doctor` describe a file that was gone,\n * escalating an ordinary sub-second commit window into a blocker.\n */\nexport async function lockResidue(path: string): Promise<{ holder: string; stale: boolean; unreadable: boolean } | null> {\n const holder = await readLockHolder(path);\n if (holder.kind === \"absent\") return null;\n if (holder.kind === \"unreadable\") return { holder: `see ${path}`, stale: false, unreadable: true };\n return {\n holder: `pid ${holder.pid} on ${holder.host} since ${String(holder.at)}`,\n stale: holder.host === hostname() && pidGone(holder.pid),\n unreadable: false,\n };\n}\n\n"]}
@@ -22,11 +22,8 @@ const COMMIT_INTENT_VERSION = 1;
22
22
  /**
23
23
  * Write the intent record and get it onto the disk before the first swap.
24
24
  *
25
- * fsync, not just write: the point of the record is to survive the crash that
26
- * interrupts the commit, and a record still sitting in the page cache when the
27
- * machine dies describes nothing. The directory entry is fsynced too where the
28
- * platform allows opening a directory — without that the file's bytes can be
29
- * durable while its name is not.
25
+ * fsync, not just write — through `writeDurable`, whose doc says why: the
26
+ * point of the record is to survive the crash that interrupts the commit.
30
27
  */
31
28
  export async function writeCommitIntent(docsDir, spec, staged) {
32
29
  const intent = {