openlanes 3.0.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 +7 -0
- data/agents/hansolo-reviewer.md +59 -0
- data/agents/lando-broker.md +70 -0
- data/agents/leah-researcher.md +60 -0
- data/agents/luke-backend.md +73 -0
- data/agents/palpatine-planner.md +61 -0
- data/agents/r2d2-mechanic.md +57 -0
- data/agents/rey-frontend.md +68 -0
- data/agents/yoda-writer.md +67 -0
- data/bin/factory-e2e +157 -0
- data/bin/plan-number +164 -0
- data/bin/setup +26 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/lib/cart.rb +9 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/hansolo-reviewer/review-medium/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/review-medium.yml +60 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/blocked.md +9 -0
- data/evals/cases/lando-broker/unblock-fast/fail/plan/spec.md +16 -0
- data/evals/cases/lando-broker/unblock-fast/fail/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast/pass/plan/spec.md +18 -0
- data/evals/cases/lando-broker/unblock-fast/pass/run.json +1 -0
- data/evals/cases/lando-broker/unblock-fast.yml +44 -0
- data/evals/cases/leah-researcher/research-deep/fail/plan/spec.md +26 -0
- data/evals/cases/leah-researcher/research-deep/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep/pass/plan/spec.md +39 -0
- data/evals/cases/leah-researcher/research-deep/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-deep.yml +51 -0
- data/evals/cases/leah-researcher/research-fast/fail/plan/spec.md +27 -0
- data/evals/cases/leah-researcher/research-fast/fail/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast/pass/plan/spec.md +23 -0
- data/evals/cases/leah-researcher/research-fast/pass/run.json +1 -0
- data/evals/cases/leah-researcher/research-fast.yml +55 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/fail/repo/spec/cart_spec.rb +7 -0
- data/evals/cases/luke-backend/build-medium/fail/run.json +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-backend.md +7 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/plan.md +3 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/plan/spec.md +8 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/lib/cart.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/repo/spec/cart_spec.rb +11 -0
- data/evals/cases/luke-backend/build-medium/pass/run.json +1 -0
- data/evals/cases/luke-backend/build-medium.yml +68 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan-backend.md +6 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-backend.md +32 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/plan.md +28 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/plan/spec.md +14 -0
- data/evals/cases/palpatine-planner/plan-deep/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-deep.yml +50 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-backend.md +48 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/fail/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-backend.md +8 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan-frontend.md +1 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/plan.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/plan/spec.md +12 -0
- data/evals/cases/palpatine-planner/plan-fast/pass/run.json +1 -0
- data/evals/cases/palpatine-planner/plan-fast.yml +47 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter/formatter.rb +12 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/lib/greeter.rb +15 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/README.md +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/lib/greeter.rb +3 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/repo/spec/greeter_spec.rb +7 -0
- data/evals/cases/r2d2-mechanic/task-fast/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-fast.yml +59 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/fail/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/plan.md +7 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/pull-requests.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/plan/spec.md +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/Gemfile +2 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/lib/greeter.rb +6 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/repo/spec/greeter_spec.rb +11 -0
- data/evals/cases/r2d2-mechanic/task-medium/pass/run.json +1 -0
- data/evals/cases/r2d2-mechanic/task-medium.yml +58 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/fail/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/lib/cart.rb +5 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/fail/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/fail/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan-frontend.md +7 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/plan.md +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/pull-requests.md +8 -0
- data/evals/cases/rey-frontend/ui-medium/pass/plan/spec.md +5 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/lib/cart.rb +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.js +4 -0
- data/evals/cases/rey-frontend/ui-medium/pass/repo/web/cart_badge.test.js +3 -0
- data/evals/cases/rey-frontend/ui-medium/pass/run.json +1 -0
- data/evals/cases/rey-frontend/ui-medium.yml +55 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/plan.md +5 -0
- data/evals/cases/yoda-writer/spec-medium/fail/plan/spec.md +45 -0
- data/evals/cases/yoda-writer/spec-medium/fail/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/plan.md +0 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +63 -0
- data/evals/cases/yoda-writer/spec-medium/pass/run.json +1 -0
- data/evals/cases/yoda-writer/spec-medium.yml +40 -0
- data/exe/depot +1 -0
- data/exe/lanes +1 -0
- data/exe/openlanes +32 -0
- data/lib/openlanes/adapters/base.rb +69 -0
- data/lib/openlanes/adapters/claude.rb +124 -0
- data/lib/openlanes/adapters/codex.rb +33 -0
- data/lib/openlanes/adapters/model.rb +37 -0
- data/lib/openlanes/adapters/pi.rb +22 -0
- data/lib/openlanes/adapters/stream.rb +196 -0
- data/lib/openlanes/adapters.rb +92 -0
- data/lib/openlanes/agents/agent.rb +103 -0
- data/lib/openlanes/agents/profile.rb +57 -0
- data/lib/openlanes/agents/registry.rb +151 -0
- data/lib/openlanes/agents/roster.rb +103 -0
- data/lib/openlanes/agents/routing.rb +88 -0
- data/lib/openlanes/agents.rb +20 -0
- data/lib/openlanes/cli/agents/subcommands/describe.rb +66 -0
- data/lib/openlanes/cli/agents/subcommands/list.rb +20 -0
- data/lib/openlanes/cli/base.rb +263 -0
- data/lib/openlanes/cli/create/create.rb +354 -0
- data/lib/openlanes/cli/docs/docs.rb +40 -0
- data/lib/openlanes/cli/eval/eval.rb +110 -0
- data/lib/openlanes/cli/index/index.rb +40 -0
- data/lib/openlanes/cli/init/init.rb +145 -0
- data/lib/openlanes/cli/linear/linear.rb +36 -0
- data/lib/openlanes/cli/linear/subcommands/import.rb +174 -0
- data/lib/openlanes/cli/linear/subcommands/projects.rb +55 -0
- data/lib/openlanes/cli/list_plans/list_plans.rb +21 -0
- data/lib/openlanes/cli/mail/mail.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/read.rb +43 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +43 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +48 -0
- data/lib/openlanes/cli/resync/subcommands/prs.rb +111 -0
- data/lib/openlanes/cli/run/run.rb +544 -0
- data/lib/openlanes/cli/skills/subcommands/install.rb +92 -0
- data/lib/openlanes/cli/start/start.rb +110 -0
- data/lib/openlanes/cli/state/state.rb +26 -0
- data/lib/openlanes/cli/state/subcommands/show.rb +30 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +48 -0
- data/lib/openlanes/cli/states/states.rb +15 -0
- data/lib/openlanes/cli/unblock/unblock.rb +235 -0
- data/lib/openlanes/cli/version/version.rb +13 -0
- data/lib/openlanes/cli/web/web.rb +100 -0
- data/lib/openlanes/cli/worktree/worktree.rb +85 -0
- data/lib/openlanes/cli.rb +162 -0
- data/lib/openlanes/engine/board.rb +104 -0
- data/lib/openlanes/engine/dispatcher.rb +1297 -0
- data/lib/openlanes/engine/progress_log.rb +125 -0
- data/lib/openlanes/engine/runner.rb +224 -0
- data/lib/openlanes/engine/state_file.rb +190 -0
- data/lib/openlanes/engine/tally.rb +177 -0
- data/lib/openlanes/engine.rb +19 -0
- data/lib/openlanes/evals/case.rb +191 -0
- data/lib/openlanes/evals/changes.rb +67 -0
- data/lib/openlanes/evals/check.rb +224 -0
- data/lib/openlanes/evals/evidence.rb +71 -0
- data/lib/openlanes/evals/live.rb +140 -0
- data/lib/openlanes/evals/recording.rb +38 -0
- data/lib/openlanes/evals/run.rb +34 -0
- data/lib/openlanes/evals/score.rb +24 -0
- data/lib/openlanes/evals/scorer.rb +42 -0
- data/lib/openlanes/evals/verdict.rb +18 -0
- data/lib/openlanes/evals.rb +56 -0
- data/lib/openlanes/execution/checks.rb +73 -0
- data/lib/openlanes/execution/child.rb +228 -0
- data/lib/openlanes/execution/clock.rb +121 -0
- data/lib/openlanes/execution/control.rb +194 -0
- data/lib/openlanes/execution/executor.rb +849 -0
- data/lib/openlanes/execution/prompt.rb +455 -0
- data/lib/openlanes/execution/transcript.rb +531 -0
- data/lib/openlanes/execution.rb +19 -0
- data/lib/openlanes/launcher.rb +69 -0
- data/lib/openlanes/lifecycle/adoption.rb +197 -0
- data/lib/openlanes/lifecycle/brief.rb +288 -0
- data/lib/openlanes/lifecycle/completion.rb +58 -0
- data/lib/openlanes/lifecycle/creator.rb +126 -0
- data/lib/openlanes/lifecycle/lanes.rb +74 -0
- data/lib/openlanes/lifecycle/resync.rb +428 -0
- data/lib/openlanes/lifecycle/unblocker.rb +236 -0
- data/lib/openlanes/lifecycle.rb +28 -0
- data/lib/openlanes/linear/api.rb +224 -0
- data/lib/openlanes/linear/attribution.rb +185 -0
- data/lib/openlanes/linear/fuzzy.rb +68 -0
- data/lib/openlanes/linear/import.rb +314 -0
- data/lib/openlanes/linear/issue.rb +187 -0
- data/lib/openlanes/linear/mapping.rb +116 -0
- data/lib/openlanes/linear/push.rb +197 -0
- data/lib/openlanes/linear/survey.rb +174 -0
- data/lib/openlanes/linear/unit.rb +274 -0
- data/lib/openlanes/linear.rb +41 -0
- data/lib/openlanes/live/claims.rb +71 -0
- data/lib/openlanes/live/command.rb +122 -0
- data/lib/openlanes/live/commands.rb +505 -0
- data/lib/openlanes/live/keys.rb +93 -0
- data/lib/openlanes/live/memory_store.rb +265 -0
- data/lib/openlanes/live/meter.rb +51 -0
- data/lib/openlanes/live/node.rb +88 -0
- data/lib/openlanes/live/publisher.rb +179 -0
- data/lib/openlanes/live/repo.rb +60 -0
- data/lib/openlanes/live/run_record.rb +133 -0
- data/lib/openlanes/live/run_state.rb +101 -0
- data/lib/openlanes/live/session.rb +107 -0
- data/lib/openlanes/live/snapshot.rb +322 -0
- data/lib/openlanes/live/stations.rb +67 -0
- data/lib/openlanes/live/store.rb +277 -0
- data/lib/openlanes/live.rb +109 -0
- data/lib/openlanes/plans/dev_work.rb +56 -0
- data/lib/openlanes/plans/feature.rb +131 -0
- data/lib/openlanes/plans/index.rb +208 -0
- data/lib/openlanes/plans/ledger.rb +255 -0
- data/lib/openlanes/plans/mailbox.rb +164 -0
- data/lib/openlanes/plans/ordinal.rb +92 -0
- data/lib/openlanes/plans/plan_state.rb +291 -0
- data/lib/openlanes/plans/pull_request.rb +29 -0
- data/lib/openlanes/plans/pull_requests.rb +229 -0
- data/lib/openlanes/plans/spec.rb +213 -0
- data/lib/openlanes/plans/state_machine.rb +433 -0
- data/lib/openlanes/plans/status.rb +365 -0
- data/lib/openlanes/plans/subject.rb +133 -0
- data/lib/openlanes/plans/tree.rb +79 -0
- data/lib/openlanes/plans.rb +39 -0
- data/lib/openlanes/presentation/diagram.rb +149 -0
- data/lib/openlanes/presentation/documentation.rb +438 -0
- data/lib/openlanes/presentation/reporter.rb +181 -0
- data/lib/openlanes/presentation/viewer.rb +62 -0
- data/lib/openlanes/presentation.rb +23 -0
- data/lib/openlanes/skills/installer.rb +68 -0
- data/lib/openlanes/skills/skill.rb +60 -0
- data/lib/openlanes/skills.rb +25 -0
- data/lib/openlanes/support/config.rb +217 -0
- data/lib/openlanes/support/frontmatter.rb +38 -0
- data/lib/openlanes/support/markdown.rb +56 -0
- data/lib/openlanes/support/project.rb +79 -0
- data/lib/openlanes/support/ui.rb +671 -0
- data/lib/openlanes/vcs/github.rb +171 -0
- data/lib/openlanes/vcs/publisher.rb +221 -0
- data/lib/openlanes/vcs/worktree.rb +248 -0
- data/lib/openlanes/vcs.rb +19 -0
- data/lib/openlanes/version.rb +10 -0
- data/lib/openlanes/web/app.rb +176 -0
- data/lib/openlanes/web/context.rb +66 -0
- data/lib/openlanes/web/demo.rb +169 -0
- data/lib/openlanes/web/demo_board.rb +286 -0
- data/lib/openlanes/web/events.rb +104 -0
- data/lib/openlanes/web/launcher.rb +143 -0
- data/lib/openlanes/web/server.rb +77 -0
- data/lib/openlanes/web/stores.rb +36 -0
- data/lib/openlanes/web.rb +92 -0
- data/lib/openlanes.rb +151 -0
- data/openlanes.gemspec +87 -0
- data/schemas/plan-state.schema.json +101 -0
- data/skills/openlanes/SKILL.md +48 -0
- data/skills/openlanes-documents/SKILL.md +61 -0
- data/skills/openlanes-documents/references/blocked-template.md +49 -0
- data/skills/openlanes-documents/references/closing-templates.md +59 -0
- data/skills/openlanes-documents/references/plan-template.md +61 -0
- data/skills/openlanes-documents/references/retroactive-spec.md +43 -0
- data/skills/openlanes-documents/references/spec-template.md +45 -0
- data/skills/openlanes-run/SKILL.md +90 -0
- data/skills/openlanes-setup/SKILL.md +64 -0
- data/skills/openlanes-spec/SKILL.md +90 -0
- data/skills/openlanes-status/SKILL.md +32 -0
- data/skills/openlanes-sync/SKILL.md +22 -0
- data/web/dist/assets/apl-B4CMkyY2.js +2 -0
- data/web/dist/assets/asciiarmor-Df11BRmG.js +2 -0
- data/web/dist/assets/asn1-EdZsLKOL.js +2 -0
- data/web/dist/assets/asterisk-B-8jnY81.js +2 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- data/web/dist/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- data/web/dist/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- data/web/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
- data/web/dist/assets/clike-B9uivgTg.js +2 -0
- data/web/dist/assets/clojure-BMjYHr_A.js +2 -0
- data/web/dist/assets/cmake-BQqOBYOt.js +2 -0
- data/web/dist/assets/cobol-CWcv1MsR.js +2 -0
- data/web/dist/assets/coffeescript-S37ZYGWr.js +2 -0
- data/web/dist/assets/commonlisp-DBKNyK5s.js +2 -0
- data/web/dist/assets/crystal-SjHAIU92.js +2 -0
- data/web/dist/assets/css-BnMrqG3P.js +2 -0
- data/web/dist/assets/cypher-C_CwsFkJ.js +2 -0
- data/web/dist/assets/d-pRatUO7H.js +2 -0
- data/web/dist/assets/diff-DbItnlRl.js +2 -0
- data/web/dist/assets/dockerfile-BKs6k2Af.js +2 -0
- data/web/dist/assets/dtd-DF_7sFjM.js +2 -0
- data/web/dist/assets/dylan-DwRh75JA.js +2 -0
- data/web/dist/assets/ebnf-CDyGwa7X.js +2 -0
- data/web/dist/assets/ecl-Cabwm37j.js +2 -0
- data/web/dist/assets/eiffel-CnydiIhH.js +2 -0
- data/web/dist/assets/elm-vLlmbW-K.js +2 -0
- data/web/dist/assets/erlang-BNw1qcRV.js +2 -0
- data/web/dist/assets/factor-kuTfRLto.js +2 -0
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/fcl-Kvtd6kyn.js +2 -0
- data/web/dist/assets/forth-Ffai-XNe.js +2 -0
- data/web/dist/assets/fortran-DYz_wnZ1.js +2 -0
- data/web/dist/assets/gas-Bneqetm1.js +2 -0
- data/web/dist/assets/gherkin-heZmZLOM.js +2 -0
- data/web/dist/assets/groovy-D9Dt4D0W.js +2 -0
- data/web/dist/assets/haskell-Cw1EW3IL.js +2 -0
- data/web/dist/assets/haxe-H-WmDvRZ.js +2 -0
- data/web/dist/assets/http-DBlCnlav.js +2 -0
- data/web/dist/assets/idl-BEugSyMb.js +2 -0
- data/web/dist/assets/index-2xPC-eHt.js +2 -0
- data/web/dist/assets/index-8dNhRmiO.js +8 -0
- data/web/dist/assets/index-B7-O9ujQ.js +2 -0
- data/web/dist/assets/index-BdR34cXC.js +2 -0
- data/web/dist/assets/index-Bp7W-ZTq.js +2 -0
- data/web/dist/assets/index-Byyxbh_z.js +4 -0
- data/web/dist/assets/index-C0VmkrO-.js +2 -0
- data/web/dist/assets/index-CI74IkWP.js +198 -0
- data/web/dist/assets/index-CYxcbpRM.js +3 -0
- data/web/dist/assets/index-D0rmJN3h.js +2 -0
- data/web/dist/assets/index-DB4QwuYn.js +2 -0
- data/web/dist/assets/index-DDI7zoAS.js +2 -0
- data/web/dist/assets/index-DGLUuEXi.js +2 -0
- data/web/dist/assets/index-OR-odsl9.js +2 -0
- data/web/dist/assets/index-P3vSkiU-.js +2 -0
- data/web/dist/assets/index-a4EHR-u4.css +1 -0
- data/web/dist/assets/index-mRV4XhJZ.js +2 -0
- data/web/dist/assets/index-popk9wbW.js +2 -0
- data/web/dist/assets/index-uM88Njdj.js +2 -0
- data/web/dist/assets/javascript-iXu5QeM3.js +2 -0
- data/web/dist/assets/julia-DuME0IfC.js +2 -0
- data/web/dist/assets/livescript-BwQOo05w.js +2 -0
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- data/web/dist/assets/lua-VAEuO923.js +2 -0
- data/web/dist/assets/mathematica-DTrFuWx2.js +2 -0
- data/web/dist/assets/mbox-CNhZ1qSd.js +2 -0
- data/web/dist/assets/mirc-CjQqDB4T.js +2 -0
- data/web/dist/assets/mllike-CXdrOF99.js +2 -0
- data/web/dist/assets/modelica-Dc1JOy9r.js +2 -0
- data/web/dist/assets/mscgen-BA5vi2Kp.js +2 -0
- data/web/dist/assets/mumps-BT43cFF4.js +2 -0
- data/web/dist/assets/nginx-DdIZxoE0.js +2 -0
- data/web/dist/assets/nsis-LY0VT_u5.js +2 -0
- data/web/dist/assets/ntriples-BfvgReVJ.js +2 -0
- data/web/dist/assets/octave-Ck1zUtKM.js +2 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-CZsFKZCk.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-400-normal-tXDxWGRy.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-B-hc8bdn.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-600-normal-CXB2Mrig.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-CP6RWG8X.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-800-normal-PJW4A9xW.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-AKUz2ZI5.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-400-normal-C2ZjKngx.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-CW4Fknsm.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-600-normal-Zx-mtRSD.woff +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-CIth-cA7.woff2 +0 -0
- data/web/dist/assets/overpass-cyrillic-ext-800-normal-hx3cL3ZN.woff +0 -0
- data/web/dist/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- data/web/dist/assets/overpass-latin-400-normal-c_C8zulu.woff +0 -0
- data/web/dist/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- data/web/dist/assets/overpass-latin-600-normal-DhYQrzaY.woff +0 -0
- data/web/dist/assets/overpass-latin-800-normal-Bzx_XP5l.woff2 +0 -0
- data/web/dist/assets/overpass-latin-800-normal-D6-q4f0l.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-BIkXRwl6.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-400-normal-DUq_h-_z.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-Cyj4Nio0.woff2 +0 -0
- data/web/dist/assets/overpass-latin-ext-600-normal-DQKKMGzp.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-D6WKWGNs.woff +0 -0
- data/web/dist/assets/overpass-latin-ext-800-normal-DZ60AB77.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-B144qALP.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-400-normal-XK9_jwT8.woff +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-Dgejvo4A.woff2 +0 -0
- data/web/dist/assets/overpass-mono-cyrillic-ext-400-normal-DgxwtUZy.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-DqAOLzCO.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-5YIw-E8g.woff +0 -0
- data/web/dist/assets/overpass-mono-latin-ext-400-normal-DBdJzBs_.woff2 +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-DrLp_cHj.woff +0 -0
- data/web/dist/assets/overpass-mono-vietnamese-400-normal-jmOeL0rj.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-BKRCEiXy.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-400-normal-CCWAWVG7.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-CjS-4vQf.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-600-normal-DlMMlPfM.woff +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-BB8K9-Vo.woff2 +0 -0
- data/web/dist/assets/overpass-vietnamese-800-normal-DZUygvj_.woff +0 -0
- data/web/dist/assets/oz-BzwKVEFT.js +2 -0
- data/web/dist/assets/pascal--L3eBynH.js +2 -0
- data/web/dist/assets/perl-CdXCOZ3F.js +2 -0
- data/web/dist/assets/pig-CevX1Tat.js +2 -0
- data/web/dist/assets/powershell-CFHJl5sT.js +2 -0
- data/web/dist/assets/properties-C78fOPTZ.js +2 -0
- data/web/dist/assets/protobuf-ChK-085T.js +2 -0
- data/web/dist/assets/pug-DeIclll2.js +2 -0
- data/web/dist/assets/puppet-DMA9R1ak.js +2 -0
- data/web/dist/assets/python-BuPzkPfP.js +2 -0
- data/web/dist/assets/q-pXgVlZs6.js +2 -0
- data/web/dist/assets/r-B6wPVr8A.js +2 -0
- data/web/dist/assets/rpm-CTu-6PCP.js +2 -0
- data/web/dist/assets/ruby-BK5aLJev.js +2 -0
- data/web/dist/assets/sas-B4kiWyti.js +2 -0
- data/web/dist/assets/scheme-C41bIUwD.js +2 -0
- data/web/dist/assets/shell-CjFT_Tl9.js +2 -0
- data/web/dist/assets/sieve-C3Gn_uJK.js +2 -0
- data/web/dist/assets/simple-mode-GW_nhZxv.js +2 -0
- data/web/dist/assets/smalltalk-CnHTOXQT.js +2 -0
- data/web/dist/assets/solr-DehyRSwq.js +2 -0
- data/web/dist/assets/sparql-DkYu6x3z.js +2 -0
- data/web/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
- data/web/dist/assets/sql-D0XecflT.js +2 -0
- data/web/dist/assets/stex-C3f8Ysf7.js +2 -0
- data/web/dist/assets/stylus-B533Al4x.js +2 -0
- data/web/dist/assets/swift-BzpIVaGY.js +2 -0
- data/web/dist/assets/tcl-DVfN8rqt.js +2 -0
- data/web/dist/assets/textile-CnDTJFAw.js +2 -0
- data/web/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
- data/web/dist/assets/tiki-DGYXhP31.js +2 -0
- data/web/dist/assets/toml-Bm5Em-hy.js +2 -0
- data/web/dist/assets/troff-wAsdV37c.js +2 -0
- data/web/dist/assets/ttcn-CfJYG6tj.js +2 -0
- data/web/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
- data/web/dist/assets/turtle-B1tBg_DP.js +2 -0
- data/web/dist/assets/vb-CmGdzxic.js +2 -0
- data/web/dist/assets/vbscript-BuJXcnF6.js +2 -0
- data/web/dist/assets/velocity-D8B20fx6.js +2 -0
- data/web/dist/assets/verilog-C6RDOZhf.js +2 -0
- data/web/dist/assets/vhdl-lSbBsy5d.js +2 -0
- data/web/dist/assets/webidl-ZXfAyPTL.js +2 -0
- data/web/dist/assets/xquery-DzFWVndE.js +2 -0
- data/web/dist/assets/yacas-BJ4BC0dw.js +2 -0
- data/web/dist/assets/z80-Hz9HOZM7.js +2 -0
- data/web/dist/index.html +14 -0
- data/web/dist/vite.svg +1 -0
- data/web/src/api/fixtures/board.json +512 -0
- metadata +907 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Presentation
|
|
5
|
+
# `/spec-status` — every plan, its state in icon and words, and its pull
|
|
6
|
+
# requests as clickable links.
|
|
7
|
+
#
|
|
8
|
+
# {#render} returns a string and prints nothing. Whoever calls it decides
|
|
9
|
+
# where it goes, which keeps the table pipeable.
|
|
10
|
+
class Reporter
|
|
11
|
+
# Columns, so the header and the rows cannot drift apart.
|
|
12
|
+
HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
|
|
13
|
+
|
|
14
|
+
# Cells given to the plan number: "018.01" with room to grow.
|
|
15
|
+
ORDINAL_WIDTH = 7
|
|
16
|
+
|
|
17
|
+
# Cells given to a status icon. Emoji vary in both character count and
|
|
18
|
+
# display width, so the cell is padded to this by {UI.fit} rather than
|
|
19
|
+
# trusted to be any particular size — see the note there.
|
|
20
|
+
EMOJI_WIDTH = 2
|
|
21
|
+
|
|
22
|
+
# Cells given to a pull request's state, so the URLs line up beneath each
|
|
23
|
+
# other whichever states the tree happens to contain.
|
|
24
|
+
STATE_WIDTH = Plans::PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
|
|
25
|
+
|
|
26
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
27
|
+
def initialize(tree:)
|
|
28
|
+
@tree = tree
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [OpenLanes::Plans::Tree]
|
|
32
|
+
attr_reader :tree
|
|
33
|
+
|
|
34
|
+
# @return [String] the whole table, newline-terminated
|
|
35
|
+
def render
|
|
36
|
+
return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
|
|
37
|
+
|
|
38
|
+
[header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash{Symbol => Integer}] how many plans sit in each state
|
|
42
|
+
def totals
|
|
43
|
+
tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Plans whose folder name their contents do not justify.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<OpenLanes::Plans::Subject>]
|
|
49
|
+
def inconsistent = tree.subjects.reject(&:consistent?)
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# @return [Integer] widest status label, so the column lines up
|
|
54
|
+
def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
|
|
55
|
+
|
|
56
|
+
# @return [Integer] widest feature name
|
|
57
|
+
def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
|
|
58
|
+
|
|
59
|
+
# The one place the table's shape is written down. The heading, the plan
|
|
60
|
+
# rows, the continuation lines and the violations all come through here, so
|
|
61
|
+
# a column cannot widen in one of them and stay put in the others.
|
|
62
|
+
#
|
|
63
|
+
# Cells arrive already fitted and already painted, in that order, because
|
|
64
|
+
# the order matters: escape codes count toward a string's length, so
|
|
65
|
+
# padding a coloured string pads it to a width of which several characters
|
|
66
|
+
# are invisible, and the column collapses by exactly that much.
|
|
67
|
+
#
|
|
68
|
+
# @return [String]
|
|
69
|
+
def row(flag, ordinal, emoji, label, title, tail)
|
|
70
|
+
format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Cell at which the pull requests start, so a plan's second and third pull
|
|
74
|
+
# requests sit under its first. Measured by running blanks through {#row}
|
|
75
|
+
# rather than counted by hand, so the indent cannot drift when the layout
|
|
76
|
+
# changes.
|
|
77
|
+
#
|
|
78
|
+
# @return [Integer]
|
|
79
|
+
def pr_column
|
|
80
|
+
@pr_column ||= row(" ",
|
|
81
|
+
" " * ORDINAL_WIDTH,
|
|
82
|
+
" " * EMOJI_WIDTH,
|
|
83
|
+
" " * label_width,
|
|
84
|
+
" " * title_width,
|
|
85
|
+
"").length
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Integer] cell at which the feature name starts
|
|
89
|
+
def title_column = pr_column - title_width - 1
|
|
90
|
+
|
|
91
|
+
# @return [String]
|
|
92
|
+
def header
|
|
93
|
+
text = row(" ",
|
|
94
|
+
UI.fit(HEADINGS[0], ORDINAL_WIDTH),
|
|
95
|
+
UI.fit(HEADINGS[1], EMOJI_WIDTH),
|
|
96
|
+
UI.fit(HEADINGS[2], label_width),
|
|
97
|
+
UI.fit(HEADINGS[3], title_width),
|
|
98
|
+
HEADINGS[4])
|
|
99
|
+
|
|
100
|
+
rule = " " + ("─" * (UI.display_width(text) - 2))
|
|
101
|
+
|
|
102
|
+
[UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One line per plan, plus a continuation line for each extra pull request
|
|
106
|
+
# so that every URL is visible and clickable rather than truncated.
|
|
107
|
+
#
|
|
108
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
109
|
+
# @return [Array<String>]
|
|
110
|
+
def rows_for(subject)
|
|
111
|
+
feature = subject.feature
|
|
112
|
+
prs = subject.pull_requests
|
|
113
|
+
|
|
114
|
+
first = row(
|
|
115
|
+
subject.consistent? ? " " : UI.paint("!", :red),
|
|
116
|
+
UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
|
|
117
|
+
UI.fit(feature.status.emoji, EMOJI_WIDTH),
|
|
118
|
+
UI.paint(UI.fit(feature.status.label, label_width), :yellow),
|
|
119
|
+
UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
|
|
120
|
+
prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
|
|
124
|
+
|
|
125
|
+
[first, *rest, *violation_line(subject)]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
|
|
129
|
+
# is as likely to be piped into a file as read in a terminal, and every
|
|
130
|
+
# terminal worth the name linkifies a bare URL anyway.
|
|
131
|
+
#
|
|
132
|
+
# @param pr [OpenLanes::Plans::PullRequest]
|
|
133
|
+
# @return [String]
|
|
134
|
+
def link(pr)
|
|
135
|
+
colour = state_colour(pr)
|
|
136
|
+
|
|
137
|
+
format("#%4s %s %s",
|
|
138
|
+
pr.number,
|
|
139
|
+
UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
|
|
140
|
+
UI.paint(pr.url.to_s, colour))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param pr [OpenLanes::Plans::PullRequest]
|
|
144
|
+
# @return [Symbol] a pastel style name
|
|
145
|
+
def state_colour(pr)
|
|
146
|
+
if pr.merged?
|
|
147
|
+
:magenta
|
|
148
|
+
elsif pr.open?
|
|
149
|
+
:yellow
|
|
150
|
+
else
|
|
151
|
+
:blue
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @param subject [OpenLanes::Plans::Subject]
|
|
156
|
+
# @return [Array<String>]
|
|
157
|
+
def violation_line(subject)
|
|
158
|
+
return [] if subject.consistent?
|
|
159
|
+
|
|
160
|
+
[(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @return [String]
|
|
164
|
+
def footer
|
|
165
|
+
counts = totals.map { |key, n| "#{Plans::STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
|
|
166
|
+
lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
|
|
167
|
+
|
|
168
|
+
unless inconsistent.empty?
|
|
169
|
+
lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
tree.duplicates.each do |ordinal, dirnames|
|
|
173
|
+
lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
|
|
174
|
+
dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
lines.join("\n")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Presentation
|
|
5
|
+
# Hands Markdown files to a renderer outside this process: the system
|
|
6
|
+
# viewer via `open`, or mdfried in the terminal.
|
|
7
|
+
#
|
|
8
|
+
# Both paths shell out, so the class takes its two effects as lambdas and
|
|
9
|
+
# the specs assert on the commands instead of launching viewers on the box
|
|
10
|
+
# running the suite.
|
|
11
|
+
class Viewer
|
|
12
|
+
# What to run when mdfried is asked for but not installed. Homebrew is the
|
|
13
|
+
# one package manager the toolchain already assumes (see the Brewfile
|
|
14
|
+
# convention), so there is no fallback chain to get wrong.
|
|
15
|
+
INSTALL = "brew install mdfried >/dev/null"
|
|
16
|
+
|
|
17
|
+
# How to notice mdfried is missing without depending on `which`, which
|
|
18
|
+
# differs across shells; `command -v` is POSIX.
|
|
19
|
+
DETECT = "command -v mdfried >/dev/null"
|
|
20
|
+
|
|
21
|
+
# @param launch [#call] runs a command, returns truthy on success
|
|
22
|
+
# @param pipe [#call] runs a command, yielding its stdin as an IO
|
|
23
|
+
def initialize(launch: ->(*cmd) { system(*cmd) },
|
|
24
|
+
pipe: ->(cmd, &block) { IO.popen(cmd, "w", &block) })
|
|
25
|
+
@launch = launch
|
|
26
|
+
@pipe = pipe
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Opens each file in whatever the system has configured for Markdown.
|
|
30
|
+
#
|
|
31
|
+
# @param paths [Array<String>]
|
|
32
|
+
# @return [void]
|
|
33
|
+
def open(paths)
|
|
34
|
+
Array(paths).each { |path| @launch.call("open", path) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Streams each file's content into mdfried, installing it first when it
|
|
38
|
+
# is missing.
|
|
39
|
+
#
|
|
40
|
+
# @param paths [Array<String>]
|
|
41
|
+
# @return [void]
|
|
42
|
+
# @raise [OpenLanes::Error] when mdfried is absent and brew cannot fix that
|
|
43
|
+
def mdfried(paths)
|
|
44
|
+
ensure_mdfried!
|
|
45
|
+
Array(paths).each do |path|
|
|
46
|
+
@pipe.call("mdfried") { |io| io.write(File.read(path)) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @return [void]
|
|
53
|
+
# @raise [OpenLanes::Error]
|
|
54
|
+
def ensure_mdfried!
|
|
55
|
+
return if @launch.call(DETECT)
|
|
56
|
+
|
|
57
|
+
@launch.call(INSTALL) or
|
|
58
|
+
raise Error, "mdfried is not installed, and `brew install mdfried` failed"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# Everything a person reads: the live dashboard, the status table, the
|
|
5
|
+
# generated conventions and the state diagram. These render strings and
|
|
6
|
+
# draw on STDERR; none of them changes a plan.
|
|
7
|
+
module Presentation
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @return [String] the conventions document, derived
|
|
11
|
+
def documentation = Documentation.new.render
|
|
12
|
+
|
|
13
|
+
# @return [String] the state machine, drawn for a terminal
|
|
14
|
+
def diagram = Diagram.new.render
|
|
15
|
+
|
|
16
|
+
# @param tree [OpenLanes::Plans::Tree]
|
|
17
|
+
# @return [OpenLanes::Presentation::Reporter]
|
|
18
|
+
def reporter(tree:) = Reporter.new(tree:)
|
|
19
|
+
|
|
20
|
+
# @return [OpenLanes::Presentation::Viewer]
|
|
21
|
+
def viewer = Viewer.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Skills
|
|
7
|
+
# Copies the bundled skills into a skill directory.
|
|
8
|
+
#
|
|
9
|
+
# Copies, never links: a link into the gem's versioned install path
|
|
10
|
+
# dangles the moment `gem cleanup` removes that version, and the skill
|
|
11
|
+
# disappears with no error anywhere. Re-running after an upgrade is the
|
|
12
|
+
# price, and the report says what that run changed.
|
|
13
|
+
class Installer
|
|
14
|
+
# What happened to one skill.
|
|
15
|
+
#
|
|
16
|
+
# @!attribute [r] skill
|
|
17
|
+
# @return [OpenLanes::Skills::Skill]
|
|
18
|
+
# @!attribute [r] action
|
|
19
|
+
# @return [Symbol] :installed, :kept or :replaced
|
|
20
|
+
# @!attribute [r] target
|
|
21
|
+
# @return [String]
|
|
22
|
+
Outcome = Data.define(:skill, :action, :target)
|
|
23
|
+
|
|
24
|
+
# @param into [String] the destination directory
|
|
25
|
+
# @param skills [Array<OpenLanes::Skills::Skill>]
|
|
26
|
+
def initialize(into:, skills:)
|
|
27
|
+
@into = File.expand_path(into)
|
|
28
|
+
@skills = skills
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String]
|
|
32
|
+
attr_reader :into
|
|
33
|
+
|
|
34
|
+
# @param overwrite [Boolean] replace a skill already there
|
|
35
|
+
# @param dry_run [Boolean] decide, and change nothing
|
|
36
|
+
# @return [Array<Outcome>]
|
|
37
|
+
def call(overwrite: false, dry_run: false)
|
|
38
|
+
@skills.map do |skill|
|
|
39
|
+
target = File.join(into, skill.name)
|
|
40
|
+
action = action_for(skill, overwrite)
|
|
41
|
+
copy(skill, target, replacing: action == :replaced) unless dry_run || action == :kept
|
|
42
|
+
Outcome.new(skill:, action:, target:)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @return [Symbol]
|
|
49
|
+
def action_for(skill, overwrite)
|
|
50
|
+
return :installed unless skill.installed?(into)
|
|
51
|
+
|
|
52
|
+
overwrite ? :replaced : :kept
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A link already standing where the skill goes is removed, not
|
|
56
|
+
# followed: replacing what it points at would edit somebody's checkout.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def copy(skill, target, replacing:)
|
|
60
|
+
if replacing
|
|
61
|
+
File.symlink?(target) ? File.unlink(target) : FileUtils.rm_rf(target)
|
|
62
|
+
end
|
|
63
|
+
FileUtils.mkdir_p(into)
|
|
64
|
+
FileUtils.cp_r(skill.path, target)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Skills
|
|
5
|
+
# One bundled skill: a directory holding a `SKILL.md` whose frontmatter
|
|
6
|
+
# names and describes it.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] name
|
|
9
|
+
# @return [String] the frontmatter `name`, else the directory's
|
|
10
|
+
# @!attribute [r] description
|
|
11
|
+
# @return [String] the whole frontmatter `description`
|
|
12
|
+
# @!attribute [r] path
|
|
13
|
+
# @return [String] the skill's directory
|
|
14
|
+
Skill = Data.define(:name, :description, :path) do
|
|
15
|
+
# What `skills install -h` prints beside the name: a description is
|
|
16
|
+
# written for an agent deciding whether to load it, and runs long.
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
19
|
+
def summary = description.split(/(?<=[.!?])\s+/, 2).first.to_s.strip
|
|
20
|
+
|
|
21
|
+
# @param into [String]
|
|
22
|
+
# @return [Boolean] whether a skill of this name is already there
|
|
23
|
+
def installed?(into) = File.exist?(File.join(into, name)) || File.symlink?(File.join(into, name))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Skill
|
|
27
|
+
FILENAME = "SKILL.md"
|
|
28
|
+
|
|
29
|
+
# @param dir [String]
|
|
30
|
+
# @return [Array<OpenLanes::Skills::Skill>] every `<dir>/*/SKILL.md`, by name
|
|
31
|
+
def self.all(dir)
|
|
32
|
+
Dir.glob(File.join(dir, "*", FILENAME)).map { load(File.dirname(it)) }.sort_by(&:name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param path [String] a skill directory
|
|
36
|
+
# @return [OpenLanes::Skills::Skill]
|
|
37
|
+
def self.load(path)
|
|
38
|
+
meta = frontmatter(File.read(File.join(path, FILENAME), encoding: "UTF-8"))
|
|
39
|
+
new(name: meta.fetch("name", File.basename(path)).to_s,
|
|
40
|
+
description: meta.fetch("description", "").to_s.gsub(/\s+/, " ").strip,
|
|
41
|
+
path:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Agents read a SKILL.md leniently, and a description with `word: word`
|
|
45
|
+
# in it is not valid YAML as a plain scalar. Refusing such a skill
|
|
46
|
+
# here would hide one the agents load fine, so a header YAML rejects
|
|
47
|
+
# is read a line at a time instead.
|
|
48
|
+
#
|
|
49
|
+
# @param text [String]
|
|
50
|
+
# @return [Hash{String => String}]
|
|
51
|
+
def self.frontmatter(text)
|
|
52
|
+
Frontmatter.split(text).first
|
|
53
|
+
rescue Psych::Exception
|
|
54
|
+
header = text[/\A---\s*\n(.*?)\n---/m, 1].to_s
|
|
55
|
+
header.scan(/^([a-z][\w-]*):[ \t]*(.*)$/).to_h.transform_values { it.strip.delete_prefix('"').delete_suffix('"') }
|
|
56
|
+
end
|
|
57
|
+
private_class_method :frontmatter
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
# The agent skills that ship inside the gem, under `skills/`, and the copy
|
|
5
|
+
# that puts them where a coding agent looks for them.
|
|
6
|
+
module Skills
|
|
7
|
+
# Where the bundled skills live: beside `agents/`, at the gem root.
|
|
8
|
+
SOURCE_DIR = File.expand_path("../../skills", __dir__)
|
|
9
|
+
|
|
10
|
+
# The shared skill store that `~/.claude/skills` and the other agents'
|
|
11
|
+
# skill directories link into.
|
|
12
|
+
DESTINATION = File.join(Dir.home, ".agents", "skills")
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param dir [String] a directory of `<name>/SKILL.md`
|
|
17
|
+
# @return [Array<OpenLanes::Skills::Skill>] by name
|
|
18
|
+
def bundled(dir: SOURCE_DIR) = Skill.all(dir)
|
|
19
|
+
|
|
20
|
+
# @param into [String]
|
|
21
|
+
# @param source [String]
|
|
22
|
+
# @return [OpenLanes::Skills::Installer]
|
|
23
|
+
def installer(into: DESTINATION, source: SOURCE_DIR) = Installer.new(into:, skills: bundled(dir: source))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
# A project's `.openlanes.yml`, at its root:
|
|
7
|
+
#
|
|
8
|
+
# project: Equilibris App # heading for INDEX.md; default: the root's name
|
|
9
|
+
# lane: full # default lane for `openlanes create`
|
|
10
|
+
# frontend: false # a spec's `frontend:` when spec.md omits it
|
|
11
|
+
# jobs: 4 # how many plans `openlanes run` works on at once
|
|
12
|
+
# redis_url: redis://10.0.4.2:6379/0 # the Redis the live run shares (W16)
|
|
13
|
+
# commands: # per-command option defaults
|
|
14
|
+
# run:
|
|
15
|
+
# timeout: 1800
|
|
16
|
+
#
|
|
17
|
+
# Precedence, loudest first: a flag typed on the command line, the plan's
|
|
18
|
+
# spec.md frontmatter, this file, the built-in default. The file supplies
|
|
19
|
+
# defaults and nothing else.
|
|
20
|
+
#
|
|
21
|
+
# `jobs`, `lane` and `project` are each one command's option said once for
|
|
22
|
+
# the whole project, so each has a second spelling under `commands:`.
|
|
23
|
+
# Writing both is an error rather than a tie-break: two answers to one
|
|
24
|
+
# question is a file somebody will edit in the wrong place.
|
|
25
|
+
#
|
|
26
|
+
# Anything unknown is an error naming the file, never a shrug: a config
|
|
27
|
+
# silently ignored is a timeout somebody set that stops applying with no
|
|
28
|
+
# signal anywhere.
|
|
29
|
+
#
|
|
30
|
+
# @!attribute [r] path
|
|
31
|
+
# @return [String, nil] the file, nil when the project has none
|
|
32
|
+
# @!attribute [r] project
|
|
33
|
+
# @return [String, nil]
|
|
34
|
+
# @!attribute [r] lane
|
|
35
|
+
# @return [Symbol, nil] one of {Plans::Spec::LANES}
|
|
36
|
+
# @!attribute [r] frontend
|
|
37
|
+
# @return [Boolean, nil] nil when each plan decides
|
|
38
|
+
# @!attribute [r] jobs
|
|
39
|
+
# @return [Integer, nil]
|
|
40
|
+
# @!attribute [r] redis_url
|
|
41
|
+
# @return [String, nil] where the live run keeps its board, its commands
|
|
42
|
+
# and its plan claims; `--redis-url` beats it, and it beats `$REDIS_URL`
|
|
43
|
+
# @!attribute [r] commands
|
|
44
|
+
# @return [Hash{String => Hash{Symbol => Object}}] by command name, as
|
|
45
|
+
# typed: "run", "resync dirs"
|
|
46
|
+
Config = Data.define(:path, :project, :lane, :frontend, :jobs, :redis_url, :commands) do
|
|
47
|
+
def initialize(path: nil, project: nil, lane: nil, frontend: nil, jobs: nil, redis_url: nil, commands: {})
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One command's option defaults, the top-level spelling folded in.
|
|
52
|
+
#
|
|
53
|
+
# @param command [String, Symbol] e.g. :run or "resync dirs"
|
|
54
|
+
# @return [Hash{Symbol => Object}]
|
|
55
|
+
def for(command)
|
|
56
|
+
name = command.to_s
|
|
57
|
+
folded = Config::SHORTHANDS.filter_map { |key, owner|
|
|
58
|
+
value = public_send(key)
|
|
59
|
+
[key, value.is_a?(Symbol) ? value.to_s : value] if owner == name && !value.nil?
|
|
60
|
+
}.to_h
|
|
61
|
+
commands.fetch(name, {}).merge(folded)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Boolean] whether the project has a file at all
|
|
65
|
+
def file? = !path.nil?
|
|
66
|
+
|
|
67
|
+
# The file `openlanes init` writes: only what was set, in the order the
|
|
68
|
+
# keys are documented, so it reads like the example it came from.
|
|
69
|
+
#
|
|
70
|
+
# @return [String]
|
|
71
|
+
def to_yaml
|
|
72
|
+
settings = { "project" => project, "lane" => lane&.to_s, "frontend" => frontend, "jobs" => jobs,
|
|
73
|
+
"redis_url" => redis_url }
|
|
74
|
+
settings["commands"] = commands.transform_values { it.transform_keys(&:to_s) } unless commands.empty?
|
|
75
|
+
YAML.dump(settings.compact).delete_prefix("---\n")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class Config
|
|
80
|
+
# The file, at the project root.
|
|
81
|
+
FILE = Project::FILE
|
|
82
|
+
|
|
83
|
+
# Every key the top level may hold.
|
|
84
|
+
KEYS = %w[project lane frontend jobs redis_url commands].freeze
|
|
85
|
+
|
|
86
|
+
# Schemes a `redis_url:` may use.
|
|
87
|
+
REDIS_SCHEMES = %w[redis rediss unix].freeze
|
|
88
|
+
|
|
89
|
+
# Top-level keys that are one command's option, by the command that takes it.
|
|
90
|
+
SHORTHANDS = { project: "index", lane: "create", jobs: "run" }.freeze
|
|
91
|
+
|
|
92
|
+
class << self
|
|
93
|
+
# @param root [String] the project root
|
|
94
|
+
# @return [OpenLanes::Config] empty when the root holds no file
|
|
95
|
+
# @raise [OpenLanes::Error] when the file exists and cannot be used
|
|
96
|
+
def load(root)
|
|
97
|
+
path = File.join(root, FILE)
|
|
98
|
+
return new unless File.file?(path)
|
|
99
|
+
|
|
100
|
+
parse(File.read(path, encoding: "UTF-8"), path:)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The project the process is working in. {CLI::Base} has already moved
|
|
104
|
+
# to the root, so that is where this looks; the file is reread only
|
|
105
|
+
# when it changes, because routing asks for it on every tick.
|
|
106
|
+
#
|
|
107
|
+
# @return [OpenLanes::Config]
|
|
108
|
+
def current
|
|
109
|
+
root = Project.root || Dir.pwd
|
|
110
|
+
path = File.join(root, FILE)
|
|
111
|
+
key = [path, File.file?(path) && File.mtime(path)]
|
|
112
|
+
return @current.last if @current&.first == key
|
|
113
|
+
|
|
114
|
+
load(root).tap { |config| @current = [key, config] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param text [String] the whole file
|
|
118
|
+
# @param path [String] for every error message
|
|
119
|
+
# @return [OpenLanes::Config]
|
|
120
|
+
def parse(text, path:)
|
|
121
|
+
data = YAML.safe_load(text) || {}
|
|
122
|
+
fail_with(path, "expected a mapping of settings at the top") unless data.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
settings = data.transform_keys(&:to_s)
|
|
125
|
+
unknown = settings.keys - KEYS
|
|
126
|
+
unless unknown.empty?
|
|
127
|
+
fail_with(path,
|
|
128
|
+
"unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; " \
|
|
129
|
+
"known: #{KEYS.join(", ")}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
config = new(path:,
|
|
133
|
+
project: project(settings, path),
|
|
134
|
+
lane: lane(settings, path),
|
|
135
|
+
frontend: frontend(settings, path),
|
|
136
|
+
jobs: jobs(settings, path),
|
|
137
|
+
redis_url: redis_url(settings, path),
|
|
138
|
+
commands: commands(settings["commands"], path))
|
|
139
|
+
conflicts!(config, path)
|
|
140
|
+
config
|
|
141
|
+
rescue Psych::Exception => e
|
|
142
|
+
fail_with(path, "is not valid YAML (#{e.message.lines.first.to_s.strip})")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# @return [void] never returns
|
|
148
|
+
def fail_with(path, message) = raise(Error, "#{path}: #{message}")
|
|
149
|
+
|
|
150
|
+
# @return [String, nil]
|
|
151
|
+
def project(settings, path)
|
|
152
|
+
value = settings["project"]
|
|
153
|
+
return nil if value.nil?
|
|
154
|
+
return value.strip if value.is_a?(String) && !value.strip.empty?
|
|
155
|
+
|
|
156
|
+
fail_with(path, "project: must be a non-empty name, not #{value.inspect}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Symbol, nil]
|
|
160
|
+
def lane(settings, path)
|
|
161
|
+
return nil unless settings.key?("lane")
|
|
162
|
+
|
|
163
|
+
value = settings["lane"].to_s.strip.downcase.to_sym
|
|
164
|
+
return value if Plans::Spec::LANES.include?(value)
|
|
165
|
+
|
|
166
|
+
fail_with(path, "lane: #{settings["lane"].inspect} is not one of #{Plans::Spec::LANES.join(", ")}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [Boolean, nil]
|
|
170
|
+
def frontend(settings, path)
|
|
171
|
+
value = settings["frontend"]
|
|
172
|
+
return value if [true, false, nil].include?(value)
|
|
173
|
+
|
|
174
|
+
fail_with(path, "frontend: #{value.inspect} is not true or false")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [Integer, nil]
|
|
178
|
+
def jobs(settings, path)
|
|
179
|
+
value = settings["jobs"]
|
|
180
|
+
return value if value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
181
|
+
|
|
182
|
+
fail_with(path, "jobs: #{value.inspect} is not a whole number above zero")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [String, nil]
|
|
186
|
+
def redis_url(settings, path)
|
|
187
|
+
value = settings["redis_url"]
|
|
188
|
+
return nil if value.nil?
|
|
189
|
+
return value.strip if value.is_a?(String) && REDIS_SCHEMES.include?(value.strip[/\A([a-z]+):/, 1])
|
|
190
|
+
|
|
191
|
+
fail_with(path, "redis_url: #{value.inspect} is not a #{REDIS_SCHEMES.map { "#{it}://" }.join(", ")} URL")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [Hash{String => Hash{Symbol => Object}}]
|
|
195
|
+
def commands(value, path)
|
|
196
|
+
return {} if value.nil?
|
|
197
|
+
|
|
198
|
+
fail_with(path, "commands: must be a mapping of command name to its options") unless value.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
value.to_h do |name, options|
|
|
201
|
+
fail_with(path, "commands.#{name}: must be a mapping of option to value") unless options.is_a?(Hash)
|
|
202
|
+
|
|
203
|
+
[name.to_s.strip, options.transform_keys { it.to_s.tr("-", "_").to_sym }]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# @return [void]
|
|
208
|
+
def conflicts!(config, path)
|
|
209
|
+
SHORTHANDS.each do |key, owner|
|
|
210
|
+
next if config.public_send(key).nil? || !config.commands.fetch(owner, {}).key?(key)
|
|
211
|
+
|
|
212
|
+
fail_with(path, "#{key} and commands.#{owner}.#{key} are the same setting; keep one")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
# Splits a markdown file into its YAML frontmatter and its body.
|
|
8
|
+
#
|
|
9
|
+
# The agent definitions and `create --from` both read frontmatter, and both
|
|
10
|
+
# used to call `YAML.safe_load` on their own. Its defaults refuse to build a
|
|
11
|
+
# `Date`, so an ordinary `date: 2026-08-31` raised Psych::DisallowedClass and
|
|
12
|
+
# `create` reported a missing `title:` on a file whose title was right there.
|
|
13
|
+
# One parser, one list of permitted classes, so that cannot happen twice.
|
|
14
|
+
module Frontmatter
|
|
15
|
+
# rubocop: disable Lint/OrAssignmentToConstant
|
|
16
|
+
# Frontmatter, then body.
|
|
17
|
+
PATTERN ||= /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
|
|
18
|
+
|
|
19
|
+
# Dates and timestamps are ordinary frontmatter, so they load. Nothing else
|
|
20
|
+
# does: the point of `safe_load` is that a seed file cannot name a class.
|
|
21
|
+
PERMITTED_CLASSES ||= [Date, Time].freeze
|
|
22
|
+
# rubocop: enable Lint/OrAssignmentToConstant
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# @param content [String] a whole markdown file
|
|
26
|
+
# @return [Array(Hash, String)] the frontmatter and the body. A file with
|
|
27
|
+
# no frontmatter is all body, and frontmatter that is not a mapping —
|
|
28
|
+
# a bare list, a lone string — reads as no keys rather than raising.
|
|
29
|
+
# @raise [Psych::Exception] when the frontmatter is not valid YAML
|
|
30
|
+
def split(content)
|
|
31
|
+
match = PATTERN.match(content) or return [{}, content]
|
|
32
|
+
|
|
33
|
+
meta = YAML.safe_load(match[1], permitted_classes: PERMITTED_CLASSES)
|
|
34
|
+
[meta.is_a?(Hash) ? meta : {}, match[2]]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|