openlanes 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. checksums.yaml +4 -4
  2. data/Brewfile +42 -0
  3. data/agents/bb8-resolver.md +49 -0
  4. data/agents/c3po-operator.md +4 -0
  5. data/agents/chewie-verifier.md +85 -0
  6. data/agents/hansolo-reviewer.md +20 -15
  7. data/agents/lando-broker.md +81 -19
  8. data/agents/leah-researcher.md +6 -6
  9. data/agents/luke-backend.md +41 -20
  10. data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
  11. data/agents/palpatine-risk-assessor.md +112 -0
  12. data/agents/r2d2-mechanic.md +5 -1
  13. data/agents/rey-frontend.md +38 -19
  14. data/agents/yoda-writer.md +7 -7
  15. data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
  16. data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
  17. data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
  18. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
  19. data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
  20. data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
  21. data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
  22. data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
  23. data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
  24. data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
  25. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
  26. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
  27. data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
  28. data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
  29. data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
  30. data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
  31. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
  32. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
  33. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
  34. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
  35. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
  36. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
  37. data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
  38. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
  39. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
  40. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
  41. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
  42. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
  43. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
  44. data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
  45. data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
  46. data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
  47. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
  48. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
  49. data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
  50. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
  51. data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
  52. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
  53. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
  54. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
  55. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  56. data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
  57. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
  58. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
  59. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
  60. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
  61. data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
  62. data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
  63. data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
  64. data/lib/openlanes/adapters/base.rb +18 -4
  65. data/lib/openlanes/adapters/capabilities.rb +29 -0
  66. data/lib/openlanes/adapters/claude.rb +14 -11
  67. data/lib/openlanes/adapters/codex.rb +9 -0
  68. data/lib/openlanes/adapters/pi.rb +10 -0
  69. data/lib/openlanes/adapters/stream.rb +145 -1
  70. data/lib/openlanes/adapters.rb +7 -0
  71. data/lib/openlanes/agents/agent.rb +4 -3
  72. data/lib/openlanes/agents/roster.rb +14 -2
  73. data/lib/openlanes/agents/routing.rb +43 -3
  74. data/lib/openlanes/apps/definition.rb +152 -0
  75. data/lib/openlanes/apps/framing.rb +114 -0
  76. data/lib/openlanes/apps/listing.rb +60 -0
  77. data/lib/openlanes/apps/ports.rb +168 -0
  78. data/lib/openlanes/apps/probe.rb +63 -0
  79. data/lib/openlanes/apps/process_spec.rb +39 -0
  80. data/lib/openlanes/apps/runner.rb +174 -0
  81. data/lib/openlanes/apps/status.rb +62 -0
  82. data/lib/openlanes/apps/supervisor.rb +277 -0
  83. data/lib/openlanes/apps/watcher.rb +50 -0
  84. data/lib/openlanes/apps.rb +41 -0
  85. data/lib/openlanes/cli/api/api.rb +10 -0
  86. data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
  87. data/lib/openlanes/cli/app/app.rb +31 -0
  88. data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
  89. data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
  90. data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
  91. data/lib/openlanes/cli/base.rb +89 -16
  92. data/lib/openlanes/cli/coverage/coverage.rb +68 -0
  93. data/lib/openlanes/cli/create/create.rb +11 -265
  94. data/lib/openlanes/cli/eval/eval.rb +4 -1
  95. data/lib/openlanes/cli/init/init.rb +39 -2
  96. data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
  97. data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
  98. data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
  99. data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
  100. data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
  101. data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
  102. data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
  103. data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
  104. data/lib/openlanes/cli/review/review.rb +72 -0
  105. data/lib/openlanes/cli/run/run.rb +22 -486
  106. data/lib/openlanes/cli/setup/setup.rb +51 -0
  107. data/lib/openlanes/cli/start/start.rb +7 -51
  108. data/lib/openlanes/cli/state/state.rb +1 -1
  109. data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
  110. data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
  111. data/lib/openlanes/cli/unblock/unblock.rb +11 -205
  112. data/lib/openlanes/cli/web/web.rb +84 -8
  113. data/lib/openlanes/cli/worktree/worktree.rb +1 -1
  114. data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
  115. data/lib/openlanes/cli.rb +37 -2
  116. data/lib/openlanes/coverage/cobertura.rb +49 -0
  117. data/lib/openlanes/coverage/config.rb +194 -0
  118. data/lib/openlanes/coverage/istanbul.rb +31 -0
  119. data/lib/openlanes/coverage/lcov.rb +38 -0
  120. data/lib/openlanes/coverage/report.rb +71 -0
  121. data/lib/openlanes/coverage/simplecov.rb +70 -0
  122. data/lib/openlanes/coverage.rb +33 -0
  123. data/lib/openlanes/engine/agent_locks.rb +231 -0
  124. data/lib/openlanes/engine/arbitration.rb +25 -0
  125. data/lib/openlanes/engine/board.rb +36 -5
  126. data/lib/openlanes/engine/conversions.rb +160 -0
  127. data/lib/openlanes/engine/coverage_gate.rb +217 -0
  128. data/lib/openlanes/engine/dispatcher.rb +1175 -117
  129. data/lib/openlanes/engine/event_journal/replay.rb +96 -0
  130. data/lib/openlanes/engine/event_journal.rb +148 -0
  131. data/lib/openlanes/engine/extensions.rb +271 -0
  132. data/lib/openlanes/engine/merging.rb +459 -0
  133. data/lib/openlanes/engine/narrator.rb +6 -6
  134. data/lib/openlanes/engine/plan_locks.rb +114 -0
  135. data/lib/openlanes/engine/pr_outcomes.rb +161 -0
  136. data/lib/openlanes/engine/pr_watch.rb +83 -0
  137. data/lib/openlanes/engine/progress_log.rb +12 -120
  138. data/lib/openlanes/engine/quality_question.rb +125 -0
  139. data/lib/openlanes/engine/rebase.rb +200 -0
  140. data/lib/openlanes/engine/run_workflow.rb +776 -0
  141. data/lib/openlanes/engine/runner.rb +186 -15
  142. data/lib/openlanes/engine/start_workflow.rb +77 -0
  143. data/lib/openlanes/engine/state_file.rb +101 -34
  144. data/lib/openlanes/engine/tally.rb +1 -1
  145. data/lib/openlanes/engine/verification/app_session.rb +104 -0
  146. data/lib/openlanes/engine/verification/driver.rb +314 -0
  147. data/lib/openlanes/engine/verification/outcome.rb +516 -0
  148. data/lib/openlanes/engine/verification/report.rb +95 -0
  149. data/lib/openlanes/engine/verification/round.rb +100 -0
  150. data/lib/openlanes/engine/verification/screenshots.rb +120 -0
  151. data/lib/openlanes/engine/verification.rb +57 -0
  152. data/lib/openlanes/engine.rb +30 -1
  153. data/lib/openlanes/execution/boundary.rb +212 -0
  154. data/lib/openlanes/execution/child.rb +4 -2
  155. data/lib/openlanes/execution/clock.rb +36 -6
  156. data/lib/openlanes/execution/control.rb +32 -0
  157. data/lib/openlanes/execution/executor.rb +114 -35
  158. data/lib/openlanes/execution/process_start.rb +63 -0
  159. data/lib/openlanes/execution/prompt.rb +223 -34
  160. data/lib/openlanes/execution/transcript.rb +159 -8
  161. data/lib/openlanes/execution.rb +21 -0
  162. data/lib/openlanes/lifecycle/adoption.rb +25 -8
  163. data/lib/openlanes/lifecycle/aging.rb +166 -0
  164. data/lib/openlanes/lifecycle/allocator.rb +216 -0
  165. data/lib/openlanes/lifecycle/brief.rb +32 -2
  166. data/lib/openlanes/lifecycle/completion.rb +12 -6
  167. data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
  168. data/lib/openlanes/lifecycle/creator.rb +17 -13
  169. data/lib/openlanes/lifecycle/lanes.rb +1 -1
  170. data/lib/openlanes/lifecycle/resync.rb +170 -42
  171. data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
  172. data/lib/openlanes/lifecycle/unblocker.rb +76 -2
  173. data/lib/openlanes/lifecycle.rb +37 -1
  174. data/lib/openlanes/linear/mapping.rb +4 -1
  175. data/lib/openlanes/linear/unit.rb +1 -1
  176. data/lib/openlanes/live/chat.rb +60 -11
  177. data/lib/openlanes/live/choice_converter.rb +166 -0
  178. data/lib/openlanes/live/choice_judge.rb +109 -0
  179. data/lib/openlanes/live/claims.rb +9 -1
  180. data/lib/openlanes/live/command.rb +13 -10
  181. data/lib/openlanes/live/commands.rb +217 -30
  182. data/lib/openlanes/live/copilot/slash.rb +82 -0
  183. data/lib/openlanes/live/copilot.rb +38 -4
  184. data/lib/openlanes/live/event_bus/base.rb +52 -0
  185. data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
  186. data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
  187. data/lib/openlanes/live/event_bus/transaction.rb +27 -0
  188. data/lib/openlanes/live/event_bus.rb +29 -0
  189. data/lib/openlanes/live/interviewer.rb +146 -0
  190. data/lib/openlanes/live/keys.rb +56 -0
  191. data/lib/openlanes/live/mail.rb +143 -0
  192. data/lib/openlanes/live/memory_store.rb +12 -1
  193. data/lib/openlanes/live/news.rb +61 -0
  194. data/lib/openlanes/live/node.rb +20 -0
  195. data/lib/openlanes/live/outage.rb +225 -0
  196. data/lib/openlanes/live/outage_report.rb +89 -0
  197. data/lib/openlanes/live/publisher.rb +42 -2
  198. data/lib/openlanes/live/run_record.rb +39 -1
  199. data/lib/openlanes/live/run_state.rb +19 -2
  200. data/lib/openlanes/live/session.rb +31 -3
  201. data/lib/openlanes/live/snapshot.rb +103 -42
  202. data/lib/openlanes/live/stations.rb +4 -4
  203. data/lib/openlanes/live/store.rb +18 -1
  204. data/lib/openlanes/live/subscriber.rb +22 -2
  205. data/lib/openlanes/live.rb +66 -1
  206. data/lib/openlanes/plans/blocked_file.rb +117 -0
  207. data/lib/openlanes/plans/choices.rb +228 -0
  208. data/lib/openlanes/plans/dependencies.rb +304 -0
  209. data/lib/openlanes/plans/feature.rb +75 -16
  210. data/lib/openlanes/plans/journal.rb +169 -0
  211. data/lib/openlanes/plans/last_known_state.rb +82 -0
  212. data/lib/openlanes/plans/lock.rb +171 -0
  213. data/lib/openlanes/plans/mailbox.rb +145 -4
  214. data/lib/openlanes/plans/plan_state.rb +590 -9
  215. data/lib/openlanes/plans/pull_request_machine.rb +163 -0
  216. data/lib/openlanes/plans/review_label.rb +139 -0
  217. data/lib/openlanes/plans/spec.rb +239 -3
  218. data/lib/openlanes/plans/state_machine.rb +63 -30
  219. data/lib/openlanes/plans/status.rb +60 -11
  220. data/lib/openlanes/plans/subject.rb +4 -0
  221. data/lib/openlanes/plans/tree.rb +101 -10
  222. data/lib/openlanes/plans/verdict.rb +124 -0
  223. data/lib/openlanes/plans.rb +19 -0
  224. data/lib/openlanes/presentation/api_docs.rb +76 -0
  225. data/lib/openlanes/presentation/documentation.rb +105 -12
  226. data/lib/openlanes/presentation/pr_changes.rb +90 -0
  227. data/lib/openlanes/presentation/reporter.rb +64 -5
  228. data/lib/openlanes/presentation.rb +20 -1
  229. data/lib/openlanes/setup/check.rb +16 -0
  230. data/lib/openlanes/setup/machine.rb +179 -0
  231. data/lib/openlanes/setup.rb +34 -0
  232. data/lib/openlanes/support/clock.rb +57 -0
  233. data/lib/openlanes/support/config.rb +318 -6
  234. data/lib/openlanes/support/log.rb +206 -0
  235. data/lib/openlanes/support/refusal.rb +15 -0
  236. data/lib/openlanes/support/runtime.rb +61 -0
  237. data/lib/openlanes/support/spawner.rb +51 -0
  238. data/lib/openlanes/support/ui.rb +39 -24
  239. data/lib/openlanes/vcs/ci_gate.rb +31 -0
  240. data/lib/openlanes/vcs/github.rb +362 -12
  241. data/lib/openlanes/vcs/label_migration.rb +132 -0
  242. data/lib/openlanes/vcs/label_sync.rb +75 -0
  243. data/lib/openlanes/vcs/publisher.rb +226 -8
  244. data/lib/openlanes/vcs/refresh.rb +337 -0
  245. data/lib/openlanes/vcs/rename_date.rb +83 -0
  246. data/lib/openlanes/vcs/review.rb +66 -0
  247. data/lib/openlanes/vcs/worktree.rb +167 -6
  248. data/lib/openlanes/vcs.rb +18 -0
  249. data/lib/openlanes/version.rb +1 -1
  250. data/lib/openlanes/web/admin_auth.rb +104 -0
  251. data/lib/openlanes/web/app.rb +115 -16
  252. data/lib/openlanes/web/context.rb +15 -9
  253. data/lib/openlanes/web/demo.rb +29 -14
  254. data/lib/openlanes/web/demo_board.rb +63 -5
  255. data/lib/openlanes/web/events.rb +86 -7
  256. data/lib/openlanes/web/launcher.rb +69 -7
  257. data/lib/openlanes/web/listen.rb +133 -0
  258. data/lib/openlanes/web/plan_files.rb +136 -0
  259. data/lib/openlanes/web/request_log.rb +108 -0
  260. data/lib/openlanes/web/server.rb +29 -10
  261. data/lib/openlanes/web.rb +6 -4
  262. data/lib/openlanes.rb +45 -7
  263. data/openlanes.gemspec +9 -4
  264. data/schemas/journal-event.schema.json +72 -0
  265. data/schemas/plan-state.schema.json +163 -2
  266. data/skills/openlanes/SKILL.md +2 -2
  267. data/skills/openlanes-documents/SKILL.md +4 -4
  268. data/skills/openlanes-documents/references/blocked-template.md +2 -2
  269. data/skills/openlanes-documents/references/closing-templates.md +1 -1
  270. data/skills/openlanes-documents/references/plan-template.md +12 -4
  271. data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
  272. data/skills/openlanes-run/SKILL.md +5 -5
  273. data/skills/openlanes-spec/SKILL.md +1 -1
  274. data/skills/openlanes-status/SKILL.md +1 -1
  275. data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
  276. data/web/dist/assets/index-6ZmW4slI.js +199 -0
  277. data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
  278. data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
  279. data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
  280. data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
  281. data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
  282. data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
  283. data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
  284. data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
  285. data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
  286. data/web/dist/assets/index-Cp86u27u.css +1 -0
  287. data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
  288. data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
  289. data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
  290. data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
  291. data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
  292. data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
  293. data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
  294. data/web/dist/index.html +2 -2
  295. data/web/src/api/fixtures/board.json +203 -28
  296. metadata +1053 -889
  297. data/bin/factory-e2e +0 -157
  298. data/bin/plan-number +0 -164
  299. data/bin/setup +0 -26
  300. data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
  301. data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
  302. data/web/dist/assets/index-BYJCWlk0.js +0 -198
  303. data/web/dist/assets/index-CjInBi5X.css +0 -1
  304. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
  305. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
  306. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
  307. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
  308. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
  309. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
  310. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
  311. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
  312. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
  313. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
  314. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
  315. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
  316. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
  317. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
  318. /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
  319. /data/exe/{depot → ol} +0 -0
