polyrun 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +23 -3
  4. data/docs/SETUP_PROFILE.md +1 -1
  5. data/lib/polyrun/cli/ci_shard_run_command.rb +65 -0
  6. data/lib/polyrun/cli/config_command.rb +42 -0
  7. data/lib/polyrun/cli/default_run.rb +115 -0
  8. data/lib/polyrun/cli/help.rb +54 -0
  9. data/lib/polyrun/cli/helpers.rb +19 -30
  10. data/lib/polyrun/cli/plan_command.rb +47 -17
  11. data/lib/polyrun/cli/prepare_command.rb +2 -3
  12. data/lib/polyrun/cli/prepare_recipe.rb +12 -7
  13. data/lib/polyrun/cli/queue_command.rb +17 -7
  14. data/lib/polyrun/cli/run_shards_command.rb +49 -3
  15. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +3 -3
  16. data/lib/polyrun/cli/run_shards_plan_options.rb +18 -11
  17. data/lib/polyrun/cli/run_shards_planning.rb +16 -12
  18. data/lib/polyrun/cli/start_bootstrap.rb +2 -6
  19. data/lib/polyrun/cli.rb +53 -47
  20. data/lib/polyrun/config/dotted_path.rb +21 -0
  21. data/lib/polyrun/config/effective.rb +71 -0
  22. data/lib/polyrun/config/resolver.rb +70 -0
  23. data/lib/polyrun/config.rb +7 -0
  24. data/lib/polyrun/database/provision.rb +12 -7
  25. data/lib/polyrun/partition/constraints.rb +15 -4
  26. data/lib/polyrun/partition/paths.rb +83 -2
  27. data/lib/polyrun/partition/plan.rb +38 -28
  28. data/lib/polyrun/partition/timing_keys.rb +85 -0
  29. data/lib/polyrun/prepare/assets.rb +12 -5
  30. data/lib/polyrun/process_stdio.rb +91 -0
  31. data/lib/polyrun/quick/runner.rb +26 -17
  32. data/lib/polyrun/rspec.rb +19 -0
  33. data/lib/polyrun/templates/POLYRUN.md +1 -1
  34. data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -1
  35. data/lib/polyrun/timing/merge.rb +2 -1
  36. data/lib/polyrun/timing/rspec_example_formatter.rb +53 -0
  37. data/lib/polyrun/version.rb +1 -1
  38. data/polyrun.gemspec +1 -1
  39. data/sig/polyrun/rspec.rbs +2 -0
  40. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca0c4afee7136f1cdf3ffe319d55c08457323119102a0a4d01478b02df91ce31
4
- data.tar.gz: d468805765ee230bed530fdce182f189c0521e3dff7516be5c75bc620c362fc8
3
+ metadata.gz: 5dd8b178e07e0cf6648284d59d5b8f58a5feceeb2c7570edfd381aafaed207cb
4
+ data.tar.gz: cd2846dc77b56bccf0ac411fcc4f6a2a7aaf2e106609172e84299c2a1d253f64
5
5
  SHA512:
