polyrun 1.4.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +2 -2
  4. data/docs/SETUP_PROFILE.md +2 -0
  5. data/lib/polyrun/cli/ci_shard_hooks.rb +12 -4
  6. data/lib/polyrun/cli/ci_shard_run_command.rb +3 -1
  7. data/lib/polyrun/cli/help.rb +10 -2
  8. data/lib/polyrun/cli/helpers.rb +38 -0
  9. data/lib/polyrun/cli/init_command.rb +8 -1
  10. data/lib/polyrun/cli/partition_diagnostics.rb +22 -0
  11. data/lib/polyrun/cli/plan_command.rb +47 -18
  12. data/lib/polyrun/cli/queue_command.rb +25 -2
  13. data/lib/polyrun/cli/run_queue_command.rb +145 -0
  14. data/lib/polyrun/cli/run_shards_command.rb +6 -1
  15. data/lib/polyrun/cli/run_shards_parallel_children.rb +28 -35
  16. data/lib/polyrun/cli/run_shards_parallel_wait.rb +267 -0
  17. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +81 -3
  18. data/lib/polyrun/cli/run_shards_plan_options.rb +17 -3
  19. data/lib/polyrun/cli/run_shards_planning.rb +20 -12
  20. data/lib/polyrun/cli/run_shards_run.rb +28 -37
  21. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +75 -0
  22. data/lib/polyrun/cli/spec_quality_commands.rb +140 -0
  23. data/lib/polyrun/cli.rb +16 -2
  24. data/lib/polyrun/coverage/example_diff.rb +122 -0
  25. data/lib/polyrun/coverage/merge/formatters_html.rb +4 -0
  26. data/lib/polyrun/data/factory_counts.rb +14 -1
  27. data/lib/polyrun/database/clone_shards.rb +2 -0
  28. data/lib/polyrun/database/shard.rb +2 -1
  29. data/lib/polyrun/hooks.rb +9 -1
  30. data/lib/polyrun/log.rb +16 -0
  31. data/lib/polyrun/minitest.rb +43 -0
  32. data/lib/polyrun/partition/hrw.rb +40 -3
  33. data/lib/polyrun/partition/paths_build.rb +8 -3
  34. data/lib/polyrun/partition/plan.rb +88 -19
  35. data/lib/polyrun/partition/plan_lpt.rb +49 -7
  36. data/lib/polyrun/partition/plan_sharding.rb +8 -0
  37. data/lib/polyrun/partition/reports.rb +139 -0
  38. data/lib/polyrun/partition/timing_diagnostics.rb +139 -0
  39. data/lib/polyrun/partition/timing_keys.rb +2 -1
  40. data/lib/polyrun/queue/duration.rb +30 -0
  41. data/lib/polyrun/queue/file_store.rb +107 -3
  42. data/lib/polyrun/quick/example_runner.rb +13 -0
  43. data/lib/polyrun/quick/runner.rb +21 -0
  44. data/lib/polyrun/rspec.rb +26 -0
  45. data/lib/polyrun/spec_quality/config.rb +134 -0
  46. data/lib/polyrun/spec_quality/fragment.rb +39 -0
  47. data/lib/polyrun/spec_quality/merge.rb +78 -0
  48. data/lib/polyrun/spec_quality/minitest_hook.rb +42 -0
  49. data/lib/polyrun/spec_quality/plan_loader.rb +47 -0
  50. data/lib/polyrun/spec_quality/profile.rb +91 -0
  51. data/lib/polyrun/spec_quality/report.rb +261 -0
  52. data/lib/polyrun/spec_quality/rspec_hook.rb +55 -0
  53. data/lib/polyrun/spec_quality/sql_counter.rb +34 -0
  54. data/lib/polyrun/spec_quality.rb +205 -0
  55. data/lib/polyrun/templates/POLYRUN.md +6 -0
  56. data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -0
  57. data/lib/polyrun/templates/polyrun_hooks_spec_quality.rb +12 -0
  58. data/lib/polyrun/templates/polyrun_spec_quality.yml +20 -0
  59. data/lib/polyrun/templates/rails_prepare.polyrun.yml +5 -0
  60. data/lib/polyrun/timing/merge.rb +5 -5
  61. data/lib/polyrun/timing/stats.rb +76 -0
  62. data/lib/polyrun/timing/summary.rb +5 -2
  63. data/lib/polyrun/timing/variance_report.rb +51 -0
  64. data/lib/polyrun/version.rb +1 -1
  65. data/lib/polyrun/worker_ping.rb +74 -0
  66. data/sig/polyrun/minitest.rbs +2 -0
  67. data/sig/polyrun/rspec.rbs +4 -0
  68. data/sig/polyrun/worker_ping.rbs +10 -0
  69. metadata +26 -1