data/lib/openlanes/cli.rb CHANGED
@@ -7,6 +7,7 @@ require "tty-screen"
7
7
  # the shared flags and plumbing, so it loads first; the rest only meet each
8
8
  # other here, at registration.
9
9
  require_relative "cli/base"
10
+ require_relative "cli/coverage/coverage"
10
11
  require_relative "cli/create/create"
11
12
  require_relative "cli/index/index"
12
13
  require_relative "cli/init/init"
@@ -19,6 +20,7 @@ require_relative "cli/linear/subcommands/import"
19
20
  require_relative "cli/docs/docs"
20
21
  require_relative "cli/eval/eval"
21
22
  require_relative "cli/run/run"
23
+ require_relative "cli/setup/setup"
22
24
  require_relative "cli/start/start"
23
25
  require_relative "cli/unblock/unblock"
24
26
  require_relative "cli/version/version"
@@ -30,10 +32,19 @@ require_relative "cli/worktree/worktree"
30
32
  require_relative "cli/mail/mail"
31
33
  require_relative "cli/mail/subcommands/send"
32
34
  require_relative "cli/mail/subcommands/read"
35
+ require_relative "cli/mail/subcommands/ack"
36
+ require_relative "cli/mail/subcommands/render"
33
37
  require_relative "cli/state/state"