6
- metadata.gz: 98e04c5388e51da737bb73407497830d82bf61daabdfd77e9024fd2d973663dfed934c87691c6f2e864d7642e457a1eaaff27e3cf3f79bfa102a92ec9a0034aa
7
- data.tar.gz: b6c407039df97914ed8e07a04bdb0dc4c8e044d9b0ffd3802e44e600a6c4002fe22b8673266263c5981bb4bde90246c992defb589eb3b785952b0c56f67c263e
6
+ metadata.gz: c9a71f317d28ce3dcdf25d9c8e08221e71eadb2cf044217170ca0ba08cdc22cb702e6adaf02383d4e6089fa2fdddc94198cc420f031f53186715b67c14c92567
7
+ data.tar.gz: 81469ea975e78befbf6c66e3482e5c6006c11bb8b1806a079545a00ac057f250d49323aeab6685e8eaec22ff65e5aa72fb28aaca688365048483650d3c78de00
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # CHANGELOG
2
+
3
+ ## 1.2.0 (2026-04-15)
4
+
5
+ - Add `polyrun config <dotted.path>` to print values from `Polyrun::Config::Effective` (same effective tree as runtime: arbitrary YAML paths, merged `prepare.env.<KEY>` as for `polyrun prepare`, resolved `partition.shard_index`, `partition.shard_total`, `partition.timing_granularity`, and `workers`).
6
+ - Memoize `Polyrun::Config::Effective.build` per thread (keyed by config path, object id, and env fingerprint) so repeated `dig` calls do not rebuild the merged tree.
7
+ - Add `DISPATCH_SUBCOMMAND_NAMES` and `IMPLICIT_PATH_EXCLUSION_TOKENS`; route implicit path-only argv against one list (includes `ci-shard-*`, `help`, `version`); add spec that dispatch names match `when` branches in `lib/polyrun/cli.rb`.
8
+ - Run `polyrun` with no subcommand to fan out parallel tests: pick RSpec (`start`), Minitest (`bundle exec rails test` or `bundle exec ruby -I test`), or Polyrun Quick (`bundle exec polyrun quick`) from `spec/**/*_spec.rb` vs `test/**/*_test.rb` vs Quick globs.
9
+ - Accept path-only argv (and optional `run-shards` options before paths, e.g. `--workers`) to shard those files without naming a subcommand; infer suite from `_spec.rb` / `_test.rb` vs other `.rb` files.
10
+ - Add optional `partition.suite` (`auto`, `rspec`, `minitest`, `quick`) when resolving globbed paths for `run-shards` / `parallel-rspec` / default runs.
11
+ - Document implicit argv (known subcommand first vs path-like implicit parallel) and parallel Quick `bundle exec` from app root in `polyrun help` and `examples/README.md`.
12
+ - Comment `detect_auto_suite` glob order in `lib/polyrun/partition/paths.rb` (RSpec/Minitest globs before Quick discovery).
13
+ - Remove redundant `OptionParser` from `polyrun config` (no options; banner only).
14
+
15
+ ## 1.1.0 (2026-04-15)
16
+
17
+ - Add `ci-shard-run` / `ci-shard-rspec` for matrix-style sharding (one job per `POLYRUN_SHARD_INDEX` / `POLYRUN_SHARD_TOTAL`): resolve paths via the same plan as `polyrun plan`, then `exec` the given command with this shard’s paths (unlike `run-shards`, which fans out multiple workers on one host).
18
+ - Add experimental per-example partition timing: `partition.timing_granularity` / `--timing-granularity` (`file` default, `example` for `path:line` items), `POLYRUN_TIMING_GRANULARITY`, merged timing JSON with `absolute_path:line` keys, `TimingKeys.load_costs_json_file`, constraints matching pins/globs on the file part of locators, queue init support, and optional `Polyrun::Timing::RSpecExampleFormatter` plus `Polyrun::RSpec.install_example_timing!`.
19
+ - Add `Polyrun::ProcessStdio.inherit_stdio_spawn_wait` and `spawn_wait` for subprocesses with inherited stdio (or temp-file capture when `silent: true`) to avoid Open3 pipe-thread noise on interrupt; used by prepare (shell / custom assets), `Prepare::Assets.precompile!`, and `Provision.prepare_template!` (`bin/rails db:prepare`). On failure, `db:prepare` / `assets:precompile` embed captured stdout/stderr in `Polyrun::Error` (truncated when huge).
20
+ - Refactor `polyrun plan` around `plan_command_compute_manifest` and `plan_command_build_manifest`; `cmd_plan` output stays aligned with `plan_command_compute_manifest` (tests guard drift).
21
+ - `TimingKeys.load_costs_json_file` accepts optional `root:` for key normalization; warns when two JSON keys normalize to the same entry with different seconds; `TimingKeys.canonical_file_path` / `normalize_locator` resolve directory symlinks so `/var/…` and `/private/var/…` (macOS) map to one key.
22
+ - `Polyrun::RSpec.install_example_timing!(output_path:)` no longer sets `ENV` when an explicit path is passed; formatter uses `timing_output_path` (override or `ENV` / default filename).
23
+ - Fix noisy `IOError` / broken-pipe behavior when interrupting long-running prepare / Rails subprocesses that previously used `Open3.capture3`.
24
+
25
+ ## 1.0.0 (2026-04-14)
26
+
27
+ - Initial stable release of Polyrun: parallel tests, SimpleCov-compatible coverage formatters, fixtures/snapshots, assets and DB provisioning with zero runtime gem dependencies.
28
+ - Add `polyrun` CLI with `plan`, `run-shards`, partition/load balancing, coverage merge and reporting, database helpers, queue helpers, and the Quick runner.
29
+ - Add coverage Rake tasks and YAML configuration for merged / Cobertura-style output.
30
+ - Add database command flows using `db:prepare` (replacing earlier `db:migrate`-only paths) for provisioning-style runs.
31
+ - Implement graceful shutdown for worker processes in `run_shards` when the parent is interrupted.
32
+ - Expand Quick runner defaults and parallel shard database creation.
33
+ - Add examples tree, specs for merge and queue behavior, and docs for cwd-relative configuration.
34
+ - Add RSpec suite, RuboCop (including a FileLength cop), YAML templates, and a `bin/release` script.
35
+ - Add RBS signatures under `sig/` and validate them in CI; expand documentation and specs.
data/README.md CHANGED
@@ -8,7 +8,7 @@ Running tests in parallel across processes still requires a single merged covera
8
8
 
9
9
  Polyrun provides:
10
10
 
11
- - Orchestration: `plan`, `run-shards`, and `parallel-rspec` (run-shards plus merge-coverage), with an optional on-disk queue and constraints for file lists and load balancing.
11
+ - Orchestration: `plan`, `run-shards`, and `parallel-rspec` (run-shards plus merge-coverage), with an optional on-disk queue and constraints for file lists and load balancing. For **GitHub Actions-style matrix sharding** (one job per global shard), use `ci-shard-run -- …` (any test runner) or `ci-shard-rspec`—not `run-shards` / `parallel-rspec`, which fan out N workers on one machine.
12
12
  - Coverage: merge SimpleCov-compatible JSON fragments; emit JSON, LCOV, Cobertura, or console summaries (you can drop separate SimpleCov merge plugins for this path).