@@ -1,7 +1,9 @@
1
1
  require "shellwords"
2
2
  require "rbconfig"
3
+ require "json"
3
4
 
4
5
  require_relative "run_shards_planning"
6
+ require_relative "run_shards_worker_interrupt"
5
7
  require_relative "run_shards_parallel_children"
6
8
 
7
9
  module Polyrun
@@ -9,6 +11,7 @@ module Polyrun
9
11
  # Partition + spawn workers for `polyrun run-shards` (keeps {RunShardsCommand} file small).
10
12
  module RunShardsRun
11
13
  include RunShardsPlanning
14
+ include RunShardsWorkerInterrupt
12
15
  include RunShardsParallelChildren
13
16
 
14
17
  private
@@ -20,13 +23,15 @@ module Polyrun
20
23
  run_shards_workers_and_merge(ctx)
21
24
  end
22
25
 
23
- # rubocop:disable Metrics/AbcSize -- orchestration: hooks, merge, worker failures
26
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- orchestration: hooks, merge, worker failures
24
27
  def run_shards_workers_and_merge(ctx)
25
28
  hook_cfg = Polyrun::Hooks.from_config(ctx[:cfg])
26
29
  suite_started = false
27
30
  exit_code = 1
28
31
  merged_failures_path = nil
29
32
  merge_failures_errored = false
33
+ merge_spec_quality_errored = false
34
+ spec_quality_strict_failed = false
30
35
 
31
36
  begin
32
37
  env_suite = ENV.to_h.merge(
@@ -62,6 +67,20 @@ module Polyrun
62
67
  Polyrun::Log.warn "polyrun run-shards: finished #{pids.size} worker(s)" + (failed.any? ? " (some failed)" : " (exit 0)")
63
68
  end
64
69
 
70
+ if ctx[:merge_spec_quality]
71
+ begin
72
+ merged_sq_path = merge_spec_quality_after_shards(ctx)
73
+ if merged_sq_path && ctx[:report_spec_quality]
74
+ cfg = load_spec_quality_config(nil)
75
+ merged = JSON.parse(File.read(merged_sq_path))
76
+ spec_quality_strict_failed = cfg["strict"] && Polyrun::SpecQuality::Report.gate_violations(merged, cfg).any?
77
+ end
78
+ rescue Polyrun::Error, JSON::ParserError => e
79
+ Polyrun::Log.warn e.message.to_s
80
+ merge_spec_quality_errored = true
81
+ end
82
+ end
83
+
65
84
  if ctx[:merge_failures]
66
85
  begin
67
86
  merged_failures_path = merge_failures_after_shards(ctx)
@@ -77,13 +96,13 @@ module Polyrun
77
96
  merge_failures: ctx[:merge_failures]
78
97
  )
79
98
  exit_code = 1
80
- exit_code = 1 if wait_hook_err != 0
81
99
  return exit_code
82
100
  end
83
101
 
84
102
  exit_code = run_shards_merge_or_hint_coverage(ctx)
85
103
  exit_code = 1 if wait_hook_err != 0 && exit_code == 0
86
- exit_code = 1 if merge_failures_errored && exit_code == 0
104
+ exit_code = 1 if merge_spec_quality_errored && exit_code == 0
105
+ exit_code = 1 if spec_quality_strict_failed && exit_code == 0
87
106
  exit_code
88
107
  ensure
89
108
  if suite_started
@@ -93,11 +112,15 @@ module Polyrun
93
112
  "POLYRUN_SUITE_EXIT_STATUS" => exit_code.to_s,
94
113
  "POLYRUN_MERGED_FAILURES_PATH" => merged_failures_path.to_s
95
114
  )
