polyrun 1.4.0 → 1.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04bfc3ed1a2c01072864dd179decce4d13c68221bba4da0964f4ea600401b57c
4
- data.tar.gz: df02fc828fb8ac8c9cf3792ae08420ee87d328a89d79a7eeec26c9e239506ee4
3
+ metadata.gz: 7666af9186562083f29dc56e6c867e48b877acdff6ad28ff8c351e8d3c308582
4
+ data.tar.gz: 503f5435deb22112044f7841a82728e6782a770eb656859419e8412d623dcff0
5
5
  SHA512:
6
- metadata.gz: 378921ebc46b80562c5ae4bb529a3035eed7366cbfbcffd9a5c4bcb1d18f1c1fe6b737dfcc580c9b7e3fb1ce4360ac9ad04c7a7edbc50e4763a2e5430a61c873
7
- data.tar.gz: 552545582ab8c34e1411834d5f5ef6d1b6f1d022ead68746059b709cd04d733c6d778b13621c003ec2f4525367bdb5e849577d109e2e04835fcf9b12407bf2ef
6
+ metadata.gz: d0d5d248f1e072c446049bafff111db6e29d784a4a0992528214a6e29cca7b156b67144e6b9c22fc71fa9143f110ac443cda2953219aa816c5562d3247b5e02b
7
+ data.tar.gz: d0be776ce5d4a7a5acacfe237a4d07a47c078562aee6a054fb5b63a31a78e199bc2b5ee276b0a8e77a0f0cacd3e4f03cb72d8fd5037baf782d0efb8a1cdf1b04
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.4.1 (2026-04-16)
4
+
5
+ - Add `polyrun merge-failures` and `run-shards --merge-failures` / `--merge-failures-output` / `--merge-failures-format`; merge per-worker JSONL under `tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl` (or RSpec JSON via `-i`). Run merge after all workers exit, including when a shard failed (`--merge-coverage` still runs only after all shards succeed).
6
+ - Add `Polyrun::Reporting::FailureMerge`, `Polyrun::RSpec.install_failure_fragments!`, and `Polyrun::Reporting::RspecFailureFragmentFormatter`; parent sets `POLYRUN_FAILURE_FRAGMENTS=1` on workers when merge-failures is enabled.
7
+ - Add optional `reporting:` in `polyrun.yml` and `Polyrun::Config#reporting` for merge-failures toggles and paths; honor `POLYRUN_MERGE_FAILURES`, `POLYRUN_MERGED_FAILURES_OUT`, `POLYRUN_MERGED_FAILURES_FORMAT`; set `POLYRUN_MERGED_FAILURES_PATH` for `after_suite` when merge wrote a file.
8
+ - On bad JSONL or non-RSpec JSON, raise `Polyrun::Error` with path and line where applicable; `merge-failures` exits 1 without a full stack trace; a failed merge after successful workers forces `run-shards` exit 1.
9
+ - Fix `run_shards_plan_ready_log` to take `cfg` so debug logging for merge-failures does not raise `NameError`.
10
+
3
11
  ## 1.4.0 (2026-04-16)
4
12
 
5
13
  - Add `hooks:` in `polyrun.yml` — shell commands for `before_suite` / `after_suite`, `before_shard` / `after_shard`, `before_worker` / `after_worker` (RSpec-style YAML keys `before(:suite)`, `before(:all)`, `before(:each)` accepted). Wire hooks into `run-shards`, `parallel-rspec`, and `ci-shard-*`.
