polyrun 2.1.2 → 2.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/CONTRIBUTING.md +23 -0
  4. data/docs/SETUP_PROFILE.md +29 -0
  5. data/ext/polyrun_coverage_merge/coverage_merge.c +492 -0
  6. data/ext/polyrun_coverage_merge/extconf.rb +3 -0
  7. data/lib/polyrun/benchmark/profile.rb +191 -0
  8. data/lib/polyrun/benchmark/report.rb +87 -0
  9. data/lib/polyrun/cli/benchmark_commands.rb +66 -0
  10. data/lib/polyrun/cli/coverage_commands.rb +4 -4
  11. data/lib/polyrun/cli/coverage_merge_io.rb +30 -4
  12. data/lib/polyrun/cli/failure_commands.rb +10 -3
  13. data/lib/polyrun/cli/help.rb +14 -7
  14. data/lib/polyrun/cli/report_commands.rb +25 -11
  15. data/lib/polyrun/cli/run_queue_command.rb +5 -32
  16. data/lib/polyrun/cli/run_shards_parallel_children.rb +7 -0
  17. data/lib/polyrun/cli/run_shards_parallel_wait.rb +3 -0
  18. data/lib/polyrun/cli/run_shards_plan_options.rb +1 -1
  19. data/lib/polyrun/cli/run_shards_run.rb +1 -0
  20. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +9 -1
  21. data/lib/polyrun/cli/spec_quality_commands.rb +16 -7
  22. data/lib/polyrun/cli.rb +8 -1
  23. data/lib/polyrun/coverage/example_diff.rb +127 -60
  24. data/lib/polyrun/coverage/example_diff_snapshot.rb +44 -0
  25. data/lib/polyrun/coverage/example_diff_track_filter.rb +51 -0
  26. data/lib/polyrun/coverage/file_stats_report.rb +36 -0
  27. data/lib/polyrun/coverage/formatter.rb +22 -1
  28. data/lib/polyrun/coverage/merge/formatters.rb +11 -3
  29. data/lib/polyrun/coverage/merge_merge_two.rb +112 -63
  30. data/lib/polyrun/coverage/merge_native.rb +37 -0
  31. data/lib/polyrun/coverage/reporting.rb +1 -1
  32. data/lib/polyrun/export/csv.rb +25 -0
  33. data/lib/polyrun/export/markdown.rb +47 -0
  34. data/lib/polyrun/hooks/shell_runner.rb +33 -0
  35. data/lib/polyrun/hooks.rb +4 -7
  36. data/lib/polyrun/partition/paths_build.rb +8 -0
  37. data/lib/polyrun/queue/worker_loop.rb +39 -0
  38. data/lib/polyrun/reporting/failure_merge.rb +48 -12
  39. data/lib/polyrun/reporting/junit.rb +8 -7
  40. data/lib/polyrun/reporting/junit_report.rb +87 -0
  41. data/lib/polyrun/rspec/example_debug.rb +158 -0
  42. data/lib/polyrun/rspec/example_debug_instrumentation.rb +73 -0
  43. data/lib/polyrun/rspec/sharded_formatter_compat.rb +57 -0
  44. data/lib/polyrun/rspec.rb +34 -0
  45. data/lib/polyrun/spec_quality/profile.rb +24 -12
  46. data/lib/polyrun/spec_quality/report.rb +93 -0
  47. data/lib/polyrun/spec_quality.rb +19 -11
  48. data/lib/polyrun/timing/summary.rb +55 -5
  49. data/lib/polyrun/version.rb +1 -1
  50. data/lib/polyrun/worker_output.rb +159 -0
  51. data/lib/polyrun/worker_output_forwarders.rb +90 -0
  52. data/polyrun.gemspec +8 -1
  53. data/sig/polyrun/rspec.rbs +10 -0
  54. data/sig/polyrun/worker_output.rbs +11 -0
  55. metadata +65 -2
@@ -3,6 +3,7 @@ require "json"
3
3
 
4
4
  require_relative "merge"
5
5
  require_relative "result"
6
+ require_relative "file_stats_report"
6
7
 
7
8
  module Polyrun