96
- hook_cfg.run_phase_if_enabled(:after_suite, env_after)
115
+ begin
116
+ hook_cfg.run_phase_if_enabled(:after_suite, env_after)
117
+ rescue Interrupt
118
+ Polyrun::Log.warn "polyrun run-shards: after_suite hook interrupted; workers are stopped or were not started"
119
+ end
97
120
  end
98
121
  end
99
122
  end
100
- # rubocop:enable Metrics/AbcSize
123
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
101
124
 
102
125
  def run_shards_warn_interleaved(parallel, pid_count)
103
126
  return unless parallel && pid_count > 1
@@ -106,38 +129,6 @@ module Polyrun
106
129
  Polyrun::Log.warn "polyrun run-shards: each worker prints its own summary line; the last \"N examples\" line is not a total across shards."
107
130
  end
108
131
 
109
- # Best-effort worker teardown then exit. Does not return.
110
- def run_shards_shutdown_on_signal!(pids, code)
111
- run_shards_terminate_children!(pids)
112
- exit(code)
113
- rescue Interrupt
114
- pids.each do |h|
115
- Process.kill(:KILL, h[:pid])
116
- rescue Errno::ESRCH
117
- # already reaped
118
- end
119
- pids.each do |h|
120
- Process.wait(h[:pid])
121
- rescue Errno::ESRCH, Errno::ECHILD, Interrupt
122
- # already reaped or give up
123
- end
124
- exit(code)
125
- end
126
-
127
- # Send SIGTERM to each worker PID and wait so Ctrl+C / SIGTERM does not leave orphans.
128
- def run_shards_terminate_children!(pids)
129
- pids.each do |h|
130
- Process.kill(:TERM, h[:pid])
131
- rescue Errno::ESRCH
132
- # already reaped
133
- end
134
- pids.each do |h|
135
- Process.wait(h[:pid])
136
- rescue Errno::ESRCH, Errno::ECHILD
137
- # already reaped
138
- end
139
- end
140
-
141
132
  def run_shards_merge_or_hint_coverage(ctx)
142
133
  if ctx[:merge_coverage]
143
134
  mo = ctx[:merge_output] || "coverage/merged.json"