13
13
  - CI reporting: JUnit XML from RSpec JSON; slow-file reports from merged timing JSON.
14
14
  - Parallel hygiene: asset digest markers, SQL snapshots, YAML fixture batches, and DB URL or shard helpers aligned with `POLYRUN_SHARD_*`.
@@ -19,7 +19,7 @@ Capybara and Playwright stay in your application; Polyrun does not replace brows
19
19
 
20
20
  ## How?
21
21
 
22
- 1. Add the gem (path or RubyGems) and `require "polyrun"` where you integrate—for example coverage merge in CI or prepare hooks.
22
+ 1. Add the gem (path or RubyGems) and `require "polyrun"` where you integrate—for example coverage merge in CI or prepare hooks. To pin the executable in your app, run `bundle binstubs polyrun` (writes `bin/polyrun`; ensure `bin/` is on `PATH` or invoke `./bin/polyrun`).
23
23
  2. Add a `polyrun.yml` beside the app, or pass `-c` to point at one. Configure `partition` (paths, shard index and total, strategy), and optionally `databases` (Postgres template and `shard_db_pattern`), `prepare`, and `coverage`. If you use `partition.paths_build`, Polyrun can write `partition.paths_file` (for example `spec/spec_paths.txt`) from globs and ordered stages—substring priorities for integration specs, or a regex stage for “Rails-heavy files first”—without a per-project Ruby script. That step runs before `plan` and `run-shards`. Use `bin/polyrun build-paths` to refresh the paths file only.
24
24
  3. Run prepare once before fan-out—for example `script/ci_prepare` for Vite or webpack builds, and `Polyrun::Prepare::Assets` digest markers. See `examples/TESTING_REQUIREMENTS.md`.
25
25
  4. Run workers with `bin/polyrun run-shards --workers N -- bundle exec rspec`: N separate OS processes, each running RSpec with its own file list from `partition.paths_file`, or `spec/spec_paths.txt`, or else `spec/**/*_spec.rb`. Stderr shows where paths came from; after a successful multi-worker run it reminds you to run merge-coverage unless you use `parallel-rspec` or `run-shards --merge-coverage`.
@@ -32,6 +32,8 @@ If the current directory already has `polyrun.yml` or `config/polyrun.yml`, you
32
32
  ```bash
33
33
  bin/polyrun version
34
34
  bin/polyrun build-paths # write spec/spec_paths.txt from partition.paths_build (uses polyrun.yml in cwd)
35
+ bin/polyrun ci-shard-run -- bundle exec rspec # CI matrix: shard plan + append paths to the command after --
36
+ bin/polyrun ci-shard-rspec # same as ci-shard-run -- bundle exec rspec
35
37
  bin/polyrun parallel-rspec --workers 5 # run-shards + merge-coverage (default: bundle exec rspec)
36
38
  bin/polyrun run-shards --workers 5 --merge-coverage -- bundle exec rspec
37
39
  bin/polyrun merge-coverage -i cov1.json -i cov2.json -o merged.json --format json,lcov,cobertura,console
@@ -41,6 +43,12 @@ bin/polyrun init --profile gem -o polyrun.yml # starter YAML; see docs/SETUP_P
41
43
  bin/polyrun quick # Polyrun::Quick examples under spec/polyrun_quick/ or test/polyrun_quick/
42
44
  ```
43
45
 
46
+ ### Matrix shards and timing
47
+
48
+ - `ci-shard-run` — Pass the command as separate words after `--` (e.g. `ci-shard-run -- bundle exec rspec`). One combined string with spaces is split via `Shellwords`, not a full shell; shell-only quoting does not apply.
49
+ - Timing JSON — Run `plan`, `queue init`, and `merge-timing` from the same repository root (cwd) you use when producing `polyrun_timing.json` so path keys normalize consistently. `Polyrun::Partition::Plan.load_timing_costs` and `TimingKeys.load_costs_json_file` accept `root:` to align keys to a fixed directory.
50
+ - Per-example timing (`--timing-granularity example`) — Experimental. Cost maps and plan items scale with example count, not file count; expect larger memory use and slower planning than file mode on big suites.
51
+
44
52
  ### Adopting Polyrun (setup profile and scaffolds)
45
53
 
46
54
  - [docs/SETUP_PROFILE.md](docs/SETUP_PROFILE.md) — Checklist for project type (gem, Rails, Appraisal), parallelism target (one CI job with N workers, matrix shards, or a single non-matrix runner), database layout, prepare, spec order, coverage, and CI model A (single runner with `parallel-rspec`) versus model B (matrix plus a merge-coverage job). Treat `polyrun.yml` as the contract; bin scripts and `database.yml` are adapters.