@@ -0,0 +1,92 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "optparse"
4
+
5
+ require_relative "../reporting/failure_merge"
6
+
7
+ module Polyrun
8
+ class CLI
9
+ module FailureCommands
10
+ private
11
+
12
+ def cmd_merge_failures(argv, _config_path)
13
+ inputs, output, format = merge_failures_parse_argv(argv)
14
+ if inputs.empty?
15
+ Polyrun::Log.warn "merge-failures: need at least one existing -i FILE (after glob expansion)"
16
+ return 2
17
+ end
18
+ Polyrun::Log.warn "merge-failures: merging #{inputs.size} fragment(s)" if @verbose
19
+ n = merge_failures_merge_files_or_warn!(inputs, output: output, format: format)
20
+ return 1 if n.nil?
21
+
22
+ Polyrun::Log.puts File.expand_path(output)
23
+ Polyrun::Log.warn "merge-failures: #{n} failure row(s)" if @verbose
24
+ 0
25
+ end
26
+
27
+ def merge_failures_merge_files_or_warn!(inputs, output:, format:)
28
+ Polyrun::Reporting::FailureMerge.merge_files!(inputs, output: output, format: format)
29
+ rescue Polyrun::Error => e
30
+ Polyrun::Log.warn e.message.to_s
31
+ nil
32
+ end
33
+
34
+ def merge_failures_parse_argv(argv)
35
+ inputs = []
36
+ output = File.join("tmp", "polyrun_failures", "merged.jsonl")
37
+ format = "jsonl"
38
+ OptionParser.new do |opts|
39
+ opts.banner = "usage: polyrun merge-failures -i FILE [-i FILE] [-o PATH] [--format jsonl|json]"
40
+ opts.on("-i", "--input FILE", "JSONL fragment or RSpec JSON (repeatable; globs ok)") do |f|
41
+ expand_merge_input_pattern(f).each { |x| inputs << x }
42
+ end
43
+ opts.on("-o", "--output PATH", String) { |v| output = v }
44
+ opts.on("--format VAL", "jsonl (default) or json") { |v| format = v }
45
+ end.parse!(argv)
46
+ inputs.uniq!
47
+ inputs.select! { |p| File.file?(p) }
48
+ [inputs, output, format]
49
+ end
50
+
51
+ # After run-shards workers exit: merge polyrun failure fragments when requested.
52
+ # Runs even when shards failed (unlike --merge-coverage).
53
+ # @return [String, nil] absolute path to merged file, or nil when skipped / nothing written
54
+ def merge_failures_after_shards(ctx)
55
+ return nil unless ctx[:merge_failures]
56
+
57
+ pattern = Polyrun::Reporting::FailureMerge.default_fragment_glob
58
+ files = Dir.glob(pattern).sort
59
+ if files.empty?
60
+ Polyrun::Log.warn "polyrun run-shards: --merge-failures: no #{Polyrun::Reporting::FailureMerge::FRAGMENT_GLOB} under fragment dir (enable Polyrun::RSpec.install_failure_fragments! in spec_helper?)"
61
+ return nil
62
+ end
63
+
64
+ fmt = merge_failures_resolved_format(ctx)
65
+ out = merge_failures_resolved_output_path(ctx, fmt)
66
+ Polyrun::Log.warn "polyrun run-shards: merging #{files.size} failure fragment(s) → #{out} (#{fmt})"
67
+ Polyrun::Debug.log_kv(merge_failures: "start", output: out, inputs: files, format: fmt)
68
+ n = Polyrun::Reporting::FailureMerge.merge_files!(files, output: out, format: fmt)
69
+ Polyrun::Debug.log_kv(merge_failures: "done", rows: n, output: File.expand_path(out))
70
+ File.expand_path(out)
71
+ end
72
+
73
+ def merge_failures_resolved_format(ctx)
74
+ f = ctx[:merge_failures_format].to_s.strip.downcase
75
+ return "jsonl" if f.empty?
76
+ return "jsonl" if f == "jsonl"
77
+ return "json" if f == "json"
78
+
79
+ Polyrun::Log.warn "polyrun run-shards: unknown merge_failures_format=#{ctx[:merge_failures_format].inspect}; using jsonl"
80
+ "jsonl"
81
+ end
82
+
83
+ def merge_failures_resolved_output_path(ctx, fmt)
84
+ raw = ctx[:merge_failures_output]
85
+ return File.expand_path(raw) if raw && !raw.to_s.strip.empty?
86
+
87
+ ext = (fmt == "json") ? "json" : "jsonl"
88
+ File.expand_path(File.join("tmp", "polyrun_failures", "merged.#{ext}"))
89
+ end
90
+ end
91
+ end
92
+ end
@@ -21,6 +21,7 @@ module Polyrun
21
21
  Skip start auto-prepare / auto DB provision: POLYRUN_START_SKIP_PREPARE=1, POLYRUN_START_SKIP_DATABASES=1
22
22
  Skip writing paths_file from partition.paths_build: POLYRUN_SKIP_PATHS_BUILD=1
23
23
  Warn if merge-coverage wall time exceeds N seconds (default 10): POLYRUN_MERGE_SLOW_WARN_SECONDS (0 disables)