@@ -0,0 +1,75 @@
1
+ module Polyrun
2
+ class CLI
3
+ # SIGINT/SIGTERM handling and non-blocking reap for parallel worker PIDs (used by run-shards / ci-shard fan-out).
4
+ module RunShardsWorkerInterrupt
5
+ private
6
+
7
+ def run_shards_log_interrupt_workers(pids, _ctx)
8
+ parts = pids.map { |h| "shard=#{h[:shard]} pid=#{h[:pid]}" }
9
+ Polyrun::Log.orchestration_warn "polyrun run-shards: SIGINT/SIGTERM while waiting on workers — stopping: #{parts.join(", ")}"
10
+ Polyrun::Log.warn "polyrun run-shards: search this log for each shard's started … pid= line and RSpec output; repeat SIGINT during cleanup escalates to SIGKILL"
11
+ end
12
+
13
+ # Best-effort worker teardown then exit. Does not return.
14
+ def run_shards_shutdown_on_signal!(pids, code)
15
+ run_shards_log_interrupt_workers(pids, nil)
16
+ run_shards_terminate_children!(pids)
17
+ exit(code)
18
+ rescue Interrupt
19
+ run_shards_signal_workers_kill(pids)
20
+ run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
21
+ exit(code)
22
+ end
23
+
24
+ # Send SIGTERM to each worker PID and wait so Ctrl+C / SIGTERM does not leave orphans.
25
+ def run_shards_terminate_children!(pids)
26
+ run_shards_signal_workers_term(pids)
27
+ run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
28
+ end
29
+
30
+ def run_shards_signal_workers_term(pids)
31
+ pids.each do |h|
32
+ Process.kill(:TERM, h[:pid])
33
+ rescue Errno::ESRCH
34
+ end
35
+ end
36
+
37
+ def run_shards_signal_workers_kill(pids)
38
+ pids.each do |h|
39
+ Process.kill(:KILL, h[:pid])
40
+ rescue Errno::ESRCH
41
+ end
42
+ end
43
+
44
+ # Reap child PIDs without blocking uninterruptibly on one stuck zombie (avoids noisy stacks on repeat Ctrl+C).
45
+ def run_shards_reap_worker_pids_interruptible(pids)
46
+ pending = pids.compact.uniq
47
+ force_note = false
48
+ until pending.empty?
49
+ pending.reject! do |pid|
50
+ w = Process.wait(pid, Process::WNOHANG)
51
+ next true if w == pid
52
+
53
+ false
54
+ rescue Errno::ECHILD
55
+ true
56
+ end
57
+ break if pending.empty?
58
+
59
+ begin
60
+ sleep(0.05)
61
+ rescue Interrupt
62
+ unless force_note
63
+ force_note = true
64
+ Polyrun::Log.orchestration_warn "polyrun run-shards: repeated SIGINT during worker cleanup — SIGKILL to #{pending.size} process(es)"
65
+ end
66
+ pending.each do |pid|
67
+ Process.kill(:KILL, pid)
68
+ rescue Errno::ESRCH
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,140 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ require_relative "../spec_quality"
5
+
6
+ module Polyrun
7
+ class CLI
8
+ module SpecQualityCommands
9
+ private
10
+
11
+ def cmd_merge_spec_quality(argv)
12
+ inputs = []
13
+ output = "coverage/polyrun-spec-quality.json"
14
+ parser = OptionParser.new do |opts|
15
+ opts.banner = "usage: polyrun merge-spec-quality [-i FILE]... [-o OUT] [FILE...]"
16
+ opts.on("-i", "--input FILE", "Spec quality JSONL fragment (repeatable)") { |f| inputs << f }
17
+ opts.on("-o", "--output PATH", String) { |v| output = v }
18
+ end
19
+ parser.parse!(argv)
20
+ inputs.concat(argv) if inputs.empty?
21
+
22
+ if inputs.empty?
23
+ inputs = Dir.glob(Polyrun::SpecQuality::Fragment.glob_pattern).sort
24
+ end
25
+
26
+ if inputs.empty?
27
+ Polyrun::Log.warn "merge-spec-quality: need -i FILE or coverage/polyrun-spec-quality-fragment-*.jsonl"
28
+ return 2
29
+ end
30
+
31
+ out_abs = File.expand_path(output)
32
+ Polyrun::SpecQuality::Merge.merge_and_write(inputs.map { |p| File.expand_path(p) }, out_abs)
33
+ Polyrun::Log.puts out_abs
34
+ 0
35
+ end
36
+
37
+ # rubocop:disable Metrics/AbcSize -- report argv + gate output
38
+ def cmd_report_spec_quality(argv)
39
+ input = nil
40
+ out_file = nil
41
+ top = 30
42
+ profile = nil
43
+ config_path = nil
44
+ strict = false
45
+ json_out = false
46
+ plan_paths = []
47
+
48
+ OptionParser.new do |opts|
49
+ opts.banner = "usage: polyrun report-spec-quality -i FILE [-o PATH] [--top N] [--profile LIST] [--strict] [--json]"
50
+ opts.on("-i", "--input PATH", "Merged polyrun-spec-quality.json") { |v| input = v }
51
+ opts.on("-o", "--output PATH", "Write report to file instead of stdout") { |v| out_file = v }
52
+ opts.on("--top N", Integer) { |v| top = v }
53
+ opts.on("--profile LIST", "cpu,mem,io,wall (comma-separated)") { |v| profile = v }
54
+ opts.on("-c", "--config PATH", "polyrun_spec_quality.yml path") { |v| config_path = v }
55
+ opts.on("--plan PATH", "Partition plan JSON (repeatable; polyrun plan output per shard)") { |v| plan_paths << v }
56
+ opts.on("--strict", "Exit 1 when gate thresholds fail") { strict = true }
57
+ opts.on("--json", "Write analysis JSON instead of text report") { json_out = true }
58
+ end.parse!(argv)
59
+ input ||= argv.first
60
+
61
+ unless input && File.file?(input)
62
+ Polyrun::Log.warn "report-spec-quality: need -i FILE"
63
+ return 2
64
+ end
65
+
66
+ merged = JSON.parse(File.read(File.expand_path(input)))
67
+ cfg = load_spec_quality_config(config_path)
68
+ strict = true if cfg["strict"] || strict
69
+ plan_shards = Polyrun::SpecQuality::PlanLoader.load_shards(plan_paths)
70
+
71
+ text = if json_out
72
+ JSON.pretty_generate(Polyrun::SpecQuality::Report.analyze(merged, cfg, plan_shards: plan_shards))
73
+ else
74
+ Polyrun::SpecQuality::Report.format_report(
75
+ merged, cfg: cfg, top: top, profile: profile, plan_shards: plan_shards
76
+ )
77
+ end
78
+
79
+ if out_file
80
+ File.write(File.expand_path(out_file), text)
81
+ Polyrun::Log.puts File.expand_path(out_file)
82
+ else
83
+ Polyrun::Log.print text
84
+ end
85
+
86
+ violations = Polyrun::SpecQuality::Report.gate_violations(merged, cfg)
87
+ if strict && violations.any?
88
+ violations.each { |v| Polyrun::Log.warn "polyrun spec-quality gate: #{v}" }
89
+ return 1
90
+ end
91
+
92
+ 0
93
+ end
94
+ # rubocop:enable Metrics/AbcSize
95
+
96
+ def merge_spec_quality_after_shards(ctx)
97
+ files = merge_spec_quality_fragment_files
98
+ if files.empty?
99
+ Polyrun::Log.warn "polyrun run-shards: --merge-spec-quality: no coverage/polyrun-spec-quality-fragment-*.jsonl found (enable POLYRUN_SPEC_QUALITY in spec_helper?)"
100
+ return nil
101
+ end
102
+
103
+ out = ctx[:merge_spec_quality_output] || "coverage/polyrun-spec-quality.json"
104
+ Polyrun::Log.warn "polyrun run-shards: merging #{files.size} spec-quality fragment(s) → #{out}"
105
+ Polyrun::SpecQuality::Merge.merge_and_write(files, File.expand_path(out))
106
+ report_spec_quality_after_merge(out, ctx)
107
+ File.expand_path(out)
108
+ end
109
+
110
+ def report_spec_quality_after_merge(merged_path, ctx)
111
+ return unless ctx[:report_spec_quality]
112
+
113
+ cfg = load_spec_quality_config(ctx[:config_path])
114
+ merged = JSON.parse(File.read(File.expand_path(merged_path)))
115
+ text = Polyrun::SpecQuality::Report.format_report(merged, cfg: cfg)
116
+ Polyrun::Log.print text
117
+
118
+ violations = Polyrun::SpecQuality::Report.gate_violations(merged, cfg)
119
+ return if violations.empty?
120
+
121
+ violations.each { |v| Polyrun::Log.warn "polyrun spec-quality gate: #{v}" }
122
+ end
123
+
124
+ def merge_spec_quality_fragment_files
125
+ Dir.glob(Polyrun::SpecQuality::Fragment.glob_pattern).sort
126
+ end
127
+
128
+ def load_spec_quality_config(config_path)
129
+ root = Dir.pwd
130
+ path = config_path
131
+ if path && !path.to_s.empty?
132
+ path = File.expand_path(path, root)
133
+ end
134
+ Polyrun::SpecQuality::Config.load(root: root, config_path: path)
135
+ rescue
136
+ Polyrun::SpecQuality::Config::DEFAULTS.dup
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/polyrun/cli.rb CHANGED
@@ -1,6 +1,8 @@
1
+ # rubocop:disable Polyrun/FileLength -- CLI dispatch + require wiring
1
2
  require "optparse"
