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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +2 -2
- data/docs/SETUP_PROFILE.md +2 -0
- data/lib/polyrun/cli/ci_shard_hooks.rb +12 -4
- data/lib/polyrun/cli/ci_shard_run_command.rb +3 -1
- data/lib/polyrun/cli/help.rb +10 -2
- data/lib/polyrun/cli/helpers.rb +38 -0
- data/lib/polyrun/cli/init_command.rb +8 -1
- data/lib/polyrun/cli/partition_diagnostics.rb +22 -0
- data/lib/polyrun/cli/plan_command.rb +47 -18
- data/lib/polyrun/cli/queue_command.rb +25 -2
- data/lib/polyrun/cli/run_queue_command.rb +145 -0
- data/lib/polyrun/cli/run_shards_command.rb +6 -1
- data/lib/polyrun/cli/run_shards_parallel_children.rb +28 -35
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +267 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +81 -3
- data/lib/polyrun/cli/run_shards_plan_options.rb +17 -3
- data/lib/polyrun/cli/run_shards_planning.rb +20 -12
- data/lib/polyrun/cli/run_shards_run.rb +28 -37
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +75 -0
- data/lib/polyrun/cli/spec_quality_commands.rb +140 -0
- data/lib/polyrun/cli.rb +16 -2
- data/lib/polyrun/coverage/example_diff.rb +122 -0
- data/lib/polyrun/coverage/merge/formatters_html.rb +4 -0
- data/lib/polyrun/data/factory_counts.rb +14 -1
- data/lib/polyrun/database/clone_shards.rb +2 -0
- data/lib/polyrun/database/shard.rb +2 -1
- data/lib/polyrun/hooks.rb +9 -1
- data/lib/polyrun/log.rb +16 -0
- data/lib/polyrun/minitest.rb +43 -0
- data/lib/polyrun/partition/hrw.rb +40 -3
- data/lib/polyrun/partition/paths_build.rb +8 -3
- data/lib/polyrun/partition/plan.rb +88 -19
- data/lib/polyrun/partition/plan_lpt.rb +49 -7
- data/lib/polyrun/partition/plan_sharding.rb +8 -0
- data/lib/polyrun/partition/reports.rb +139 -0
- data/lib/polyrun/partition/timing_diagnostics.rb +139 -0
- data/lib/polyrun/partition/timing_keys.rb +2 -1
- data/lib/polyrun/queue/duration.rb +30 -0
- data/lib/polyrun/queue/file_store.rb +107 -3
- data/lib/polyrun/quick/example_runner.rb +13 -0
- data/lib/polyrun/quick/runner.rb +21 -0
- data/lib/polyrun/rspec.rb +26 -0
- data/lib/polyrun/spec_quality/config.rb +134 -0
- data/lib/polyrun/spec_quality/fragment.rb +39 -0
- data/lib/polyrun/spec_quality/merge.rb +78 -0
- data/lib/polyrun/spec_quality/minitest_hook.rb +42 -0
- data/lib/polyrun/spec_quality/plan_loader.rb +47 -0
- data/lib/polyrun/spec_quality/profile.rb +91 -0
- data/lib/polyrun/spec_quality/report.rb +261 -0
- data/lib/polyrun/spec_quality/rspec_hook.rb +55 -0
- data/lib/polyrun/spec_quality/sql_counter.rb +34 -0
- data/lib/polyrun/spec_quality.rb +205 -0
- data/lib/polyrun/templates/POLYRUN.md +6 -0
- data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -0
- data/lib/polyrun/templates/polyrun_hooks_spec_quality.rb +12 -0
- data/lib/polyrun/templates/polyrun_spec_quality.yml +20 -0
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +5 -0
- data/lib/polyrun/timing/merge.rb +5 -5
- data/lib/polyrun/timing/stats.rb +76 -0
- data/lib/polyrun/timing/summary.rb +5 -2
- data/lib/polyrun/timing/variance_report.rb +51 -0
- data/lib/polyrun/version.rb +1 -1
- data/lib/polyrun/worker_ping.rb +74 -0
- data/sig/polyrun/minitest.rbs +2 -0
- data/sig/polyrun/rspec.rbs +4 -0
- data/sig/polyrun/worker_ping.rbs +10 -0
- 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
|
|
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
|
-
|
|
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
|
-
@
|
|
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 }
|
|
@@ -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 =
|
|
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")
|