24
+ Failure fragments (run-shards --merge-failures): POLYRUN_MERGE_FAILURES=1; parent sets POLYRUN_FAILURE_FRAGMENTS=1 in workers; POLYRUN_FAILURE_FRAGMENT_DIR, POLYRUN_MERGED_FAILURES_OUT, POLYRUN_MERGED_FAILURES_FORMAT; after_suite sets POLYRUN_MERGED_FAILURES_PATH when merge ran
24
25
  Parallel RSpec workers: POLYRUN_WORKERS default 5, max 10 (run-shards / parallel-rspec / start); distinct from POLYRUN_SHARD_PROCESSES / ci-shard --shard-processes (local processes per CI matrix job)
25
26
  Partition timing granularity (default file): POLYRUN_TIMING_GRANULARITY=file|example (experimental per-example; see partition.timing_granularity)
26
27
 
@@ -29,7 +30,8 @@ module Polyrun
29
30
  plan emit partition manifest JSON
30
31
  prepare run prepare recipe: default | assets (optional prepare.command overrides bin/rails assets:precompile) | shell (prepare.command required)
31
32
  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
+ merge-failures merge per-shard failure JSONL fragments or RSpec JSON files (jsonl/json)
34
+ run-shards fan out N parallel OS processes (POLYRUN_SHARD_*; not Ruby threads); optional --merge-coverage / --merge-failures
33
35
  parallel-rspec run-shards + merge-coverage (defaults to: bundle exec rspec after --)
34
36
  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
37
  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 --; optional --shard-processes M or --workers M (POLYRUN_SHARD_PROCESSES; not POLYRUN_WORKERS) for N×M jobs × processes on this host
@@ -2,12 +2,14 @@ require "optparse"
2
2
  require "rbconfig"
3
3
 
4
4
  require_relative "start_bootstrap"
5
+ require_relative "failure_commands"
5
6
  require_relative "run_shards_run"
6
7
 
7
8
  module Polyrun
8
9
  class CLI
9
10
  module RunShardsCommand
10
11
  include StartBootstrap
12
+ include FailureCommands
11
13
  include RunShardsRun
12
14
 
13
15
  private
@@ -114,10 +116,11 @@ module Polyrun
114
116
  # ENV for a worker process: POLYRUN_SHARD_* plus per-shard database URLs from polyrun.yml or DATABASE_URL.
115
117
  # When +matrix_total+ > 1 with multiple local workers, sets +POLYRUN_SHARD_MATRIX_INDEX+ / +POLYRUN_SHARD_MATRIX_TOTAL+
116
118
  # so {Coverage::Collector} can name fragments uniquely across CI matrix jobs (NxM sharding).
117
- def shard_child_env(cfg:, workers:, shard:, matrix_index: nil, matrix_total: nil)
119
+ def shard_child_env(cfg:, workers:, shard:, matrix_index: nil, matrix_total: nil, failure_fragments: false)
118
120
  child_env = ENV.to_h.merge(
119
121
  Polyrun::Database::Shard.env_map(shard_index: shard, shard_total: workers)
120
122
  )
123
+ child_env["POLYRUN_FAILURE_FRAGMENTS"] = "1" if failure_fragments
121
124
  mt = matrix_total.nil? ? 0 : Integer(matrix_total)
122
125
  if mt > 1
123
126
  if matrix_index.nil?
@@ -34,7 +34,14 @@ module Polyrun
34
34
  return [pids, code]
35
35
  end
36
36
 
37
- child_env = shard_child_env(cfg: cfg, workers: workers, shard: shard, matrix_index: mx, matrix_total: mt)
37
+ child_env = shard_child_env(
38
+ cfg: cfg,
39
+ workers: workers,
40
+ shard: shard,
41
+ matrix_index: mx,
42
+ matrix_total: mt,
43
+ failure_fragments: ctx[:merge_failures]
44
+ )
38
45
  child_env = child_env.merge("POLYRUN_HOOK_ORCHESTRATOR" => "0")
39
46
  child_env = hook_cfg.merge_worker_ruby_env(child_env)
40
47
 
@@ -31,7 +31,7 @@ module Polyrun
31
31
  costs, strategy, err = run_shards_resolve_costs(o[:timing_path], o[:strategy], o[:timing_granularity])
32
32
  return [err, nil] if err
33
33
 
34
- run_shards_plan_ready_log(o, strategy, cmd, paths_source, items.size)
34
+ run_shards_plan_ready_log(o, cfg, strategy, cmd, paths_source, items.size)
35
35
 
36
36
  constraints = load_partition_constraints(pc, o[:constraints_path])