2
3
 
3
4
  require_relative "cli/helpers"
5
+ require_relative "cli/partition_diagnostics"
4
6
  require_relative "cli/plan_command"
5
7
  require_relative "cli/prepare_command"
6
8
  require_relative "cli/coverage_commands"
@@ -8,8 +10,10 @@ require_relative "cli/report_commands"
8
10
  require_relative "cli/env_commands"
9
11
  require_relative "cli/database_commands"
10
12
  require_relative "cli/run_shards_command"
13
+ require_relative "cli/run_queue_command"
11
14
  require_relative "cli/queue_command"
12
15
  require_relative "cli/timing_command"
16
+ require_relative "cli/spec_quality_commands"
13
17
  require_relative "cli/init_command"
14
18
  require_relative "cli/quick_command"
15
19
  require_relative "cli/ci_shard_run_parse"
@@ -28,9 +32,9 @@ module Polyrun
28
32
 
29
33
  # Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
30
34
  DISPATCH_SUBCOMMAND_NAMES = %w[
31
- plan prepare merge-coverage merge-failures report-coverage report-junit report-timing
35
+ plan prepare merge-coverage merge-failures merge-spec-quality report-coverage report-junit report-timing report-spec-quality
32
36
  env config merge-timing db:setup-template db:setup-shard db:clone-shards
33
- run-shards parallel-rspec start build-paths init queue quick hook
37
+ run-shards parallel-rspec start build-paths init queue run-queue quick hook
34
38
  ].freeze