34
38
  require_relative "cli/state/subcommands/sign"
35
39
  require_relative "cli/state/subcommands/show"
40
+ require_relative "cli/state/subcommands/resume"
36
41
  require_relative "cli/skills/subcommands/install"
42
+ require_relative "cli/api/api"
43
+ require_relative "cli/api/subcommands/docs"
44
+ require_relative "cli/app/app"
45
+ require_relative "cli/app/subcommands/start"
46
+ require_relative "cli/app/subcommands/status"
47
+ require_relative "cli/app/subcommands/stop"
37
48
 
38
49
  module OpenLanes
39
50
  # The command line. Every command is a thin shell over one library class:
@@ -85,7 +96,7 @@ module OpenLanes
85
96
  width [90, TTY::Screen.width - 2].min
86
97
 
87
98
  # Most used first: what a person types every day, then what scripts do.
88
- group "OpenLanes Default Web Operation", "init", "start", "web"
99
+ group "OpenLanes Default Web Operation", "setup", "init", "start", "web"
89
100
  group "Headless Operation",
90
101
  "create",
91
102
  "run",
@@ -93,15 +104,19 @@ module OpenLanes
93
104
  "index",
94
105
  "resync",
95
106
  "unblock",
107
+ "review",
96
108
  "worktree",
109
+ "coverage",
110
+ "app",
97
111
  "agents",