@@ -131,7 +139,19 @@ See [`examples/README.md`](examples/README.md) for Rails apps (Capybara, Playwri
131
139
 
132
140
  You can replace SimpleCov and simplecov plugins, parallel_tests, and rspec_junit_formatter with Polyrun for those roles. Use `merge-timing`, `report-timing`, and `Data::FactoryCounts` (optionally with `Data::FactoryInstrumentation`) for slow-file and factory metrics. YAML fixture batches and bulk inserts can use `Data::Fixtures` and `ParallelProvisioning` for shard-aware seeding; wire your own `truncate` and `load_seed` in hooks.
133
141
 
134
- ---
142
+ ## License
143
+
144
+ Released under the [MIT License](LICENSE). Copyright (c) 2026 Andrei Makarov.
145
+
146
+ ## Contributing
147
+
148
+ Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, tests, RuboCop, RBS, optional Trunk, and PR conventions. Community participation follows [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
149
+
150
+ ## Security
151
+
152
+ Do not open public issues for security vulnerabilities. See [SECURITY.md](SECURITY.md) for how to report them.
153
+
154
+ ## Sponsors
135
155
 
136
156
  Sponsored by [Kisko Labs](https://www.kiskolabs.com).
137
157
 
@@ -81,7 +81,7 @@ Model A — one job, N worker processes
81
81
  Model B — matrix of jobs (one shard per job)
82
82
 
83
83
  - Each job sets `POLYRUN_SHARD_INDEX` / `POLYRUN_SHARD_TOTAL` (and DB URLs per shard if needed).
84
- - Run `polyrun build-paths`, `polyrun plan`, then `bundle exec rspec` (or `bin/rspec_ci_shard`) for that shard only.
84
+ - Run `polyrun ci-shard-run -- bundle exec rspec` (or `ci-shard-rspec`), or `ci-shard-run -- bundle exec polyrun quick` / other runners; or the same steps manually (`bin/rspec_ci_shard` wrappers).
85
85
  - Upload `coverage/polyrun-fragment-*.json` (or named per shard).
86
86
  - A final `merge-coverage` job downloads artifacts and merges.
87
87
 
@@ -0,0 +1,65 @@
1
+ require "shellwords"
2
+
3
+ module Polyrun
4
+ class CLI
5
+ # One CI matrix job = one global shard (POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL), not +run-shards+
6
+ # workers on a single host. Runs +build-paths+, +plan+ for that shard, then +exec+ of a user command
7
+ # with that shard's paths appended (same argv pattern as +run-shards+ after +--+).
8
+ #
9
+ # After +--+, prefer **multiple argv tokens** (+bundle+, +exec+, +rspec+, …). A single token that
10
+ # contains spaces is split with +Shellwords+ (not a full shell); exotic quoting differs from +sh -c+.
11
+ module CiShardRunCommand
12
+ private
13
+
14
+ # @return [Array(Array<String>, Integer)] [paths, 0] on success, or [nil, exit_code] on failure
15
+ def ci_shard_planned_paths!(plan_argv, config_path, command_label:)
16
+ manifest, code = plan_command_compute_manifest(plan_argv, config_path)
17
+ return [nil, code] if code != 0
18
+
19
+ paths = manifest["paths"] || []
20
+ if paths.empty?
21
+ Polyrun::Log.warn "polyrun #{command_label}: no paths for this shard (check shard/total and paths list)"
22
+ return [nil, 2]
23
+ end
24
+
25
+ [paths, 0]
26
+ end
27
+
28
+ # Runner-agnostic matrix shard: +polyrun ci-shard-run [plan options] -- <command> [args...]+
29
+ # Paths for this shard are appended after the command (like +run-shards+).
30
+ def cmd_ci_shard_run(argv, config_path)
31
+ sep = argv.index("--")
32
+ unless sep
33
+ Polyrun::Log.warn "polyrun ci-shard-run: need -- before the command (e.g. ci-shard-run -- bundle exec rspec)"
34
+ return 2
35
+ end
36
+
37
+ plan_argv = argv[0...sep]
38
+ cmd = argv[(sep + 1)..].map(&:to_s)
39
+ if cmd.empty?
40
+ Polyrun::Log.warn "polyrun ci-shard-run: empty command after --"
41
+ return 2
42
+ end
43
+ cmd = Shellwords.split(cmd.first) if cmd.size == 1 && cmd.first.include?(" ")
44
+
45
+ paths, code = ci_shard_planned_paths!(plan_argv, config_path, command_label: "ci-shard-run")
46
+ return code if code != 0
47
+
48
+ exec(*cmd, *paths)
49
+ end
50
+
51
+ # Same as +ci-shard-run -- bundle exec rspec+ with an optional second segment for RSpec-only flags:
52
+ # +polyrun ci-shard-rspec [plan options] [-- [rspec args]]+
53
+ def cmd_ci_shard_rspec(argv, config_path)
54
+ sep = argv.index("--")
55
+ plan_argv = sep ? argv[0...sep] : argv
56
+ rspec_argv = sep ? argv[(sep + 1)..] : []
57
+
58
+ paths, code = ci_shard_planned_paths!(plan_argv, config_path, command_label: "ci-shard-rspec")
59
+ return code if code != 0
60
+
61
+ exec("bundle", "exec", "rspec", *rspec_argv, *paths)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ require "json"
2
+
3
+ require_relative "../config/effective"
4
+
5
+ module Polyrun
6
+ class CLI
7
+ module ConfigCommand
8
+ private
9
+
10
+ def cmd_config(argv, config_path)
11
+ dotted = argv.shift
12
+ if dotted.nil? || dotted.strip.empty?
13
+ Polyrun::Log.warn "polyrun config: need a dotted path (e.g. prepare.env.PLAYWRIGHT_ENV, partition.paths_file, workers)"
14
+ return 2
15
+ end
16
+ unless argv.empty?
17
+ Polyrun::Log.warn "polyrun config: unexpected arguments: #{argv.join(" ")}"
18
+ return 2
19
+ end
20
+
21
+ cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
22
+ val = Polyrun::Config::Effective.dig(cfg, dotted)
23
+ if val.nil?
24
+ Polyrun::Log.warn "polyrun config: no value for #{dotted}"
25
+ return 1
26
+ end
27
+
28
+ Polyrun::Log.puts format_config_value(val)
29
+ 0
30
+ end
31
+
32
+ def format_config_value(val)
33
+ case val
34
+ when Hash, Array
35
+ JSON.generate(val)
36
+ else
37
+ val.to_s
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,115 @@
1
+ require "tempfile"
2
+
3
+ module Polyrun
4
+ class CLI
5
+ # No-subcommand default (`polyrun`) and path-only argv (implicit parallel run).
6
+ module DefaultRun
7
+ private
8
+
9
+ def dispatch_default_parallel!(config_path)
10
+ suite = Polyrun::Partition::Paths.detect_auto_suite(Dir.pwd)
11
+ unless suite
12
+ Polyrun::Log.warn "polyrun: no tests found (spec/**/*_spec.rb, test/**/*_test.rb, or Polyrun quick files). See polyrun help."
13
+ return 2
14
+ end
15
+
16
+ Polyrun::Log.warn "polyrun: default → parallel #{suite} (use `polyrun help` for subcommands)" if @verbose
17
+
18
+ case suite
19
+ when :rspec
20
+ cmd_start([], config_path)
21
+ when :minitest
22
+ cmd_parallel_minitest([], config_path)
23
+ when :quick
24
+ cmd_parallel_quick([], config_path)
25
+ else
26
+ 2
27
+ end
28
+ end
29
+
30
+ # If +argv[0]+ is in {IMPLICIT_PATH_EXCLUSION_TOKENS}, treat as a normal subcommand. Otherwise, path-like
31
+ # tokens may trigger implicit parallel sharding (see +print_help+).
32
+ def implicit_parallel_run?(argv)
33
+ return false if argv.empty?
34
+ return false if Polyrun::CLI::IMPLICIT_PATH_EXCLUSION_TOKENS.include?(argv[0])
35
+
36
+ argv.any? { |a| cli_implicit_path_token?(a) }
37
+ end
38
+
39
+ def cli_implicit_path_token?(s)
40
+ return false if s.start_with?("-") && s != "-"
41
+ return true if s == "-"
42
+ return true if s.start_with?("./", "../", "/")
43
+ return true if s.end_with?(".rb")
44
+ return true if File.exist?(File.expand_path(s))
45
+ return true if /[*?\[]/.match?(s)
46
+
47
+ false
48
+ end
49
+
50
+ def dispatch_implicit_parallel_targets!(argv, config_path)
51
+ path_tokens = argv.select { |a| cli_implicit_path_token?(a) }
52
+ head = argv.reject { |a| cli_implicit_path_token?(a) }
53
+ expanded = expand_implicit_target_paths(path_tokens)
54
+ if expanded.empty?
55
+ Polyrun::Log.warn "polyrun: no files matched path arguments"
56
+ return 2
57
+ end
58
+
59
+ suite = Polyrun::Partition::Paths.infer_suite_from_paths(expanded)
60
+ if suite == :invalid
61
+ Polyrun::Log.warn "polyrun: mixing _spec.rb and _test.rb paths in one run is not supported"
62
+ return 2
63
+ end
64
+ if suite.nil?
65
+ Polyrun::Log.warn "polyrun: could not infer suite from paths"
66
+ return 2
67
+ end
68
+
69
+ tmp = Tempfile.new(["polyrun-paths-", ".txt"])
70
+ begin
71
+ tmp.write(expanded.join("\n") + "\n")
72
+ tmp.close
73
+ combined = head + ["--paths-file", tmp.path]
74
+ case suite
75
+ when :rspec
76
+ cmd_start(combined, config_path)
77
+ when :minitest
78
+ cmd_parallel_minitest(combined, config_path)
79
+ when :quick
80
+ cmd_parallel_quick(combined, config_path)
81
+ else
82
+ 2
83
+ end
84
+ ensure
85
+ tmp.close! unless tmp.closed?
86
+ begin
87
+ File.unlink(tmp.path)
88
+ rescue Errno::ENOENT
89
+ # already removed
90
+ end
91
+ end
92
+ end
93
+
94
+ def expand_implicit_target_paths(path_tokens)
95
+ path_tokens.flat_map do |p|
96
+ abs = File.expand_path(p)
97
+ if File.directory?(abs)
98
+ spec = Dir.glob(File.join(abs, "**", "*_spec.rb")).sort
99
+ test = Dir.glob(File.join(abs, "**", "*_test.rb")).sort
100
+ quick = Dir.glob(File.join(abs, "**", "*.rb")).sort.reject do |f|
101
+ File.basename(f).end_with?("_spec.rb", "_test.rb")
102
+ end
103
+ spec + test + quick
104
+ elsif /[*?\[]/.match?(p)
105
+ Dir.glob(abs).sort
106
+ elsif File.file?(abs)
107
+ [abs]
108
+ else
109
+ []
110
+ end
111
+ end.uniq
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,54 @@
1
+ module Polyrun
2
+ class CLI
3
+ module Help
4
+ def print_help
5
+ Polyrun::Log.puts <<~HELP
6
+ usage: polyrun [global options] [<command> | <paths...>]
7
+
8
+ With no command, runs parallel tests for the detected suite: RSpec under spec/, Minitest under test/, or Polyrun Quick (same discovery as polyrun quick). If the first argument is a known subcommand name, it is dispatched. Otherwise, path-like tokens (optionally with run-shards flags such as --workers) shard those files in parallel; see commands below.
9
+
10
+ global:
11
+ -c, --config PATH polyrun.yml path (or POLYRUN_CONFIG)
12
+ -v, --verbose
13
+ -h, --help
14
+
15
+ Trace timing (stderr): DEBUG=1 or POLYRUN_DEBUG=1
16
+ Branch coverage in JSON fragments: POLYRUN_COVERAGE_BRANCHES=1 (stdlib Coverage; merge-coverage merges branches)
17
+ polyrun quick coverage: POLYRUN_COVERAGE=1 or (config/polyrun_coverage.yml + POLYRUN_QUICK_COVERAGE=1); POLYRUN_COVERAGE_DISABLE=1 skips
18
+ Merge wall time (stderr): POLYRUN_PROFILE_MERGE=1 (or verbose / DEBUG)
19
+ Post-merge formats (run-shards): POLYRUN_MERGE_FORMATS (default: json,lcov,cobertura,console,html)
20
+ Skip optional script/build_spec_paths.rb before start: POLYRUN_SKIP_BUILD_SPEC_PATHS=1
21
+ Skip start auto-prepare / auto DB provision: POLYRUN_START_SKIP_PREPARE=1, POLYRUN_START_SKIP_DATABASES=1
22
+ Skip writing paths_file from partition.paths_build: POLYRUN_SKIP_PATHS_BUILD=1
23
+ Warn if merge-coverage wall time exceeds N seconds (default 10): POLYRUN_MERGE_SLOW_WARN_SECONDS (0 disables)
24
+ Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start)
25
+ Partition timing granularity (default file): POLYRUN_TIMING_GRANULARITY=file|example (experimental per-example; see partition.timing_granularity)
26
+
27
+ commands:
28
+ version print version
29
+ plan emit partition manifest JSON
30
+ prepare run prepare recipe: default | assets (optional prepare.command overrides bin/rails assets:precompile) | shell (prepare.command required)
31
+ merge-coverage merge SimpleCov JSON fragments (json/lcov/cobertura/console)
32
+ run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage
33
+ parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
34
+ start parallel-rspec; auto-runs prepare (shell/assets) and db:setup-* when polyrun.yml configures them; legacy script/build_spec_paths.rb if paths_build absent
35
+ ci-shard-run CI matrix: build-paths + plan for POLYRUN_SHARD_INDEX / POLYRUN_SHARD_TOTAL (or config), then run your command with that shard's paths after -- (like run-shards; not multi-worker)
36
+ ci-shard-rspec same as ci-shard-run -- bundle exec rspec; optional -- [rspec-only flags]
37
+ build-paths write partition.paths_file from partition.paths_build (same as auto step before plan/run-shards)
38
+ init write a starter polyrun.yml or POLYRUN.md from built-in templates (see docs/SETUP_PROFILE.md)
39
+ queue file-backed batch queue (init / claim / ack / status)
40
+ quick run Polyrun::Quick (describe/it, before/after, let, expect…to, assert_*; optional capybara!)
41
+ report-coverage write all coverage formats from one JSON file
42
+ report-junit RSpec JSON or Polyrun testcase JSON → JUnit XML (CI)
43
+ report-timing print slow-file summary from merged timing JSON
44
+ merge-timing merge polyrun_timing_*.json shards
45
+ config print effective config by dotted path (see Polyrun::Config::Effective; same tree as YAML plus merged prepare.env, resolved partition shard fields, workers)
46
+ env print shard + database env (see polyrun.yml databases)
47
+ db:setup-template migrate template DB (PostgreSQL)
48
+ db:setup-shard CREATE DATABASE shard FROM template (one POLYRUN_SHARD_INDEX)
49
+ db:clone-shards migrate templates + DROP/CREATE all shard DBs (replaces clone_shard shell scripts)
50
+ HELP
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,44 +1,22 @@
1
1
  require "yaml"
2
2
 
3
+ require_relative "../partition/timing_keys"
4
+
3
5
  module Polyrun
4
6
  class CLI
5
7
  module Helpers
6
8
  private
7
9
 
8
- def partition_int(pc, keys, default)
9
- keys.each do |k|
10
- v = pc[k] || pc[k.to_sym]
11
- next if v.nil? || v.to_s.empty?
12
-
13
- i = Integer(v, exception: false)
14
- return i unless i.nil?
15
- end
16
- default
17
- end
18
-
19
10
  def env_int(name, fallback)
20
- s = ENV[name]
21
- return fallback if s.nil? || s.empty?
22
-
23
- Integer(s, exception: false) || fallback
11
+ Polyrun::Config::Resolver.env_int(name, fallback)
24
12
  end
25
13
 
26
14
  def resolve_shard_index(pc)
27
- return Integer(ENV["POLYRUN_SHARD_INDEX"]) if ENV["POLYRUN_SHARD_INDEX"] && !ENV["POLYRUN_SHARD_INDEX"].empty?
28
-
29
- ci = Polyrun::Env::Ci.detect_shard_index
30
- return ci unless ci.nil?
31
-
32
- partition_int(pc, %w[shard_index shard], 0)
15
+ Polyrun::Config::Resolver.resolve_shard_index(pc)
33
16
  end
34
17
 
35
18
  def resolve_shard_total(pc)
36
- return Integer(ENV["POLYRUN_SHARD_TOTAL"]) if ENV["POLYRUN_SHARD_TOTAL"] && !ENV["POLYRUN_SHARD_TOTAL"].empty?
37
-
38
- ci = Polyrun::Env::Ci.detect_shard_total
39
- return ci unless ci.nil?
40
-
41
- partition_int(pc, %w[shard_total total], 1)
19
+ Polyrun::Config::Resolver.resolve_shard_total(pc)
42
20
  end
43
21
 
44
22
  def expand_merge_input_pattern(path)
@@ -95,9 +73,15 @@ module Polyrun
95
73
 
96
74
  # +default_weight+ should be precomputed when sorting many paths (e.g. +queue init+), matching
97
75
  # {Partition::Plan#default_weight} semantics: mean of known timing costs for missing paths.
98
- def queue_weight_for(path, costs, default_weight = nil)
99
- abs = File.expand_path(path.to_s, Dir.pwd)
100
- return costs[abs] if costs.key?(abs)
76
+ def queue_weight_for(path, costs, default_weight = nil, granularity: :file)
77
+ g = Polyrun::Partition::TimingKeys.normalize_granularity(granularity)
78
+ key =
79
+ if g == :example
80
+ Polyrun::Partition::TimingKeys.normalize_locator(path.to_s, Dir.pwd, :example)
81
+ else
82
+ File.expand_path(path.to_s, Dir.pwd)
83
+ end
84
+ return costs[key] if costs.key?(key)
101
85
 
102
86
  unless default_weight.nil?
103
87
  return default_weight
@@ -108,6 +92,11 @@ module Polyrun
108
92
 
109
93
  vals.sum / vals.size.to_f
110
94
  end
95
+
96
+ # CLI + polyrun.yml + POLYRUN_TIMING_GRANULARITY; default +:file+.
97
+ def resolve_partition_timing_granularity(pc, cli_val)
98
+ Polyrun::Config::Resolver.resolve_partition_timing_granularity(pc, cli_val)
99
+ end
111
100
  end
112
101
  end
113
102
  end
@@ -7,6 +7,15 @@ module Polyrun
7
7
  private
8
8
 
9
9
  def cmd_plan(argv, config_path)
10
+ manifest, code = plan_command_compute_manifest(argv, config_path)
11
+ return code if code != 0
12
+
13
+ Polyrun::Log.puts JSON.generate(manifest)
14
+ 0
15
+ end
16
+
17
+ # @return [Array(Hash, Integer)] manifest hash and exit code (+0+ on success, non-zero on failure)
18
+ def plan_command_compute_manifest(argv, config_path)
10
19
  cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
11
20
  pc = cfg.partition
12
21
  ctx = plan_command_initial_context(pc)
@@ -14,30 +23,44 @@ module Polyrun
14
23
 
15
24
  paths_file = ctx[:paths_file] || (pc["paths_file"] || pc[:paths_file])
16
25
  code = Polyrun::Partition::PathsBuild.apply!(partition: pc, cwd: Dir.pwd)
17
- return code if code != 0
26
+ return [nil, code] if code != 0
18
27
 
28
+ plan_command_manifest_from_paths(cfg, pc, argv, ctx, paths_file)
29
+ end
30
+
31
+ def plan_command_manifest_from_paths(cfg, pc, argv, ctx, paths_file)
19
32
  timing_path = plan_resolve_timing_path(pc, ctx[:timing_path], ctx[:strategy])
33
+ ctx[:timing_granularity] = resolve_partition_timing_granularity(pc, ctx[:timing_granularity])
20
34
  Polyrun::Log.warn "polyrun plan: using #{cfg.path}" if @verbose && cfg.path
21
35
 
22
- items = plan_plan_items(paths_file, argv)
23
- return 2 if items.nil?
24
-
25
- loaded = plan_load_costs_and_strategy(timing_path, ctx[:strategy])
26
- return 2 if loaded.nil?
27
-
28
- costs, strategy = loaded
36
+ bundle = plan_command_items_costs_strategy(paths_file, argv, timing_path, ctx)
37
+ return [nil, 2] if bundle.nil?
29
38
 
39
+ items, costs, strategy = bundle
30
40
  constraints = load_partition_constraints(pc, ctx[:constraints_path])
31
41
 
32
- plan_command_emit_manifest(
42
+ manifest = plan_command_build_manifest(
33
43
  items: items,
34
44
  total: ctx[:total],
35
45
  strategy: strategy,
36
46
  seed: ctx[:seed],
37
47
  costs: costs,
38
48
  constraints: constraints,
39
- shard: ctx[:shard]
49
+ shard: ctx[:shard],
50
+ timing_granularity: ctx[:timing_granularity]
40
51
  )
52
+ [manifest, 0]
53
+ end
54
+
55
+ def plan_command_items_costs_strategy(paths_file, argv, timing_path, ctx)
56
+ items = plan_plan_items(paths_file, argv)
57
+ return nil if items.nil?
58
+
59
+ loaded = plan_load_costs_and_strategy(timing_path, ctx[:strategy], ctx[:timing_granularity])
60
+ return nil if loaded.nil?
61
+
62
+ costs, strategy = loaded
63
+ [items, costs, strategy]
41
64
  end
42
65
 
43
66
  def plan_command_initial_context(pc)
@@ -48,7 +71,8 @@ module Polyrun
48
71
  seed: pc["seed"] || pc[:seed],
49
72
  paths_file: nil,
50
73
  timing_path: nil,
51
- constraints_path: nil
74
+ constraints_path: nil,
75
+ timing_granularity: nil
52
76
  }
53
77
  end
54
78
 
@@ -64,10 +88,13 @@ module Polyrun
64
88
  opts.on("--timing PATH", "path => seconds JSON; implies cost_binpack unless strategy is cost-based or hrw") do |v|
65
89
  ctx[:timing_path] = v
66
90
  end
91
+ opts.on("--timing-granularity VAL", "file (default) or example (experimental: path:line items)") do |v|
92
+ ctx[:timing_granularity] = v
93
+ end
67
94
  end.parse!(argv)
68
95
  end
69
96
 
70
- def plan_command_emit_manifest(items:, total:, strategy:, seed:, costs:, constraints:, shard:)
97
+ def plan_command_build_manifest(items:, total:, strategy:, seed:, costs:, constraints:, shard:, timing_granularity: :file)
71
98
  plan = Polyrun::Debug.time("Partition::Plan.new (plan command)") do
72
99
  Polyrun::Partition::Plan.new(
73
100
  items: items,
@@ -76,7 +103,8 @@ module Polyrun
76
103
  seed: seed,
77
104
  costs: costs,
78
105
  constraints: constraints,
79
- root: Dir.pwd
106
+ root: Dir.pwd,
107
+ timing_granularity: timing_granularity
80
108
  )
81
109
  end
82
110
  Polyrun::Debug.log_kv(
@@ -86,8 +114,7 @@ module Polyrun
86
114
  strategy: strategy,
87
115
  path_count: items.size
88
116
  )
89
- Polyrun::Log.puts JSON.generate(plan.manifest(shard))
90
- 0
117
+ plan.manifest(shard)
91
118
  end
92
119
 
93
120
  def plan_resolve_timing_path(pc, timing_path, strategy)
@@ -110,9 +137,12 @@ module Polyrun
110
137
  end
111
138
  end
112
139
 
113
- def plan_load_costs_and_strategy(timing_path, strategy)
140
+ def plan_load_costs_and_strategy(timing_path, strategy, timing_granularity)
114
141
  if timing_path
115
- costs = Polyrun::Partition::Plan.load_timing_costs(File.expand_path(timing_path.to_s, Dir.pwd))
142
+ costs = Polyrun::Partition::Plan.load_timing_costs(
143
+ File.expand_path(timing_path.to_s, Dir.pwd),
144
+ granularity: timing_granularity
145
+ )
116
146
  if costs.empty?
117
147
  Polyrun::Log.warn "polyrun plan: timing file missing or has no entries: #{timing_path}"
118
148
  return nil
@@ -1,5 +1,4 @@
1
1
  require "json"
2
- require "open3"
3
2
  require "optparse"
4
3
 
5
4
  require_relative "prepare_recipe"
@@ -20,8 +19,8 @@ module Polyrun
20
19
  cfg = Polyrun::Config.load(path: config_path || ENV["POLYRUN_CONFIG"])
21
20
  prep = cfg.prepare
22
21
  recipe = prep["recipe"] || prep[:recipe] || "default"
23
- prep_env = (prep["env"] || prep[:env] || {}).transform_keys(&:to_s).transform_values(&:to_s)
24
- child_env = prep_env.empty? ? nil : ENV.to_h.merge(prep_env)
22
+ prep_env = Polyrun::Config::Resolver.prepare_env_yaml_string_map(prep)
23
+ child_env = prep_env.empty? ? nil : Polyrun::Config::Resolver.merged_prepare_env(prep)
25
24
  manifest = prepare_build_manifest(recipe, dry, prep_env)
26
25
 
27
26
  exit_code = prepare_dispatch_recipe(manifest, prep, recipe, dry, child_env)