37
37
  plan = run_shards_make_plan(items, o[:workers], strategy, o[:seed], costs, constraints, o[:timing_granularity])
@@ -59,12 +59,13 @@ module Polyrun
59
59
  [run_t0, head, cmd, cfg, cfg.partition]
60
60
  end
61
61
 
62
- def run_shards_plan_ready_log(o, strategy, cmd, paths_source, item_count)
62
+ def run_shards_plan_ready_log(o, cfg, strategy, cmd, paths_source, item_count)
63
63
  Polyrun::Debug.log_kv(
64
64
  run_shards: "ready to partition",
65
65
  workers: o[:workers],
66
66
  strategy: strategy,
67
67
  merge_coverage: o[:merge_coverage],
68
+ merge_failures: run_shards_merge_failures_flag(o, cfg),
68
69
  command: cmd,
69
70
  timing_path: o[:timing_path],
70
71
  paths_source: paths_source,
@@ -72,6 +73,37 @@ module Polyrun
72
73
  )
73
74
  end
74
75
 
76
+ def run_shards_merge_failures_flag(o, cfg)
77
+ return true if o[:merge_failures]
78
+ return true if %w[1 true yes].include?(ENV["POLYRUN_MERGE_FAILURES"].to_s.downcase)
79
+
80
+ rep = cfg.reporting
81
+ v = rep["merge_failures"] || rep[:merge_failures]
82
+ v == true || %w[1 true yes].include?(v.to_s.downcase)
83
+ end
84
+
85
+ def run_shards_merge_failures_output_opt(o, cfg)
86
+ x = o[:merge_failures_output]
87
+ return x if x && !x.to_s.strip.empty?
88
+
89
+ x = ENV["POLYRUN_MERGED_FAILURES_OUT"]
90
+ return x if x && !x.to_s.strip.empty?
91
+
92
+ rep = cfg.reporting
93
+ rep["merge_failures_output"] || rep[:merge_failures_output]
94
+ end
95
+
96
+ def run_shards_merge_failures_format_opt(o, cfg)
97
+ x = o[:merge_failures_format]
98
+ return x if x && !x.to_s.strip.empty?
99
+
100
+ x = ENV["POLYRUN_MERGED_FAILURES_FORMAT"]
101
+ return x if x && !x.to_s.strip.empty?
102
+
103
+ rep = cfg.reporting
104
+ rep["merge_failures_format"] || rep[:merge_failures_format]
105
+ end
106
+
75
107
  def run_shards_plan_context_hash(o, cmd, cfg, plan, run_t0, parallel, config_path)
76
108
  {
77
109
  workers: o[:workers],
@@ -83,6 +115,9 @@ module Polyrun
83
115
  merge_coverage: o[:merge_coverage],
84
116
  merge_output: o[:merge_output],
85
117
  merge_format: o[:merge_format],
118
+ merge_failures: run_shards_merge_failures_flag(o, cfg),
119
+ merge_failures_output: run_shards_merge_failures_output_opt(o, cfg),
120
+ merge_failures_format: run_shards_merge_failures_format_opt(o, cfg),
86
121
  config_path: config_path
87
122
  }
88
123
  end
@@ -24,7 +24,10 @@ module Polyrun
24
24
  timing_granularity: nil,
25
25
  merge_coverage: false,
26
26
  merge_output: nil,
27
- merge_format: nil
27
+ merge_format: nil,
28
+ merge_failures: false,
29
+ merge_failures_output: nil,
30
+ merge_failures_format: nil
28
31
  }
29
32
  end
30
33
 
@@ -34,8 +37,9 @@ module Polyrun
34
37
  end.parse!(head)
35
38
  end
36
39
 
40
+ # rubocop:disable Metrics/AbcSize -- one argv block for run-shards
37
41
  def run_shards_plan_options_register!(opts, st)
38
- opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--timing-granularity VAL] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--] <command> [args...]"
42
+ opts.banner = "usage: polyrun run-shards [--workers N] [--strategy NAME] [--paths-file P] [--timing P] [--timing-granularity VAL] [--constraints P] [--seed S] [--merge-coverage] [--merge-output P] [--merge-format LIST] [--merge-failures] [--merge-failures-output P] [--merge-failures-format jsonl|json] [--] <command> [args...]"
39
43
  opts.on("--workers N", Integer) { |v| st[:workers] = v }
40
44
  opts.on("--strategy NAME", String) { |v| st[:strategy] = v }
41
45
  opts.on("--seed VAL") { |v| st[:seed] = v }