98
112
  "describe",
99
113
  "mail",
100
114
  "state",
101
115
  "eval"
102
- group "Reference", "docs", "states", "linear", "skills", "completion", "version"
116
+ group "Reference", "docs", "api", "states", "linear", "skills", "completion", "version"
103
117
  end
104
118
 
119
+ register "setup", Setup
105
120
  register "init", Init
106
121
  register "create", Create, aliases: %w[new c]
107
122
  # Renamed from `status`, which read as "is the tool OK?" rather than "what
@@ -112,8 +127,13 @@ module OpenLanes
112
127
  register "start", Start
113
128
  register "web", Web
114
129
  register "unblock", Unblock
130
+ register "review", Review
115
131
  register "eval", Eval
116
132
  register "worktree", Worktree
133
+ # Beside the command, not instead of it: dry-cli runs `worktree 002` as
134
+ # before, since "002" names no subcommand.
135
+ register "worktree prune", WorktreePrune
136
+ register "coverage", Coverage
117
137
  register "docs", Docs
118
138
  register "states", States, aliases: %w[diagram]
119
139
  register "index", Index, aliases: %w[idx]
@@ -143,6 +163,8 @@ module OpenLanes
143
163
  register "mail" do |prefix|
144
164
  prefix.register "send", Mail::Send
145
165
  prefix.register "read", Mail::Read
166
+ prefix.register "ack", Mail::Ack
167
+ prefix.register "render", Mail::Render
146
168
  end
147
169
 
148
170
  # What every agent shells out to at the start and end of its round. See
@@ -150,6 +172,7 @@ module OpenLanes
150
172
  register "state" do |prefix|
151
173
  prefix.register "sign", State::Sign
152
174
  prefix.register "show", State::Show
175
+ prefix.register "resume", State::Resume
153
176
  end
154
177
 
155
178
  # Copies the skills bundled with the gem; see {OpenLanes::Skills}.
@@ -157,6 +180,18 @@ module OpenLanes
157
180
  prefix.register "install", Skills::Install
158
181
  end
159
182
 