8
9
  module Coverage
@@ -26,8 +27,11 @@ module Polyrun
26
27
  when :cobertura then CoberturaFormatter
27
28
  when :console then ConsoleFormatter
28
29
  when :html then HtmlFormatter
30
+ when :csv then CsvFormatter
31
+ when :markdown then MarkdownFormatter
29
32
  else
30
- raise ArgumentError, "unknown coverage format: #{name.inspect} (expected :json, :lcov, :cobertura, :console, :html)"
33
+ raise ArgumentError,
34
+ "unknown coverage format: #{name.inspect} (expected :json, :lcov, :cobertura, :console, :html, :csv, :markdown)"
31
35
  end
32
36
  end
33
37
 
@@ -111,6 +115,23 @@ module Polyrun
111
115
  {html: path}
112
116
  end
113
117
  end
118
+
119
+ class CsvFormatter < Base
120
+ def write_files(result, output_dir, basename)
121
+ path = File.join(output_dir, "#{basename}.csv")
122
+ File.write(path, FileStatsReport.emit_csv(result.coverage_blob))
123
+ {csv: path}
124
+ end
125
+ end
126
+
127
+ class MarkdownFormatter < Base
128
+ def write_files(result, output_dir, basename)
129
+ path = File.join(output_dir, "#{basename}.md")
130
+ title = (result.meta && result.meta["title"]) || (result.meta && result.meta[:title]) || "Polyrun coverage report"
131
+ File.write(path, FileStatsReport.emit_markdown(result.coverage_blob, title: title))
132
+ {markdown: path}
133
+ end
134
+ end
114
135
  end
115
136
  end
116
137
  end
@@ -153,16 +153,24 @@ module Polyrun
153
153
  # Per-file line stats for HTML and other formatters.
154
154
  # Integer line counts for one file entry (for O(files x groups) group aggregation).
155
155
  def line_counts(file_entry)
156
+ if native_acceleration?
157
+ MergeNative.line_counts(file_entry)
158
+ else
159
+ line_counts_ruby(file_entry)
160
+ end
161
+ end
162
+
163
+ def line_counts_ruby(file_entry)
156
164
  line_arr = line_array_from_file_entry(file_entry)
157
165
  return {relevant: 0, covered: 0} unless line_arr.is_a?(Array)
158
166
 
159
167
  relevant = 0
160
168
  covered = 0
161
- line_arr.each do |h|
162
- next if h.nil? || h == "ignored"
169
+ line_arr.each do |hit|
170
+ next if hit.nil? || hit == "ignored"
163
171
 
164
172
  relevant += 1
165
- covered += 1 if h.to_i > 0
173
+ covered += 1 if hit.to_i > 0
166
174
  end
167
175
  {relevant: relevant, covered: covered}
168
176
  end
@@ -4,114 +4,163 @@ module Polyrun
4
4
  module_function
5
5
 
6
6
  def merge_two(a, b)
7
- keys = a.keys | b.keys
7
+ if native_acceleration?
8
+ MergeNative.merge_two(a, b)
9
+ else
10
+ merge_two_ruby(a, b)
11
+ end
12
+ end
13
+
14
+ def merge_two_ruby(a, b)
15
+ a = {} if a.nil?
16
+ b = {} if b.nil?
17
+ if a.size >= b.size
18
+ merge_two_by_keys(a, b)
19
+ else
20
+ merge_two_by_keys(b, a)
21
+ end
22
+ end
23
+
24
+ def merge_two_by_keys(primary, secondary)
8
25
  out = {}
9
- keys.each do |path|
10
- out[path] = merge_file_entry(a[path], b[path])
26
+ primary.each do |path, entry|
27
+ out[path] = merge_file_entry(entry, secondary[path])
28
+ end
29
+ secondary.each do |path, entry|
30
+ out[path] = entry unless out.key?(path)
11
31
  end
12
32
  out
13
33
  end
14
34
 
15
- def normalize_file_entry(v)
16
- return nil if v.nil?
17
- return {"lines" => v} if v.is_a?(Array)
35
+ def native_merge_line_arrays?
36
+ native_acceleration?
37
+ end
18
38
 