35
39
 
36
40
  # First argv token that is a normal subcommand (not a path); if argv[0] is not here but looks like paths, run implicit parallel.
@@ -39,6 +43,7 @@ module Polyrun
39
43
  ).freeze
40
44
 
41
45
  include Helpers
46
+ include PartitionDiagnostics
42
47
  include PlanCommand
43
48
  include PrepareCommand
44
49
  include CoverageCommands
@@ -46,8 +51,10 @@ module Polyrun
46
51
  include EnvCommands
47
52
  include DatabaseCommands
48
53
  include RunShardsCommand
54
+ include RunQueueCommand
49
55
  include QueueCommand
50
56
  include TimingCommand
57
+ include SpecQualityCommands
51
58
  include InitCommand
52
59
  include QuickCommand
53
60
  include CiShardRunParse
@@ -159,6 +166,10 @@ module Polyrun
159
166
  cmd_config(argv, config_path)
160
167
  when "merge-timing"
161
168
  cmd_merge_timing(argv)
169
+ when "merge-spec-quality"
170
+ cmd_merge_spec_quality(argv)
171
+ when "report-spec-quality"
172
+ cmd_report_spec_quality(argv)
162
173
  when "db:setup-template"
163
174
  cmd_db_setup_template(argv, config_path)
164
175
  when "db:setup-shard"
@@ -179,6 +190,8 @@ module Polyrun
179
190
  cmd_init(argv, config_path)
180
191
  when "queue"
181
192
  cmd_queue(argv)
193
+ when "run-queue"
194
+ cmd_run_queue(argv, config_path)
182
195
  when "quick"
183
196
  cmd_quick(argv)
184
197
  when "hook"
@@ -196,3 +209,4 @@ module Polyrun
196
209
  end
197
210
  end
198
211
  end