183
+ # Lists and checks the OpenAPI contracts plans carry; see {OpenLanes::Presentation::ApiDocs}.
184
+ register "api" do |prefix|
185
+ prefix.register "docs", API::Docs
186
+ end
187
+
188
+ # The app a plan builds, for the Preview tab; see {OpenLanes::Apps}.
189
+ register "app" do |prefix|
190
+ prefix.register "start", App::Start
191
+ prefix.register "status", App::Status
192
+ prefix.register "stop", App::Stop
193
+ end
194
+
160
195
  register "completion", ::Dry::CLI::Autocomplete::Command[::OpenLanes::CLI]
161
196
  end
162
197
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Coverage
5
+ # Cobertura XML. Only the root `<coverage>` element is read:
6
+ # `lines-covered` and `lines-valid` when both are there (gcovr writes
7
+ # them), else `line-rate`, a 0–1 decimal that some producers round.
8
+ #
9
+ # The root tag is matched rather than parsed with an XML library, because
10
+ # its attributes are the whole of what is needed and the gem has no XML
11
+ # dependency to spend on it.
12
+ module Cobertura
13
+ ROOT = /<coverage\b([^>]*)>/m
14
+
15
+ module_function
16
+
17
+ # @param text [String]
18
+ # @return [Array(Numeric, Numeric)] covered, relevant
19
+ # @raise [OpenLanes::Coverage::Malformed]
20
+ def parse(text)
21
+ root = text[ROOT, 1] or raise Malformed, "no <coverage> element"
22
+ attributes = root.scan(/([\w-]+)\s*=\s*["']([^"']*)["']/).to_h
23
+
24
+ covered, valid = attributes.values_at("lines-covered", "lines-valid")
25
+ return [whole(covered), whole(valid)] if covered && valid
26
+
27
+ rate = attributes["line-rate"] or raise Malformed, "<coverage> has neither lines-valid nor line-rate"
28
+ [rational(rate), 1]
29
+ end
30
+
31
+ # @return [Integer]
32
+ def whole(text)
33
+ Integer(text, 10)
34
+ rescue ArgumentError
35
+ raise Malformed, "#{text.inspect} is not a line count"
36
+ end
37
+
38
+ # @return [Rational]
39
+ def rational(text)
40
+ value = Rational(text)
41
+ raise Malformed, "line-rate #{text.inspect} is outside 0 to 1" unless value.between?(0, 1)
42
+
43
+ value
44
+ rescue ArgumentError, ZeroDivisionError
45
+ raise Malformed, "line-rate #{text.inspect} is not a number"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Coverage
5
+ # The `coverage:` section of a project's `.openlanes.yml` (M15, amended
6
+ # 2026-10-17). One of two shapes, never both:
7
+ #
8
+ # coverage: # the figure, printed by a command
9
+ # command: bundle exec rspec
10
+ # report_command: jq '.total.lines.percent' coverage/coverage.json
11
+ # minimum: 95
12
+ #
13
+ # coverage: # a report file, read by a parser here
14
+ # command: npm test -- --coverage
15
+ # report: coverage/lcov.info
16
+ # format: lcov # lcov | cobertura | istanbul | simplecov
17
+ # minimum: 95
18
+ #
19
+ # `command` and `minimum` are required in both. A key the gate ignored
20
+ # would be a promise nobody checks, which is the failure this section
21
+ # exists to end; so, as everywhere in `.openlanes.yml`, anything
22
+ # unexpected is an error naming the file and the section. A
23
+ # `report_command` beside a `format` is refused rather than read as one
24
+ # of them: which of the two the author meant is exactly what is unknown.
25
+ #
26
+ # It lives in the file a project already has rather than in a second one
27
+ # beside it (the first M15 had `.openlanes.coverage.yml`): one project's
28
+ # settings in two files is two places to look, and one gets forgotten.
29
+ #
30
+ # @!attribute [r] path
31
+ # @return [String] the `.openlanes.yml` it was read from
32
+ # @!attribute [r] command
33
+ # @return [String] run through `sh -c` in the checkout
34
+ # @!attribute [r] report
35
+ # @return [String, nil] relative to the checkout, as written; nil with
36
+ # a {#report_command}
37
+ # @!attribute [r] format
38
+ # @return [Symbol, nil] one of {FORMATS}; nil with a {#report_command}
39
+ # @!attribute [r] report_command
40
+ # @return [String, nil] run through `sh -c` in the checkout after
41
+ # {#command}; its STDOUT is the line percentage
42
+ # @!attribute [r] minimum
43
+ # @return [Float] the lowest line percentage that passes, 0 to 100
44
+ Config = Data.define(:path, :command, :report, :format, :report_command, :minimum) do
45
+ def initialize(report: nil, format: nil, report_command: nil, **rest) = super
46
+
47
+ # @param root [String] the checkout the report path is relative to
48
+ # @return [String, nil] absolute; nil with a {#report_command}
49
+ def report_path(root) = report && File.expand_path(report, root)
50
+
51
+ # @return [Boolean] whether the figure comes from {#report_command}
52
+ def report_command? = !report_command.nil?
53
+
54
+ # Where the figure comes from, as a verdict names it.
55
+ #
56
+ # @return [String]
57
+ def source = report_command? ? "report_command: `#{report_command}`" : "#{format}: #{report}"
58
+
59
+ # The section as `.openlanes.yml` holds it.
60
+ #
61
+ # @return [Hash{String => Object}]
62
+ def to_h
63
+ { "command" => command, "report" => report, "format" => format&.to_s,
64
+ "report_command" => report_command, "minimum" => minimum }.compact
65
+ end
66
+ end
67
+
68
+ class Config
69
+ # The section's key at the top of `.openlanes.yml`.
70
+ SECTION = "coverage"
71
+
72
+ # Report formats with a parser. Each reports lines (plan decision 1).
73
+ FORMATS = %i[lcov cobertura istanbul simplecov].freeze
74
+
75
+ # Every key the section may hold.
76
+ KEYS = %w[command report format report_command minimum].freeze
77
+
78
+ # Keys both shapes require.
79
+ REQUIRED = %w[command minimum].freeze
80
+
81
+ # The two shapes, as every error and the setup note spell them.
82
+ SHAPES = "report_command alone, or report with format"
83
+
84
+ # Where the first M15 kept this section. Never read: only the
85
+ # `coverage:` section counts. But a project that still has it would
86
+ # otherwise turn unconfigured without a word and be sent back for
87
+ # setup, so its presence is said once, as a warning.
88
+ LEGACY = ".openlanes.coverage.yml"
89
+
90
+ class << self
91
+ # @param root [String] a checkout
92
+ # @return [OpenLanes::Coverage::Config, nil] nil when its
93
+ # `.openlanes.yml` has no `coverage:` section, or there is no file
94
+ # @raise [OpenLanes::Coverage::Invalid] when the section, or the file
95
+ # around it, cannot be used
96
+ def load(root)
97
+ warn_legacy(root)
98
+ OpenLanes::Config.load(root).coverage
99
+ rescue Invalid
100
+ raise
101
+ rescue OpenLanes::Error => e
102
+ raise Invalid, e.message
103
+ end
104
+
105
+ # @param value [Object] what YAML gave for `coverage:`
106
+ # @param path [String] the `.openlanes.yml`, named in every error
107
+ # @return [OpenLanes::Coverage::Config]
108
+ # @raise [OpenLanes::Coverage::Invalid]
109
+ def parse(value, path:)
110
+ fail_with(path, ": must be a mapping with command, minimum, and #{SHAPES}") unless value.is_a?(Hash)
111
+
112
+ settings = value.transform_keys(&:to_s)
113
+ unknown = settings.keys - KEYS
114
+ fail_with(path, ": unknown key #{unknown.join(", ")}; known: #{KEYS.join(", ")}") unless unknown.empty?
115
+ missing = REQUIRED - settings.keys
116
+ fail_with(path, ": missing key #{missing.join(", ")}") unless missing.empty?
117
+ shape!(settings, path)
118
+
119
+ new(path:,
120
+ command: text_of(settings, "command", path),
121
+ minimum: minimum_of(settings["minimum"], path),
122
+ **source_of(settings, path))
123
+ end
124
+
125
+ private
126
+
127
+ # Once per file and process: the gate asks on every poll.
128
+ #
129
+ # @return [void]
130
+ def warn_legacy(root)
131
+ path = File.join(root, LEGACY)
132
+ @warned ||= Set.new
133
+ return unless File.exist?(path) && @warned.add?(path)
134
+
135
+ UI.log("#{path} is no longer read; move its settings under #{SECTION}: in .openlanes.yml", level: :warn)
136
+ end
137
+
138
+ # @param message [String] after the section's name: ": …" for the
139
+ # section, ".key: …" for one key, as the rest of `.openlanes.yml` reads
140
+ # @return [void] never returns
141
+ def fail_with(path, message) = raise(Invalid, "#{path}: #{SECTION}#{message}")
142
+
143
+ # Exactly one shape, said whole.
144
+ #
145
+ # @return [void]
146
+ def shape!(settings, path)
147
+ given = %w[report format report_command].select { settings.key?(it) }
148
+ return if [%w[report_command], %w[report format]].include?(given)
149
+
150
+ problem =
151
+ if given.empty? then "says nowhere to read the figure"
152
+ elsif given.include?("report_command") then "report_command replaces #{(given - %w[report_command]).join(" and ")}"
153
+ else "#{given.first} without #{(%w[report format] - given).first}"
154
+ end
155
+ fail_with(path, ": #{problem}; give #{SHAPES}")
156
+ end
157
+
158
+ # @return [Hash{Symbol => Object}]
159
+ def source_of(settings, path)
160
+ return { report_command: text_of(settings, "report_command", path) } if settings.key?("report_command")
161
+
162
+ { report: text_of(settings, "report", path), format: format_of(settings["format"], path) }
163
+ end
164
+
165
+ # @return [String]
166
+ def text_of(settings, key, path)
167
+ value = settings[key]
168
+ return value.strip if value.is_a?(String) && !value.strip.empty?
169
+
170
+ fail_with(path, ".#{key}: must be a non-empty string, not #{value.inspect}")
171
+ end
172
+
173
+ # @return [Symbol]
174
+ def format_of(value, path)
175
+ format = value.to_s.strip.downcase.to_sym
176
+ return format if FORMATS.include?(format)
177
+
178
+ fail_with(path, ".format: #{value.inspect} is not one of #{FORMATS.join(", ")}")
179
+ end
180
+
181
+ # A quoted `"95"` is refused rather than converted: YAML that types a
182
+ # number as a string was written by hand, and the hand may have meant
183
+ # something else.
184
+ #
185
+ # @return [Float]
186
+ def minimum_of(value, path)
187
+ return value.to_f if value.is_a?(Numeric) && value.between?(0, 100)
188
+
189
+ fail_with(path, ".minimum: #{value.inspect} is not a number from 0 to 100")
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Coverage
7
+ # Istanbul's `coverage-summary.json` (the `json-summary` reporter):
8
+ # `total.lines.covered` over `total.lines.total`.
9
+ #
10
+ # `pct` is never read. It is floored to two decimals, and istanbul writes
11
+ # 100 for a total of zero and `"Unknown"` for a blank summary, neither of
12
+ # which is a figure the gate can stand behind.
13
+ module Istanbul
14
+ module_function
15
+
16
+ # @param text [String]
17
+ # @return [Array(Integer, Integer)] covered, relevant
18
+ # @raise [OpenLanes::Coverage::Malformed]
19
+ # @raise [JSON::ParserError]
20
+ def parse(text)
21
+ lines = JSON.parse(text).dig("total", "lines") if text.strip.start_with?("{")
22
+ raise Malformed, "no total.lines" unless lines.is_a?(Hash)
23
+
24
+ covered, total = lines.values_at("covered", "total")
25
+ raise Malformed, "total.lines needs whole-number covered and total" unless [covered, total].all?(Integer)
26
+
27
+ [covered, total]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Coverage
5
+ # `lcov.info`: one `SF:` … `end_of_record` block per source file, each
6
+ # carrying `LF:` (instrumented lines) and `LH:` (lines hit).
7
+ #
8
+ # There is no total record, and the per-file percentages must not be
9
+ # averaged: a ten-line file at 0% and a thousand-line file at 100% are
10
+ # 99%, not 50%. The counts are summed instead. A block without `LF`/`LH`
11
+ # is counted from its `DA:` lines, which is what those two summarise.
12
+ module Lcov
13
+ module_function
14
+
15
+ # @param text [String]
16
+ # @return [Array(Integer, Integer)] covered, relevant
17
+ # @raise [OpenLanes::Coverage::Malformed] when text is present but holds
18
+ # no `SF:` record
19
+ def parse(text)
20
+ records = text.split(/^end_of_record\s*$/).grep(/^SF:/)
21
+ raise Malformed, "no SF: records" if records.empty? && !text.strip.empty?
22
+
23
+ records.map { tally(it) }.transpose.map(&:sum).then { it.empty? ? [0, 0] : it }
24
+ end
25
+
26
+ # @param record [String] one file's block
27
+ # @return [Array(Integer, Integer)]
28
+ def tally(record)
29
+ found = record[/^LF:(\d+)/, 1]
30
+ hit = record[/^LH:(\d+)/, 1]
31
+ return [Integer(hit), Integer(found)] if found && hit
32
+
33
+ hits = record.scan(/^DA:\d+,(-?\d+)/).flatten.map { Integer(it) }
34
+ [hits.count(&:positive?), hits.size]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Coverage
7
+ # One coverage report, reduced to the two counts the gate compares.
8
+ #
9
+ # Every way a report can fail to say anything (missing, unreadable,
10
+ # malformed, or present with nothing instrumented) is the same answer: 0%,
11
+ # with the reason kept in {#problem}. A gate that passed on "could not
12
+ # tell" would pass a project whose test command stopped writing its
13
+ # report, which is the easiest way to stop being measured.
14
+ #
15
+ # @!attribute [r] covered
16
+ # @return [Numeric] lines hit at least once
17
+ # @!attribute [r] relevant
18
+ # @return [Numeric] instrumented lines
19
+ # @!attribute [r] problem
20
+ # @return [String, nil] why the report counts as 0%, nil when it was read
21
+ Report = Data.define(:covered, :relevant, :problem) do
22
+ def initialize(covered: 0, relevant: 0, problem: nil) = super
23
+
24
+ # Computed from the counts, never from a figure a tool rounded: simplecov
25
+ # truncates to two decimals and istanbul floors, so 94.995 would read as
26
+ # a pass at 95.
27
+ #
28
+ # @return [Float] 0.0 to 100.0
29
+ def percent = ratio.to_f
30
+
31
+ # The percentage as the counts state it. A Float cannot hold most
32
+ # two-decimal figures (10.03 is 10.0299…), so anything that floors or
33
+ # compares reads this instead of {#percent}.
34
+ #
35
+ # @return [Rational] 0 to 100
36
+ def ratio
37
+ return 0r if problem || !relevant.positive?
38
+
39
+ covered * 100r / relevant
40
+ end
41
+
42
+ # @return [Boolean] whether the report said anything at all
43
+ def read? = problem.nil?
44
+ end
45
+
46
+ class Report
47
+ # The parser for each format. Each answers `parse(text)` with
48
+ # `[covered, relevant]`, or raises {Malformed}.
49
+ PARSERS = { lcov: "Lcov", cobertura: "Cobertura", istanbul: "Istanbul", simplecov: "Simplecov" }.freeze
50
+
51
+ class << self
52
+ # @param format [Symbol] one of {Config::FORMATS}
53
+ # @param path [String]
54
+ # @return [OpenLanes::Coverage::Report]
55
+ def read(format:, path:)
56
+ parser = Coverage.const_get(PARSERS.fetch(format.to_sym))
57
+ return new(problem: "#{path} is missing") unless File.exist?(path)
58
+
59
+ covered, relevant = parser.parse(File.read(path))
60
+ return new(covered:, relevant:, problem: "#{path} has no instrumented lines") unless relevant.positive?
61
+
62
+ new(covered:, relevant:)
63
+ rescue SystemCallError, IOError => e
64
+ new(problem: "#{path} cannot be read (#{e.message})")
65
+ rescue Malformed, JSON::ParserError => e
66
+ new(problem: "#{path} is not a #{format} report (#{e.message.lines.first.to_s.strip})")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Coverage
7
+ # SimpleCov's `coverage/.resultset.json`: per command (RSpec, and any
8
+ # suite merged in), per file, one hit count per source line, `nil` where a
9
+ # line is not code.
10
+ #
11
+ # Not `.last_run.json`: that one truncates to two decimals and is written
12
+ # only after a fully successful run (plan decision 3). The resultset is
13
+ # already filtered by the project's SimpleCov filters, so every file in it
14
+ # counts. It does not record `:nocov:` blocks, so this figure can come out
15
+ # lower than SimpleCov's own, never higher.
16
+ #
17
+ # {.lines_by_file} is public so `bin/coverage-changed` (PR #27), which
18
+ # judges each changed file, merges runs the same way the gate does.
19
+ module Simplecov
20
+ module_function
21
+
22
+ # @param text [String]
23
+ # @return [Array(Integer, Integer)] covered, relevant
24
+ # @raise [OpenLanes::Coverage::Malformed]
25
+ # @raise [JSON::ParserError]
26
+ def parse(text)
27
+ relevant = lines_by_file(text).values.flatten.compact
28
+ [relevant.count(&:positive?), relevant.size]
29
+ end
30
+
31
+ # Every command's counts merged line by line: a line hit by any run is
32
+ # covered, and a line is relevant when any run says it is code.
33
+ #
34
+ # @param text [String] the resultset
35
+ # @return [Hash{String => Array<Integer, nil>}] by the path as recorded
36
+ # @raise [OpenLanes::Coverage::Malformed]
37
+ # @raise [JSON::ParserError]
38
+ def lines_by_file(text)
39
+ runs = JSON.parse(text)
40
+ raise Malformed, "expected a mapping of command name to its run" unless runs.is_a?(Hash)
41
+
42
+ runs.each_with_object({}) do |(command, run), merged|
43
+ files = run.is_a?(Hash) && run["coverage"]
44
+ raise Malformed, "#{command} has no coverage mapping" unless files.is_a?(Hash)
45
+
46
+ files.each { |path, entry| merged[path] = merge(merged[path] || [], counts(path, entry)) }
47
+ end
48
+ end
49
+
50
+ # SimpleCov 0.18+ writes `{"lines": [...]}`; older versions the bare
51
+ # array. A non-integer count (`"ignored"`) is not code.
52
+ #
53
+ # @return [Array<Integer, nil>]
54
+ def counts(path, entry)
55
+ lines = entry.is_a?(Hash) ? entry["lines"] : entry
56
+ raise Malformed, "#{path} has no line counts" unless lines.is_a?(Array)
57
+
58
+ lines.map { it.is_a?(Integer) ? it : nil }
59
+ end
60
+
61
+ # @return [Array<Integer, nil>]
62
+ def merge(into, counts)
63
+ Array.new([into.size, counts.size].max) do |index|
64
+ a, b = into[index], counts[index]
65
+ a.nil? && b.nil? ? nil : a.to_i + b.to_i
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ # A project's declared test coverage (decision M15), the `coverage:`
5
+ # section of its `.openlanes.yml`: which command runs the suite, where the
6
+ # line figure comes from, and the lowest one that may merge.
7
+ #
8
+ # Pure, like {Plans}: it reads files and does arithmetic, and starts no
9
+ # process. Running either command belongs to {Engine::CoverageGate}, which
10
+ # is the only caller that turns a figure into a verdict.
11
+ module Coverage
12
+ # A `coverage:` section, or the `.openlanes.yml` around it, that cannot be
13
+ # honoured as written.
14
+ class Invalid < OpenLanes::Error; end
15
+
16
+ # A report present on disk that a parser cannot read.
17
+ class Malformed < OpenLanes::Error; end
18
+
19
+ module_function
20
+
21
+ # @param root [String] the checkout whose declaration is read
22
+ # @return [OpenLanes::Coverage::Config, nil] nil when its `.openlanes.yml`
23
+ # has no `coverage:` section
24
+ # @raise [OpenLanes::Coverage::Invalid]
25
+ def config(root) = Config.load(root)
26
+
27
+ # @param format [Symbol] one of {Config::FORMATS}
28
+ # @param path [String] the report
29
+ # @return [OpenLanes::Coverage::Report] never raises; a report that cannot
30
+ # be read is a {Report} at 0% with its problem named
31
+ def read(format:, path:) = Report.read(format:, path:)
32
+ end
33
+ end