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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /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
|