19
- v
39
+ def native_acceleration?
40
+ MergeNative.available?
41
+ end
42
+
43
+ def normalize_file_entry(value)
44
+ return nil if value.nil?
45
+ return {"lines" => value} if value.is_a?(Array)
46
+
47
+ value
20
48
  end
21
49
 
22
50
  def line_array_from_file_entry(file)
23
- h = normalize_file_entry(file)
24
- return nil unless h.is_a?(Hash)
51
+ hash = normalize_file_entry(file)
52
+ return nil unless hash.is_a?(Hash)
25
53
 
26
- h["lines"] || h[:lines]
54
+ hash["lines"] || hash[:lines]
27
55
  end
28
56
 
29
- def merge_file_entry(x, y)
30
- x = normalize_file_entry(x)
31
- y = normalize_file_entry(y)
32
- return y if x.nil?
33
- return x if y.nil?
57
+ def merge_file_entry(left, right)
58
+ left = normalize_file_entry(left)
59
+ right = normalize_file_entry(right)
60
+ return right if left.nil?
61
+ return left if right.nil?
34
62
 
35
- lines = merge_line_arrays(x["lines"] || x[:lines], y["lines"] || y[:lines])
63
+ lines = merge_line_arrays(left["lines"] || left[:lines], right["lines"] || right[:lines])
36
64
  entry = {"lines" => lines}
37
- bx = x["branches"] || x[:branches]
38
- by = y["branches"] || y[:branches]
39
- br = merge_branch_arrays(bx, by)
40
- entry["branches"] = br if br
65
+ left_branches = left["branches"] || left[:branches]
66
+ right_branches = right["branches"] || right[:branches]
67
+ branches = merge_branch_arrays(left_branches, right_branches)
68
+ entry["branches"] = branches if branches
41
69
  entry
42
70
  end
43
71
 
44
- def merge_line_arrays(a, b)
45
- a ||= []
46
- b ||= []
47
- na = a.size
48
- nb = b.size
49
- max_len = (na > nb) ? na : nb
72
+ def merge_line_arrays(left, right)
73
+ if native_acceleration?
74
+ MergeNative.merge_line_arrays(left, right)
75
+ else
76
+ merge_line_arrays_ruby(left, right)
77
+ end
78
+ end
79
+
80
+ def merge_line_arrays_ruby(left, right)
81
+ left ||= []
82
+ right ||= []
83
+ left_size = left.size
84
+ right_size = right.size
85
+ max_len = (left_size > right_size) ? left_size : right_size
50
86
  out = Array.new(max_len)
51
- i = 0
52
- while i < max_len
53
- out[i] = merge_line_hits(a[i], b[i])
54
- i += 1
87
+ index = 0
88
+ while index < max_len
89
+ out[index] = merge_line_hits(left[index], right[index])
90
+ index += 1
55
91
  end
56
92
  out
57
93
  end
58
94
 
59
- def merge_line_hits(x, y)
60
- return y if x.nil?
61
- return x if y.nil?
62
- return "ignored" if x == "ignored" || y == "ignored"
95
+ def merge_line_hits(left, right)
96
+ return right if left.nil?
97
+ return left if right.nil?
98
+ return "ignored" if left == "ignored" || right == "ignored"
63
99
 
64
- xi = line_hit_to_i(x)
65
- yi = line_hit_to_i(y)
66
- return xi + yi if xi && yi
100
+ left_i = line_hit_to_i(left)
101
+ right_i = line_hit_to_i(right)
102
+ return left_i + right_i if left_i && right_i
67
103
 
68
- return yi if xi.nil? && yi
69
- return xi if yi.nil? && xi
104
+ return right_i if left_i.nil? && right_i
105
+ return left_i if right_i.nil? && left_i
70
106
 
71
- x
107
+ left
72
108
  end
73
109
 
74
- def line_hit_to_i(v)
75
- case v
76
- when Integer then v
110
+ def line_hit_to_i(value)
111
+ case value
112
+ when Integer then value
77
113
  when nil then nil
78
114
  else
79
- Integer(v, exception: false)
115
+ Integer(value, exception: false)
80
116
  end