212
+ # rubocop:enable Polyrun/FileLength
@@ -0,0 +1,122 @@
1
+ module Polyrun
2
+ module Coverage
3
+ # Per-example line hit deltas from stdlib +Coverage.peek_result+ snapshots.
4
+ module ExampleDiff
5
+ module_function
6
+
7
+ # @return [Hash{String=>Hash}] path => +{"lines"=>[...]}+
8
+ def peek_blob
9
+ return {} unless coverage_active?
10
+
11
+ normalize_peek(::Coverage.peek_result)
12
+ end
13
+
14
+ def coverage_active?
15
+ defined?(::Coverage) && ::Coverage.respond_to?(:running?) && ::Coverage.running?
16
+ end
17
+
18
+ def normalize_peek(raw)
19
+ out = {}
20
+ raw.each do |path, cov|
21
+ next unless cov.is_a?(Hash)
22
+
23
+ lines = cov[:lines] || cov["lines"]
24
+ next unless lines.is_a?(Array)
25
+
26
+ out[path.to_s] = {"lines" => lines.map { |x| x }}
27
+ end
28
+ out
29
+ end
30
+
31
+ # @return [Hash] +:unique_lines+, +:line_churn+, +:max_line_churn+, +:lines+ (compact triples)
32
+ # rubocop:disable Metrics/AbcSize -- per-file coverage delta walk
33
+ def diff(before_blob, after_blob)
34
+ before_blob ||= {}
35
+ after_blob ||= {}
36
+ files = before_blob.keys | after_blob.keys
37
+
38
+ line_entries = []
39
+ unique = 0
40
+ churn = 0
41
+ max_churn = 0
42
+
43
+ files.each do |path|
44
+ b_lines = line_array(before_blob[path])
45
+ a_lines = line_array(after_blob[path])
46
+ max_len = [b_lines.size, a_lines.size].max
47
+
48
+ (0...max_len).each do |i|
49
+ b = b_lines[i]
50
+ a = a_lines[i]
51
+ next if a.nil? && b.nil?
52
+
53
+ delta = (integer_hit(a) - integer_hit(b))
54
+ next unless delta.positive?
55
+
56
+ line_no = i + 1
57
+ line_entries << [path, line_no, delta]
58
+ unique += 1
59
+ churn += delta
60
+ max_churn = delta if delta > max_churn
61
+ end
62
+ end
63
+
64
+ {
65
+ unique_lines: unique,
66
+ line_churn: churn,
67
+ max_line_churn: max_churn,
68
+ lines: line_entries
69
+ }
70
+ end
71
+ # rubocop:enable Metrics/AbcSize
72
+
73
+ def filter_lines(lines, root:, track_under:, ignore_paths: [])
74
+ root = File.expand_path(root)
75
+ prefixes = Array(track_under).map { |d| File.join(root, d.to_s) }
76
+ ignore = Array(ignore_paths).map(&:to_s).reject(&:empty?)
77
+
78
+ lines.select do |path, _line, _delta|
79
+ p = File.expand_path(path.to_s, root)
80
+ next false if ignore.any? { |pat| path_matches_ignore?(p, pat) }
81
+
82
+ prefixes.any? { |pre| p == pre || p.start_with?(pre + "/") }
83
+ end
84
+ end
85
+
86
+ def apply_track_under(delta, root:, track_under:, ignore_paths: [])
87
+ filtered = filter_lines(delta[:lines] || [], root: root, track_under: track_under, ignore_paths: ignore_paths)
88
+ unique = filtered.size
89
+ churn = filtered.sum { |(_p, _l, d)| d }
90
+ max_churn = filtered.map { |(_p, _l, d)| d }.max || 0
91
+ {
92
+ unique_lines: unique,
93
+ line_churn: churn,
94
+ max_line_churn: max_churn,
95
+ lines: filtered
96
+ }
97
+ end
98
+
99
+ def path_matches_ignore?(path, pattern)
100
+ return path.include?(pattern) if pattern.is_a?(String) && !pattern.start_with?("/")
101
+
102
+ path.match?(Regexp.new(pattern))
103
+ rescue RegexpError
104
+ path.include?(pattern.to_s)
105
+ end
106
+
107
+ def line_array(entry)
108
+ return [] unless entry.is_a?(Hash)
109
+
110
+ arr = entry["lines"] || entry[:lines]
111
+ arr.is_a?(Array) ? arr : []
112
+ end
113
+
114
+ def integer_hit(value)
115
+ return 0 if value.nil?
116
+
117
+ value.is_a?(Integer) ? value : value.to_i
118
+ end
119
+ private_class_method :line_array, :integer_hit
120
+ end
121
+ end
122
+ end
@@ -1,3 +1,4 @@
1
+ # rubocop:disable Polyrun/FileLength -- HTML merge formatter + helpers in one file
1
2
  require "cgi"
2
3
  require "digest/sha1"
3
4
  require "erb"
@@ -9,6 +10,7 @@ module Polyrun
9
10
  module_function
10
11
 
11
12
  # Standalone HTML report with summary, file table, and per-file source details.
13
+ # rubocop:disable Metrics/AbcSize -- linear assembly of overview, file table, sections, asset reads
12
14
  def emit_html(coverage_blob, title: "Polyrun coverage", root: nil, groups: nil, generated_at: Time.now)
13
15
  files = coverage_blob.keys.sort.map { |path| html_file_payload(path, coverage_blob[path], root) }
14
16
  summary = html_summary(files)
@@ -31,6 +33,7 @@ module Polyrun
31
33
  javascript: File.read(html_javascript_path)
32
34
  )
33
35
  end
36
+ # rubocop:enable Metrics/AbcSize
34
37
 
35
38
  def html_asset_dir
36
39
  File.join(__dir__, "html")
@@ -197,3 +200,4 @@ module Polyrun
197
200
  end
198
201
  end
199
202
  end
203
+ # rubocop:enable Polyrun/FileLength
@@ -6,11 +6,19 @@ module Polyrun
6
6
  class << self
7
7
  def reset!
8
8
  @counts = Hash.new(0)
9
+ @example_counts = Hash.new(0)
10
+ end
11
+
12
+ def reset_example!
13
+ @example_counts = Hash.new(0)
9
14
  end
10
15
 
11
16
  def record(factory_name)
12
17
  @counts ||= Hash.new(0)