@@ -46,7 +50,11 @@ module Polyrun
46
50
  opts.on("--merge-coverage", "After success, merge coverage/polyrun-fragment-*.json (Polyrun coverage must be enabled)") { st[:merge_coverage] = true }
47
51
  opts.on("--merge-output PATH", String) { |v| st[:merge_output] = v }
48
52
  opts.on("--merge-format LIST", String) { |v| st[:merge_format] = v }
53
+ opts.on("--merge-failures", "After all workers exit, merge tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl (use Polyrun::RSpec.install_failure_fragments!)") { st[:merge_failures] = true }
54
+ opts.on("--merge-failures-output PATH", String) { |v| st[:merge_failures_output] = v }
55
+ opts.on("--merge-failures-format VAL", "jsonl (default) or json") { |v| st[:merge_failures_format] = v }
49
56
  end
57
+ # rubocop:enable Metrics/AbcSize
50
58
  end
51
59
  end
52
60
  end
@@ -25,6 +25,8 @@ module Polyrun
25
25
  hook_cfg = Polyrun::Hooks.from_config(ctx[:cfg])
26
26
  suite_started = false
27
27
  exit_code = 1
28
+ merged_failures_path = nil
29
+ merge_failures_errored = false
28
30
 
29
31
  begin
30
32
  env_suite = ENV.to_h.merge(
@@ -60,8 +62,20 @@ module Polyrun
60
62
  Polyrun::Log.warn "polyrun run-shards: finished #{pids.size} worker(s)" + (failed.any? ? " (some failed)" : " (exit 0)")
61
63
  end
62
64
 
65
+ if ctx[:merge_failures]
66
+ begin
67
+ merged_failures_path = merge_failures_after_shards(ctx)
68
+ rescue Polyrun::Error => e
69
+ Polyrun::Log.warn e.message.to_s
70
+ merge_failures_errored = true
71
+ end
72
+ end
73
+
63
74
  if failed.any?
64
- run_shards_log_failed_reruns(failed, shard_results, ctx[:plan], ctx[:parallel], ctx[:workers], ctx[:cmd])
75
+ run_shards_log_failed_reruns(
76
+ failed, shard_results, ctx[:plan], ctx[:parallel], ctx[:workers], ctx[:cmd],
77
+ merge_failures: ctx[:merge_failures]
78
+ )
65
79
  exit_code = 1
66
80
  exit_code = 1 if wait_hook_err != 0
67
81
  return exit_code
@@ -69,13 +83,15 @@ module Polyrun
69
83
 
70
84
  exit_code = run_shards_merge_or_hint_coverage(ctx)
71
85
  exit_code = 1 if wait_hook_err != 0 && exit_code == 0
86
+ exit_code = 1 if merge_failures_errored && exit_code == 0
72
87
  exit_code
73
88
  ensure
74
89
  if suite_started
75
90
  env_after = ENV.to_h.merge(
76
91
  "POLYRUN_HOOK_ORCHESTRATOR" => "1",
77
92
  "POLYRUN_SHARD_TOTAL" => ctx[:workers].to_s,
78
- "POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s
93
+ "POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s,
94
+ "POLYRUN_MERGED_FAILURES_PATH" => merged_failures_path.to_s
79
95
  )
80
96
  hook_cfg.run_phase_if_enabled(:after_suite, env_after)
81
97
  end
@@ -139,7 +155,7 @@ module Polyrun
139
155
  0
140
156
  end
141
157
 
142
- def run_shards_log_failed_reruns(failed, shard_results, plan, parallel, workers, cmd)
158
+ def run_shards_log_failed_reruns(failed, shard_results, plan, parallel, workers, cmd, merge_failures: false)
143
159
  exit_by_shard = shard_results.each_with_object({}) { |r, h| h[r[:shard]] = r[:exitstatus] }
144
160
  failed_detail = failed.sort.map { |s| "#{s} (exit #{exit_by_shard[s]})" }.join(", ")
145
161
  Polyrun::Log.warn "polyrun run-shards: failed shard(s): #{failed_detail}"
@@ -154,6 +170,9 @@ module Polyrun
154
170
  rerun << Shellwords.join(cmd + paths)
155
171
  Polyrun::Log.warn "polyrun run-shards: shard #{s} re-run (same spec list, no interleave): #{rerun}"
156
172
  end
173
+ unless merge_failures
174
+ Polyrun::Log.warn "polyrun run-shards: one merged failure report — use run-shards --merge-failures with Polyrun::RSpec.install_failure_fragments!; POLYRUN_MERGED_FAILURES_PATH is set on after_suite when merge runs."
175
+ end
157
176
  end
158
177
  end
159
178
  end
data/lib/polyrun/cli.rb CHANGED
@@ -28,7 +28,7 @@ module Polyrun
28
28
 
29
29
  # Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
30
30
  DISPATCH_SUBCOMMAND_NAMES = %w[
31
- plan prepare merge-coverage report-coverage report-junit report-timing
31
+ plan prepare merge-coverage merge-failures report-coverage report-junit report-timing
32
32
  env config merge-timing db:setup-template db:setup-shard db:clone-shards
33
33
  run-shards parallel-rspec start build-paths init queue quick hook
34
34
  ].freeze
@@ -145,6 +145,8 @@ module Polyrun
145
145
  cmd_prepare(argv, config_path)
146
146
  when "merge-coverage"
147
147
  cmd_merge_coverage(argv, config_path)
148
+ when "merge-failures"
149
+ cmd_merge_failures(argv, config_path)
148
150
  when "report-coverage"
149
151
  cmd_report_coverage(argv)
150
152
  when "report-junit"
@@ -66,6 +66,11 @@ module Polyrun
66
66
  def hooks
67
67
  raw["hooks"] || raw[:hooks] || {}
68
68
  end
69
+
70
+ # Optional +reporting:+ block (merge-failures output paths, etc.).
71
+ def reporting
72
+ raw["reporting"] || raw[:reporting] || {}
73
+ end
69
74
  end
70
75
  end
71
76
 
@@ -0,0 +1,135 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Polyrun
5
+ module Reporting
6
+ # Merge per-worker / per-shard failure fragments (JSONL or RSpec JSON) into one report.
7
+ # Fragment basenames align with {Coverage::CollectorFragmentMeta} (worker index and optional matrix shard).
8
+ module FailureMerge
9
+ DEFAULT_FRAGMENT_DIR = "tmp/polyrun_failures".freeze
10
+ FRAGMENT_GLOB = "polyrun-failure-fragment-*.jsonl".freeze
11
+
12
+ module_function
13
+
14
+ def default_fragment_glob(dir = nil)
15
+ root = File.expand_path(dir || DEFAULT_FRAGMENT_DIR, Dir.pwd)
16
+ File.join(root, FRAGMENT_GLOB)
17
+ end
18
+
19
+ def merge_fragment_paths(quiet: false)
20
+ p = default_fragment_glob
21
+ Dir.glob(p).sort.tap do |paths|
22
+ Polyrun::Log.warn "merge-failures: no files matched #{p}" if paths.empty? && !quiet
23
+ end
24
+ end
25
+
26
+ # @param paths [Array<String>] fragment paths (.jsonl and/or RSpec --format json outputs)
27
+ # @param format [String] "jsonl" or "json"
28
+ # @param output [String] destination path
29
+ # @return [Integer] count of failure rows merged
30
+ def merge_files!(paths, output:, format: "jsonl")
31
+ fmt = format.to_s.downcase
32
+ rows = collect_rows(paths)
33
+ out_abs = File.expand_path(output)
34
+ FileUtils.mkdir_p(File.dirname(out_abs))
35
+ case fmt
36
+ when "json"
37
+ doc = {
38
+ "meta" => {
39
+ "polyrun_merge" => true,
40
+ "inputs" => paths.map { |p| File.expand_path(p) },
41
+ "failure_count" => rows.size
42
+ },
43
+ "failures" => rows
44
+ }
45
+ File.write(out_abs, JSON.generate(doc))
46
+ when "jsonl"
47
+ File.write(out_abs, rows.map { |h| JSON.generate(h) }.join("\n") + (rows.empty? ? "" : "\n"))
48
+ else
49
+ raise Polyrun::Error, "merge-failures: unknown format #{fmt.inspect} (use jsonl or json)"
50
+ end
51
+ rows.size
52
+ end
53
+
54
+ def collect_rows(paths)
55
+ rows = []
56
+ paths.each do |p|
57
+ rows.concat(rows_from_path(p))
58
+ end
59
+ rows
60
+ end
61
+
62
+ def rows_from_path(path)
63
+ ext = File.extname(path).downcase
64
+ if ext == ".jsonl"
65
+ return rows_from_jsonl_file(path)
66
+ end
67
+
68
+ text = File.read(path)
69
+ data =
70
+ begin
71
+ JSON.parse(text)
72
+ rescue JSON::ParserError => e
73
+ raise Polyrun::Error, "merge-failures: #{path} is not valid JSON: #{e.message}"
74
+ end
75
+ if data.is_a?(Hash) && data["examples"].is_a?(Array)
76
+ return failures_from_rspec_examples(data["examples"])
77
+ end
78
+
79
+ hint =
80
+ if data.is_a?(Hash)
81
+ keys = data.keys
82
+ "got JSON object with keys: #{keys.take(12).join(", ")}" + ((keys.size > 12) ? ", …" : "")
83
+ else
84
+ "got #{data.class}"
85
+ end
86
+ raise Polyrun::Error,
87
+ "merge-failures: #{path} is not RSpec JSON (expected top-level \"examples\" array). #{hint}. " \
88
+ "Use RSpec --format json, or polyrun failure JSONL (.jsonl fragments)."
89
+ end
90
+
91
+ def rows_from_jsonl_file(path)
92
+ acc = []
93
+ File.readlines(path, chomp: true).each_with_index do |line, idx|
94
+ line = line.strip
95
+ next if line.empty?
96
+
97
+ acc << parse_jsonl_line!(path, idx + 1, line)
98
+ end
99
+ acc
100
+ end
101
+
102
+ def parse_jsonl_line!(path, line_number, line)
103
+ JSON.parse(line)
104
+ rescue JSON::ParserError => e
105
+ raise Polyrun::Error,
106
+ "merge-failures: invalid JSONL at #{path} line #{line_number}: #{e.message}"
107
+ end
108
+
109
+ def failures_from_rspec_examples(examples)
110
+ examples.each_with_object([]) do |ex, acc|
111
+ next unless ex.is_a?(Hash)
112
+ next unless ex["status"].to_s == "failed"
113
+
114
+ acc << rspec_example_to_row(ex)
115
+ end
116
+ end
117
+
118
+ def rspec_example_to_row(ex)
119
+ ex = ex.transform_keys(&:to_s)
120
+ exc = ex["exception"] || {}
121
+ exc = exc.transform_keys(&:to_s) if exc.is_a?(Hash)
122
+ {
123
+ "id" => ex["id"],
124
+ "full_description" => ex["full_description"],
125
+ "location" => (ex["file_path"] && ex["line_number"]) ? "#{ex["file_path"]}:#{ex["line_number"]}" : ex["full_description"],
126
+ "file_path" => ex["file_path"],
127
+ "line_number" => ex["line_number"],
128
+ "message" => exc["message"] || ex["full_description"],
129
+ "exception_class" => exc["class"],
130
+ "source" => "rspec_json"
131
+ }.compact
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,95 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Polyrun
5
+ module Reporting
6
+ # RSpec formatter: appends one JSON object per failed example to the shard fragment file.
7
+ # Enable via +Polyrun::RSpec.install_failure_fragments!+ and +POLYRUN_FAILURE_FRAGMENTS=1+ (set by run-shards --merge-failures).
8
+ #
9
+ # Output: +tmp/polyrun_failures/polyrun-failure-fragment-<workerN|shardM-workerN>.jsonl+
10
+ # (same basename rules as {Coverage::CollectorFragmentMeta}.)
11
+ class RspecFailureFragmentFormatter
12
+ ::RSpec::Core::Formatters.register self, :start, :example_failed
13
+
14
+ attr_reader :output
15
+
16
+ def initialize(output)
17
+ @output = output
18
+ @path = fragment_path
19
+ end
20
+
21
+ def start(_notification)
22
+ FileUtils.mkdir_p(File.dirname(@path))
23
+ File.write(@path, "")
24
+ end
25
+
26
+ def example_failed(notification)
27
+ ex = notification.example
28
+ exc = notification.exception
29
+ row = {
30
+ "id" => ex.id,
31
+ "full_description" => ex.full_description,
32
+ "location" => ex.location,
33
+ "file_path" => ex.file_path,
34
+ "line_number" => example_line_number(ex),
35
+ "message" => exc.message.to_s,
36
+ "exception_class" => exc.class.name,
37
+ "polyrun_shard_index" => ENV["POLYRUN_SHARD_INDEX"],
38
+ "polyrun_shard_total" => ENV["POLYRUN_SHARD_TOTAL"],
39
+ "polyrun_shard_matrix_index" => matrix_env_or_nil("POLYRUN_SHARD_MATRIX_INDEX"),
40
+ "polyrun_shard_matrix_total" => matrix_env_or_nil("POLYRUN_SHARD_MATRIX_TOTAL"),
41
+ "rspec_seed" => seed_if_known,
42
+ "rspec_order" => order_if_known
43
+ }
44
+ trim_backtrace!(row, exc)
45
+ File.open(@path, "a") { |f| f.puts(JSON.generate(row.compact)) }
46
+ end
47
+
48
+ private
49
+
50
+ def example_line_number(ex)
51
+ return ex.line_number if ex.respond_to?(:line_number)
52
+
53
+ ex.metadata[:line_number]
54
+ end
55
+
56
+ def fragment_path
57
+ dir = ENV.fetch("POLYRUN_FAILURE_FRAGMENT_DIR", FailureMerge::DEFAULT_FRAGMENT_DIR)
58
+ base = Polyrun::Coverage::CollectorFragmentMeta.fragment_default_basename_from_env
59
+ File.expand_path(File.join(dir, "polyrun-failure-fragment-#{base}.jsonl"))
60
+ end
61
+
62
+ def matrix_env_or_nil(name)
63
+ v = ENV[name]
64
+ return nil if v.nil? || v.to_s.strip.empty?
65
+
66
+ v
67
+ end
68
+
69
+ def seed_if_known
70
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
71
+
72
+ ::RSpec.configuration.seed
73
+ rescue
74
+ nil
75
+ end
76
+
77
+ def order_if_known
78
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
79
+
80
+ ::RSpec.configuration.order.to_s
81
+ rescue
82
+ nil
83
+ end
84
+
85
+ MAX_BT = 20
86
+
87
+ def trim_backtrace!(row, exc)
88
+ bt = exc.backtrace
89
+ return unless bt.is_a?(Array) && bt.any?
90
+
91
+ row["backtrace"] = bt.first(MAX_BT)
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/polyrun/rspec.rb CHANGED
@@ -30,5 +30,19 @@ module Polyrun
30
30
  config.add_formatter fmt
31
31
  end
32
32
  end
33
+
34
+ # Per-worker failure JSONL fragments for +polyrun run-shards --merge-failures+ (parity with coverage shards).
35
+ # Requires +POLYRUN_FAILURE_FRAGMENTS=1+ (set by the parent when --merge-failures is used) unless +only_if+ overrides.
36
+ # Writes +tmp/polyrun_failures/polyrun-failure-fragment-*.jsonl+ (override dir with +POLYRUN_FAILURE_FRAGMENT_DIR+).
37
+ def install_failure_fragments!(only_if: nil)
38
+ pred = only_if || -> { %w[1 true yes].include?(ENV["POLYRUN_FAILURE_FRAGMENTS"].to_s.downcase) }
39
+ return unless pred.call
40
+
41
+ require "rspec/core"
42
+ require_relative "reporting/rspec_failure_fragment_formatter"
43
+ ::RSpec.configure do |config|
44
+ config.add_formatter Polyrun::Reporting::RspecFailureFragmentFormatter
45
+ end
46
+ end
33
47
  end
34
48
  end
@@ -1,3 +1,3 @@
1
1
  module Polyrun
2
- VERSION = "1.4.0"
2
+ VERSION = "1.4.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polyrun
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -175,6 +175,7 @@ files:
175
175
  - lib/polyrun/cli/database_commands.rb
176
176
  - lib/polyrun/cli/default_run.rb
177
177
  - lib/polyrun/cli/env_commands.rb
178
+ - lib/polyrun/cli/failure_commands.rb
178
179
  - lib/polyrun/cli/help.rb
179
180
  - lib/polyrun/cli/helpers.rb
180
181
  - lib/polyrun/cli/hooks_command.rb
@@ -258,8 +259,10 @@ files:
258
259
  - lib/polyrun/quick/reporter.rb
259
260
  - lib/polyrun/quick/runner.rb
260
261
  - lib/polyrun/railtie.rb
262
+ - lib/polyrun/reporting/failure_merge.rb
261
263
  - lib/polyrun/reporting/junit.rb
262
264
  - lib/polyrun/reporting/junit_emit.rb
265
+ - lib/polyrun/reporting/rspec_failure_fragment_formatter.rb
263
266
  - lib/polyrun/reporting/rspec_junit.rb
264
267
  - lib/polyrun/rspec.rb
265
268
  - lib/polyrun/templates/POLYRUN.md