81
117
  end
82
118
 
83
- def merge_branch_arrays(a, b)
84
- return nil if a.nil? && b.nil?
85
- return (a || b).dup if a.nil? || b.nil?
119
+ def merge_branch_arrays(left, right)
120
+ return nil if left.nil? && right.nil?
121
+ return (left || right).dup if left.nil? || right.nil?
86
122
 
87
123
  index = {}
88
- [a, b].each do |arr|
89
- arr.each do |br|
90
- k = branch_key(br)
91
- existing = index[k]
92
- index[k] =
124
+ [left, right].each do |array|
125
+ array.each do |branch|
126
+ next unless branch.is_a?(Hash)
127
+
128
+ key = branch_key(branch)
129
+ existing = index[key]
130
+ index[key] =
93
131
  if existing
94
- merge_branch_entries(existing, br)
132
+ merge_branch_entries(existing, branch)
95
133
  else
96
- br.dup
134
+ branch.dup
97
135
  end
98
136
  end
99
137
  end
100
- index.values.sort_by { |br| branch_key(br) }
138
+ index.values.sort_by { |branch| branch_key(branch) }
101
139
  end
102
140
 
103
- def branch_key(br)
104
- h = br.is_a?(Hash) ? br : {}
105
- [h["type"] || h[:type], h["start_line"] || h[:start_line], h["end_line"] || h[:end_line]]
141
+ def branch_key(branch)
142
+ hash = branch.is_a?(Hash) ? branch : {}
143
+ [hash["type"] || hash[:type], hash["start_line"] || hash[:start_line], hash["end_line"] || hash[:end_line]]
106
144
  end
107
145
 
108
- def merge_branch_entries(x, y)
109
- out = x.is_a?(Hash) ? x.dup : {}
110
- xc = (x["coverage"] || x[:coverage]).to_i
111
- yc = (y["coverage"] || y[:coverage]).to_i
112
- out["coverage"] = xc + yc
146
+ def merge_branch_arrays_for_native(left, right)
147
+ merge_branch_arrays(left, right)
148
+ end
149
+
150
+ def sort_branches_for_native(branches)
151
+ branches.sort_by { |branch| branch_key(branch) }
152
+ end
153
+
154
+ def merge_branch_entries(left, right)
155
+ out = left.is_a?(Hash) ? left.dup : {}
156
+ left_count = (left["coverage"] || left[:coverage]).to_i
157
+ right_count = (right["coverage"] || right[:coverage]).to_i
158
+ out["coverage"] = left_count + right_count
113
159
  out
114
160
  end
115
161
  end
116
162
  end
117
163
  end
164
+
165
+ require_relative "merge_native"
166
+ Polyrun::Coverage::MergeNative.load!
@@ -0,0 +1,37 @@
1
+ module Polyrun
2
+ module Coverage
3
+ module MergeNative
4
+ module_function
5
+
6
+ def load!
7
+ return true if available?
8
+
9
+ extension_dir = File.expand_path("../../../ext/polyrun_coverage_merge", __dir__)
10
+ if File.directory?(extension_dir)
11
+ $LOAD_PATH.unshift(extension_dir) unless $LOAD_PATH.include?(extension_dir)
12
+ end
13
+
14
+ require "polyrun_coverage_merge"
15
+ true
16
+ rescue LoadError
17
+ false
18
+ end
19
+
20
+ def available?
21
+ ::Object.const_defined?(:PolyrunCoverageMerge)
22
+ end
23
+
24
+ def merge_line_arrays(left, right)
25
+ ::PolyrunCoverageMerge.merge_line_arrays(left, right)
26
+ end
27
+
28
+ def merge_two(left, right)
29
+ ::PolyrunCoverageMerge.merge_two(left, right)
30
+ end
31
+
32
+ def line_counts(file_entry)
33
+ ::PolyrunCoverageMerge.line_counts(file_entry)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -9,7 +9,7 @@ module Polyrun
9
9
  # Ready-to-use multi-format output (SimpleCov-compatible result blob), no extra gems.
10
10
  # Pass +formatter:+ for multi-formatter composition ({Formatter::MultiFormatter}, custom classes).