13
- @counts[factory_name.to_s] += 1
18
+ @example_counts ||= Hash.new(0)
19
+ name = factory_name.to_s
20
+ @counts[name] += 1
21
+ @example_counts[name] += 1
14
22
  end
15
23
 
16
24
  def counts
@@ -18,6 +26,11 @@ module Polyrun
18
26
  @counts.dup
19
27
  end
20
28
 
29
+ def example_counts
30
+ @example_counts ||= Hash.new(0)
31
+ @example_counts.dup
32
+ end
33
+
21
34
  def summary_lines(top: 20)
22
35
  @counts ||= Hash.new(0)
23
36
  sorted = @counts.sort_by { |_, n| -n }
@@ -57,6 +57,8 @@ module Polyrun
57
57
  end
58
58
 
59
59
  plan.each { |row| create_one_shard!(row, replace, force_drop, dry_run) }
60
+ rescue => e
61
+ raise Polyrun::Error, "CloneShards shard_index=#{shard_index}: #{e.message}"
60
62
  end
61
63
  end
62
64
  threads.each(&:join)
@@ -44,11 +44,12 @@ module Polyrun
44
44
 
45
45
  return u unless u.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
46
46
 
47
- if (m = u.match(%r{/([^/?]+)(\?|$)}))
47
+ if (m = u.match(%r{\A[a-z][a-z0-9+.-]*://[^/?#]+/([^/?]+)(\?|$)}i))
48
48
  base = m[1]
49
49
  suffixed = "#{base}_#{Integer(shard_index)}"
50
50
  u.sub(%r{/#{Regexp.escape(base)}(\?|$)}, "/#{suffixed}\\1")
51
51
  else
52
+ Polyrun::Log.warn "polyrun database: URL has no database segment; shard suffix skipped: #{u}"
52
53
  u
53
54
  end
54
55
  end
data/lib/polyrun/hooks.rb CHANGED
@@ -118,6 +118,9 @@ module Polyrun
118
118
  if reg&.any?(phase)
119
119
  begin
120
120
  reg.run(phase, merged)
121
+ rescue Interrupt
122
+ Polyrun::Log.warn "polyrun hooks: #{phase} ruby hook interrupted"
123
+ return 130
121
124
  rescue => e
122
125
  Polyrun::Log.warn "polyrun hooks: #{phase} ruby hook failed: #{e.class}: #{e.message}"
123
126
  return 1
@@ -125,7 +128,12 @@ module Polyrun
125
128
  end
126
129
 
127
130
  commands_for(phase).each do |cmd|
128
- ok = system(merged, "sh", "-c", 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
129
137
  return $?.exitstatus unless ok
130
138
  end
131
139
  0
data/lib/polyrun/log.rb CHANGED
@@ -6,6 +6,9 @@ module Polyrun
6
6
  #
7
7
  # Polyrun::Log.stderr = Logger.new($stderr)
8
8
  # Polyrun::Log.stdout = StringIO.new
9
+ #
10
+ # Orchestration (+orchestration_warn+): worker timeout and SIGINT lines use the same sink as +warn+ unless
11
+ # +POLYRUN_ORCHESTRATION_STDERR=1+ and stderr is not process +$stderr+ (then the summary is copied to +$stderr+).
9
12
  module Log
10
13
  class << self
11
14
  attr_writer :stderr
@@ -25,6 +28,19 @@ module Polyrun
25
28
  emit_line(stderr, msg)
26
29
  end
27
30
 
31
+ # Like {#warn}, and when +POLYRUN_ORCHESTRATION_STDERR=1+ and {#stderr} is not the process +$stderr+,
32
+ # also writes one line to +$stderr+ so timeout/interrupt attribution survives custom/null Log sinks.
33
+ def orchestration_warn(msg)
34
+ warn(msg)
35
+ return unless %w[1 true yes].include?(ENV["POLYRUN_ORCHESTRATION_STDERR"]&.downcase)
36
+ return if stderr.equal?($stderr)
37
+
38
+ # Intentionally the real stderr stream (+Kernel#warn+ routes through +Log.stderr+).
39
+ # rubocop:disable Style/StderrPuts
40
+ $stderr.puts(msg.to_s.chomp)
41
+ # rubocop:enable Style/StderrPuts
42
+ end
43
+
28
44
  def puts(msg = "")
29
45
  if msg.nil?
30
46
  stdout.write("\n")