11
11
  module Reporting
12
- DEFAULT_FORMATS = %w[json lcov cobertura console html].freeze
12
+ DEFAULT_FORMATS = %w[json lcov cobertura console html csv markdown].freeze
13
13
 
14
14
  # Comma list for +merge-coverage+ / +run-shards --merge-coverage+ defaults (Codecov, Jenkins, HTML, etc.).
15
15
  DEFAULT_MERGE_FORMAT_LIST = DEFAULT_FORMATS.join(",").freeze
@@ -0,0 +1,25 @@
1
+ module Polyrun
2
+ module Export
3
+ # RFC-style CSV field escaping for report exports.
4
+ module Csv
5
+ module_function
6
+
7
+ def escape_field(value)
8
+ string = value.nil? ? "" : value.to_s
9
+ if string.match?(/[",\r\n]/)
10
+ "\"#{string.gsub('"', '""')}\""
11
+ else
12
+ string
13
+ end
14
+ end
15
+
16
+ def generate(headers, rows)
17
+ lines = [headers.map { |header| escape_field(header) }.join(",")]
18
+ rows.each do |row|
19
+ lines << Array(row).map { |cell| escape_field(cell) }.join(",")
20
+ end
21
+ lines.join("\n") + "\n"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,47 @@
1
+ module Polyrun
2
+ module Export
3
+ # GitHub-flavored markdown tables for report exports.
4
+ module Markdown
5
+ module_function
6
+
7
+ def escape_cell(value)
8
+ value.nil? ? "" : value.to_s.gsub("|", "\\|").tr("\n", " ")
9
+ end
10
+
11
+ def heading(level, text)
12
+ level = level.to_i.clamp(1, 6)
13
+ "#{"#" * level} #{text}\n"
14
+ end
15
+
16
+ def table(headers, rows)
17
+ return "" if headers.nil? || headers.empty?
18
+
19
+ header_line = "| #{headers.map { |header| escape_cell(header) }.join(" | ")} |"
20
+ separator_line = "| #{headers.map { "---" }.join(" | ")} |"
21
+ body_lines = rows.map do |row|
22
+ "| #{Array(row).map { |cell| escape_cell(cell) }.join(" | ")} |"
23
+ end
24
+ ([header_line, separator_line] + body_lines).join("\n") + "\n"
25
+ end
26
+
27
+ def document(title, sections)
28
+ lines = [heading(1, title), ""]
29
+ sections.each do |section|
30
+ heading_text = section[:heading] || section["heading"]
31
+ lines << heading(2, heading_text) if heading_text && !heading_text.to_s.empty?
32
+
33
+ body = section[:body] || section["body"]
34
+ lines << body if body && !body.to_s.empty?
35
+
36
+ table_headers = section[:headers] || section["headers"]
37
+ table_rows = section[:rows] || section["rows"]
38
+ if table_headers && !table_headers.empty?
39
+ lines << table(table_headers, table_rows || [])
40
+ end
41
+ lines << ""
42
+ end
43
+ lines.join("\n")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,33 @@
1
+ require "open3"
2
+
3
+ module Polyrun
4
+ class Hooks
5
+ # Runs shell hook commands; suppresses stdout/stderr on success unless verbose.
6
+ module ShellRunner
7
+ private
8
+
9
+ def run_shell_hook(cmd, merged)
10
+ if hook_shell_output_verbose?
11
+ ok = system(merged, "sh", "-c", cmd)
12
+ return [ok, $?.exitstatus]
13
+ end
14
+
15
+ stdout, stderr, status = Open3.capture3(merged, "sh", "-c", cmd)
16
+ unless status.success?
17
+ $stdout.print(stdout) unless stdout.empty?
18
+ $stderr.print(stderr) unless stderr.empty?
19
+ end
20
+ [status.success?, status.exitstatus]
21
+ rescue Interrupt
22
+ Polyrun::Log.warn "polyrun hooks: shell hook interrupted"
23
+ [false, 130]
24
+ end
25
+
26
+ def hook_shell_output_verbose?
27
+ Polyrun::Debug.enabled? ||
28
+ %w[1 true yes].include?(ENV["POLYRUN_VERBOSE"]&.to_s&.downcase) ||
29
+ %w[1 true yes].include?(ENV["POLYRUN_HOOKS_VERBOSE"]&.to_s&.downcase)
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/polyrun/hooks.rb CHANGED
@@ -2,6 +2,7 @@ require "shellwords"
2
2
 
3
3
  require_relative "log"
4
4
  require_relative "hooks/dsl"
5
+ require_relative "hooks/shell_runner"
5
6
  require_relative "hooks/worker_runner"
6
7
  require_relative "hooks/worker_shell"
7
8
 
@@ -17,6 +18,7 @@ module Polyrun
17
18
  # Orchestration respects +POLYRUN_HOOKS_DISABLE=1+ (+run_phase_if_enabled+); +polyrun hook run+ always runs {#run_phase}.
18
19
  class Hooks
19
20
  include WorkerShell
21
+ include ShellRunner
20
22
 
21
23
  PHASES = %i[
22
24
  before_suite after_suite
@@ -128,13 +130,8 @@ module Polyrun
128
130
  end
129
131
 
130
132
  commands_for(phase).each do |cmd|
131
- ok = begin
132
- system(merged, "sh", "-c", cmd)
133
- rescue Interrupt
134
- Polyrun::Log.warn "polyrun hooks: #{phase} shell hook interrupted"
135
- return 130
136
- end
137
- return $?.exitstatus unless ok
133
+ ok, exitstatus = run_shell_hook(cmd, merged)
134
+ return exitstatus unless ok
138
135
  end
139
136
  0
140
137
  end
@@ -40,12 +40,20 @@ module Polyrun
40
40
 
41
41
  pool = glob_under_cwd(all_glob, cwd)
42
42
  pool.uniq!
43
+ pool = reject_exclude_prefixes(pool, pb)
43
44
  stages = Array(pb["stages"])
44
45
  return sort_paths(pool) if stages.empty?
45
46
 
46
47
  apply_stages_to_pool(stages, pool, cwd)
47
48
  end
48
49
 
50
+ def reject_exclude_prefixes(pool, pb)
51
+ prefixes = Array(pb["exclude_prefixes"]).map(&:to_s).reject(&:empty?)
52
+ return pool if prefixes.empty?
53
+
54
+ pool.reject { |path| prefixes.any? { |prefix| path.start_with?(prefix) } }
55
+ end
56
+
49
57
  def apply_stages_to_pool(stages, pool, cwd)
50
58
  remaining = Set.new(pool)
51
59
  out = []
@@ -0,0 +1,39 @@
1
+ module Polyrun
2
+ module Queue
3
+ # Claim / run / ack loop for +polyrun run-queue+ workers (forked or direct).
4
+ module WorkerLoop
5
+ module_function
6
+
7
+ def run(store:, worker_id:, batch:, cmd:, on_failure:)
8
+ batches_ok = 0
9
+ batches_fail = 0
10
+ loop do
11
+ claim = store.claim!(worker_id: worker_id, batch_size: batch)
12
+ paths = claim["paths"] || []
13
+ break if paths.empty?
14
+
15
+ code = run_batch(cmd, paths)
16
+ if code == 0
17
+ store.ack!(lease_id: claim["lease_id"], worker_id: worker_id)
18
+ batches_ok += 1
19
+ elsif on_failure.to_s == "requeue"
20
+ store.reclaim_lease!(claim["lease_id"])
21
+ batches_fail += 1
22
+ return {ok: batches_ok, fail: batches_fail, exit_code: 1}
23
+ else
24
+ batches_fail += 1
25
+ return {ok: batches_ok, fail: batches_fail, exit_code: code.zero? ? 1 : code}
26
+ end
27
+ end
28
+ {ok: batches_ok, fail: batches_fail, exit_code: 0}
29
+ rescue Polyrun::Error => e
30
+ Polyrun::Log.warn "polyrun run-queue worker #{worker_id}: #{e.message}"
31
+ {ok: batches_ok, fail: batches_fail, exit_code: 2}
32
+ end
33
+
34
+ def run_batch(cmd, paths)
35
+ system(*cmd, *paths) ? 0 : ($?.exitstatus || 1)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,6 +1,9 @@
1
1
  require "json"
2
2
  require "fileutils"
3
3
 
4
+ require_relative "../export/csv"
5
+ require_relative "../export/markdown"
6
+
4
7
  module Polyrun
5
8
  module Reporting
6
9
  # Merge per-worker / per-shard failure fragments (JSONL or RSpec JSON) into one report.
@@ -8,6 +11,10 @@ module Polyrun
8
11
  module FailureMerge
9
12
  DEFAULT_FRAGMENT_DIR = "tmp/polyrun_failures".freeze
10
13
  FRAGMENT_GLOB = "polyrun-failure-fragment-*.jsonl".freeze
14
+ CSV_HEADERS = %w[
15
+ id full_description location file_path line_number message exception_class
16
+ polyrun_shard_index polyrun_shard_total source
17
+ ].freeze
11
18
 
12
19
  module_function
13
20
 
@@ -24,7 +31,7 @@ module Polyrun
24
31
  end
25
32
 
26
33
  # @param paths [Array<String>] fragment paths (.jsonl and/or RSpec --format json outputs)
27
- # @param format [String] "jsonl" or "json"
34
+ # @param format [String] "jsonl", "json", "csv", or "markdown"
28
35
  # @param output [String] destination path
29
36
  # @return [Integer] count of failure rows merged
30
37
  def merge_files!(paths, output:, format: "jsonl")
@@ -34,23 +41,52 @@ module Polyrun
34
41
  FileUtils.mkdir_p(File.dirname(out_abs))
35
42
  case fmt
36
43
  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))
44
+ write_json_output!(out_abs, paths, rows)
46
45
  when "jsonl"
47
- File.write(out_abs, rows.map { |h| JSON.generate(h) }.join("\n") + (rows.empty? ? "" : "\n"))
46
+ write_jsonl_output!(out_abs, rows)
47
+ when "csv"
48
+ write_csv_output!(out_abs, rows)
49
+ when "markdown", "md"
50
+ write_markdown_output!(out_abs, rows)
48
51
  else
49
- raise Polyrun::Error, "merge-failures: unknown format #{fmt.inspect} (use jsonl or json)"
52
+ raise Polyrun::Error, "merge-failures: unknown format #{fmt.inspect} (use jsonl, json, csv, or markdown)"
50
53
  end
51
54
  rows.size
52
55
  end
53
56
 
57
+ def write_json_output!(out_abs, paths, rows)
58
+ doc = {
59
+ "meta" => {
60
+ "polyrun_merge" => true,
61
+ "inputs" => paths.map { |p| File.expand_path(p) },
62
+ "failure_count" => rows.size
63
+ },
64
+ "failures" => rows
65
+ }
66
+ File.write(out_abs, JSON.generate(doc))
67
+ end
68
+
69
+ def write_jsonl_output!(out_abs, rows)
70
+ File.write(out_abs, rows.map { |h| JSON.generate(h) }.join("\n") + (rows.empty? ? "" : "\n"))
71
+ end
72
+
73
+ def write_csv_output!(out_abs, rows)
74
+ csv_rows = rows.map { |row| CSV_HEADERS.map { |key| row[key] } }
75
+ File.write(out_abs, Export::Csv.generate(CSV_HEADERS, csv_rows))
76
+ end
77
+
78
+ def write_markdown_output!(out_abs, rows)
79
+ sections = rows.map.with_index(1) do |row, index|
80
+ {
81
+ heading: "Failure #{index}: #{row["full_description"] || row["location"] || row["id"]}",
82
+ headers: %w[field value],
83
+ rows: row.sort_by { |key, _| key.to_s }.map { |key, value| [key, value] }
84
+ }
85
+ end
86
+ sections = [{heading: "Failures", body: "(none)"}] if sections.empty?
87
+ File.write(out_abs, Export::Markdown.document("Polyrun failure report", sections))
88
+ end
89
+
54
90
  def collect_rows(paths)
55
91
  rows = []
56
92
  paths.each do |p|