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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -1
- data/CONTRIBUTING.md +23 -0
- data/docs/SETUP_PROFILE.md +29 -0
- data/ext/polyrun_coverage_merge/coverage_merge.c +492 -0
- data/ext/polyrun_coverage_merge/extconf.rb +3 -0
- data/lib/polyrun/benchmark/profile.rb +191 -0
- data/lib/polyrun/benchmark/report.rb +87 -0
- data/lib/polyrun/cli/benchmark_commands.rb +66 -0
- data/lib/polyrun/cli/coverage_commands.rb +4 -4
- data/lib/polyrun/cli/coverage_merge_io.rb +30 -4
- data/lib/polyrun/cli/failure_commands.rb +10 -3
- data/lib/polyrun/cli/help.rb +14 -7
- data/lib/polyrun/cli/report_commands.rb +25 -11
- data/lib/polyrun/cli/run_queue_command.rb +5 -32
- data/lib/polyrun/cli/run_shards_parallel_children.rb +7 -0
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +3 -0
- data/lib/polyrun/cli/run_shards_plan_options.rb +1 -1
- data/lib/polyrun/cli/run_shards_run.rb +1 -0
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +9 -1
- data/lib/polyrun/cli/spec_quality_commands.rb +16 -7
- data/lib/polyrun/cli.rb +8 -1
- data/lib/polyrun/coverage/example_diff.rb +127 -60
- data/lib/polyrun/coverage/example_diff_snapshot.rb +44 -0
- data/lib/polyrun/coverage/example_diff_track_filter.rb +51 -0
- data/lib/polyrun/coverage/file_stats_report.rb +36 -0
- data/lib/polyrun/coverage/formatter.rb +22 -1
- data/lib/polyrun/coverage/merge/formatters.rb +11 -3
- data/lib/polyrun/coverage/merge_merge_two.rb +112 -63
- data/lib/polyrun/coverage/merge_native.rb +37 -0
- data/lib/polyrun/coverage/reporting.rb +1 -1
- data/lib/polyrun/export/csv.rb +25 -0
- data/lib/polyrun/export/markdown.rb +47 -0
- data/lib/polyrun/hooks/shell_runner.rb +33 -0
- data/lib/polyrun/hooks.rb +4 -7
- data/lib/polyrun/partition/paths_build.rb +8 -0
- data/lib/polyrun/queue/worker_loop.rb +39 -0
- data/lib/polyrun/reporting/failure_merge.rb +48 -12
- data/lib/polyrun/reporting/junit.rb +8 -7
- data/lib/polyrun/reporting/junit_report.rb +87 -0
- data/lib/polyrun/rspec/example_debug.rb +158 -0
- data/lib/polyrun/rspec/example_debug_instrumentation.rb +73 -0
- data/lib/polyrun/rspec/sharded_formatter_compat.rb +57 -0
- data/lib/polyrun/rspec.rb +34 -0
- data/lib/polyrun/spec_quality/profile.rb +24 -12
- data/lib/polyrun/spec_quality/report.rb +93 -0
- data/lib/polyrun/spec_quality.rb +19 -11
- data/lib/polyrun/timing/summary.rb +55 -5
- data/lib/polyrun/version.rb +1 -1
- data/lib/polyrun/worker_output.rb +159 -0
- data/lib/polyrun/worker_output_forwarders.rb +90 -0
- data/polyrun.gemspec +8 -1
- data/sig/polyrun/rspec.rbs +10 -0
- data/sig/polyrun/worker_output.rbs +11 -0
- metadata +65 -2
|
@@ -3,6 +3,7 @@ require "optparse"
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
|
|
5
5
|
require_relative "../queue/duration"
|
|
6
|
+
require_relative "../queue/worker_loop"
|
|
6
7
|
|
|
7
8
|
module Polyrun
|
|
8
9
|
class CLI
|
|
@@ -83,44 +84,16 @@ module Polyrun
|
|
|
83
84
|
workers.times do |i|
|
|
84
85
|
wid = "worker-#{i}"
|
|
85
86
|
pid = Process.fork do
|
|
86
|
-
|
|
87
|
+
result = Polyrun::Queue::WorkerLoop.run(
|
|
88
|
+
store: store, worker_id: wid, batch: batch, cmd: cmd, on_failure: on_failure
|
|
89
|
+
)
|
|
90
|
+
exit result[:exit_code]
|
|
87
91
|
end
|
|
88
92
|
pids << {pid: pid, worker_id: wid}
|
|
89
93
|
end
|
|
90
94
|
pids
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
def run_queue_worker_loop(store:, worker_id:, batch:, cmd:, on_failure:)
|
|
94
|
-
batches_ok = 0
|
|
95
|
-
batches_fail = 0
|
|
96
|
-
loop do
|
|
97
|
-
claim = store.claim!(worker_id: worker_id, batch_size: batch)
|
|
98
|
-
paths = claim["paths"] || []
|
|
99
|
-
break if paths.empty?
|
|
100
|
-
|
|
101
|
-
code = run_queue_run_batch(cmd, paths)
|
|
102
|
-
if code == 0
|
|
103
|
-
store.ack!(lease_id: claim["lease_id"], worker_id: worker_id)
|
|
104
|
-
batches_ok += 1
|
|
105
|
-
elsif on_failure.to_s == "requeue"
|
|
106
|
-
store.reclaim_lease!(claim["lease_id"])
|
|
107
|
-
batches_fail += 1
|
|
108
|
-
exit 1
|
|
109
|
-
else
|
|
110
|
-
batches_fail += 1
|
|
111
|
-
exit code.zero? ? 1 : code
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
exit 0
|
|
115
|
-
rescue Polyrun::Error => e
|
|
116
|
-
Polyrun::Log.warn "polyrun run-queue worker #{worker_id}: #{e.message}"
|
|
117
|
-
exit 2
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def run_queue_run_batch(cmd, paths)
|
|
121
|
-
system(*cmd, *paths) ? 0 : ($?.exitstatus || 1)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
97
|
def run_queue_wait_workers(pids, store:, on_failure:)
|
|
125
98
|
ok = 0
|
|
126
99
|
fail = 0
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
|
+
require_relative "../worker_output"
|
|
3
4
|
require_relative "run_shards_parallel_wait"
|
|
4
5
|
|
|
5
6
|
module Polyrun
|
|
@@ -22,6 +23,7 @@ module Polyrun
|
|
|
22
23
|
mt = ctx[:matrix_shard_total]
|
|
23
24
|
|
|
24
25
|
pids = []
|
|
26
|
+
Polyrun::WorkerOutput.prepare_log_dir! if Polyrun::WorkerOutput.routing_enabled?
|
|
25
27
|
workers.times do |shard|
|
|
26
28
|
paths = plan.shard(shard)
|
|
27
29
|
if paths.empty?
|
|
@@ -60,6 +62,9 @@ module Polyrun
|
|
|
60
62
|
pids << {pid: pid, shard: shard, spawned_at: spawned_at, ping_path: ping_path}
|
|
61
63
|
Polyrun::Debug.log("[parent pid=#{$$}] run-shards: Process.spawn shard=#{shard} child_pid=#{pid} spec_files=#{paths.size}")
|
|
62
64
|
Polyrun::Log.warn "polyrun run-shards: started shard #{shard} pid=#{pid} (#{paths.size} file(s))" if parallel
|
|
65
|
+
if Polyrun::WorkerOutput.routing_enabled? && parallel
|
|
66
|
+
Polyrun::Log.warn "polyrun run-shards: shard #{shard} log → #{Polyrun::WorkerOutput.log_path_for(shard)}"
|
|
67
|
+
end
|
|
63
68
|
end
|
|
64
69
|
[pids, nil]
|
|
65
70
|
end
|
|
@@ -81,6 +86,8 @@ module Polyrun
|
|
|
81
86
|
end
|
|
82
87
|
|
|
83
88
|
def run_shards_spawn_one_worker(child_env, cmd, paths, hook_cfg)
|
|
89
|
+
return Polyrun::WorkerOutput.spawn_worker(child_env, cmd, paths, hook_cfg) if Polyrun::WorkerOutput.routing_enabled?
|
|
90
|
+
|
|
84
91
|
if hook_cfg.worker_hooks? && !Polyrun::Hooks.disabled?
|
|
85
92
|
Process.spawn(child_env, "sh", "-c", hook_cfg.build_worker_shell_script(cmd, paths))
|
|
86
93
|
else
|
|
@@ -111,6 +111,7 @@ module Polyrun
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def run_shards_finalize_reaped_worker!(h, hook_cfg, workers, st, shard_results, after_hook_err)
|
|
114
|
+
Polyrun::WorkerOutput.finish_worker(h[:pid])
|
|
114
115
|
exitstatus = st.exitstatus
|
|
115
116
|
ok = st.success?
|
|
116
117
|
Polyrun::Debug.log("[parent pid=#{$$}] run-shards: Process.wait child_pid=#{h[:pid]} shard=#{h[:shard]} exit=#{exitstatus} success=#{ok}")
|
|
@@ -218,6 +219,7 @@ module Polyrun
|
|
|
218
219
|
_t, loc = run_shards_read_worker_ping_payload(h[:ping_path])
|
|
219
220
|
ping_suffix = (loc && !loc.to_s.strip.empty?) ? "; last ping #{loc.to_s.strip}" : ""
|
|
220
221
|
Polyrun::Log.orchestration_warn "polyrun run-shards: WORKER IDLE TIMEOUT after #{idle_sec}s since last per-example progress ping — shard #{h[:shard]} pid #{h[:pid]}#{ping_suffix}."
|
|
222
|
+
Polyrun::WorkerOutput.warn_shard_log(h[:shard])
|
|
221
223
|
Polyrun::Log.warn "polyrun run-shards: idle shard file sample: #{sample}#{suffix}"
|
|
222
224
|
Polyrun::Log.warn "polyrun run-shards: enable per-example worker progress pings in your test setup so idle timeouts reflect real work; exit #{WORKER_IDLE_TIMEOUT_EXIT_STATUS}."
|
|
223
225
|
end
|
|
@@ -262,6 +264,7 @@ module Polyrun
|
|
|
262
264
|
""
|
|
263
265
|
end
|
|
264
266
|
Polyrun::Log.orchestration_warn "polyrun run-shards: WORKER TIMEOUT after #{timeout_sec}s (wall time since worker spawn) — shard #{h[:shard]} pid #{h[:pid]}."
|
|
267
|
+
Polyrun::WorkerOutput.warn_shard_log(h[:shard])
|
|
265
268
|
Polyrun::Log.warn "polyrun run-shards: timeout shard includes: #{sample}#{suffix}"
|
|
266
269
|
Polyrun::Log.warn "polyrun run-shards: override with --worker-timeout SEC or POLYRUN_WORKER_TIMEOUT_SEC; recorded exit #{WORKER_TIMEOUT_EXIT_STATUS} for this worker."
|
|
267
270
|
end
|
|
@@ -63,7 +63,7 @@ module Polyrun
|
|
|
63
63
|
opts.on("--merge-format LIST", String) { |v| st[:merge_format] = v }
|
|
64
64
|
opts.on("--merge-failures", "After all workers exit, merge failure fragments from tmp/polyrun_failures (requires failure fragments in test setup)") { st[:merge_failures] = true }
|
|
65
65
|
opts.on("--merge-failures-output PATH", String) { |v| st[:merge_failures_output] = v }
|
|
66
|
-
opts.on("--merge-failures-format VAL", "jsonl (default) or
|
|
66
|
+
opts.on("--merge-failures-format VAL", "jsonl (default), json, csv, or markdown") { |v| st[:merge_failures_format] = v }
|
|
67
67
|
opts.on("--merge-spec-quality", "After workers exit, merge spec-quality fragments from coverage (enable spec-quality collection in test setup)") { st[:merge_spec_quality] = true }
|
|
68
68
|
opts.on("--merge-spec-quality-output PATH", String) { |v| st[:merge_spec_quality_output] = v }
|
|
69
69
|
opts.on("--no-report-spec-quality", "Skip printing spec-quality report after merge") { st[:report_spec_quality] = false }
|
|
@@ -124,6 +124,7 @@ module Polyrun
|
|
|
124
124
|
|
|
125
125
|
def run_shards_warn_interleaved(parallel, pid_count)
|
|
126
126
|
return unless parallel && pid_count > 1
|
|
127
|
+
return if Polyrun::WorkerOutput.routing_enabled?
|
|
127
128
|
|
|
128
129
|
Polyrun::Log.warn "polyrun run-shards: #{pid_count} children running; RSpec output below may be interleaved."
|
|
129
130
|
Polyrun::Log.warn "polyrun run-shards: each worker prints its own summary line; the last \"N examples\" line is not a total across shards."
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "../worker_output"
|
|
2
|
+
|
|
1
3
|
module Polyrun
|
|
2
4
|
class CLI
|
|
3
5
|
# SIGINT/SIGTERM handling and non-blocking reap for parallel worker PIDs (used by run-shards / ci-shard fan-out).
|
|
@@ -7,12 +9,17 @@ module Polyrun
|
|
|
7
9
|
def run_shards_log_interrupt_workers(pids, _ctx)
|
|
8
10
|
parts = pids.map { |h| "shard=#{h[:shard]} pid=#{h[:pid]}" }
|
|
9
11
|
Polyrun::Log.orchestration_warn "polyrun run-shards: SIGINT/SIGTERM while waiting on workers — stopping: #{parts.join(", ")}"
|
|
10
|
-
Polyrun::
|
|
12
|
+
if Polyrun::WorkerOutput.routing_enabled?
|
|
13
|
+
Polyrun::Log.warn "polyrun run-shards: per-shard worker logs under #{Polyrun::WorkerOutput.worker_log_directory_label}"
|
|
14
|
+
else
|
|
15
|
+
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"
|
|
16
|
+
end
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
# Best-effort worker teardown then exit. Does not return.
|
|
14
20
|
def run_shards_shutdown_on_signal!(pids, code)
|
|
15
21
|
run_shards_log_interrupt_workers(pids, nil)
|
|
22
|
+
Polyrun::WorkerOutput.shutdown_all!
|
|
16
23
|
run_shards_terminate_children!(pids)
|
|
17
24
|
exit(code)
|
|
18
25
|
rescue Interrupt
|
|
@@ -23,6 +30,7 @@ module Polyrun
|
|
|
23
30
|
|
|
24
31
|
# Send SIGTERM to each worker PID and wait so Ctrl+C / SIGTERM does not leave orphans.
|
|
25
32
|
def run_shards_terminate_children!(pids)
|
|
33
|
+
Polyrun::WorkerOutput.shutdown_all!
|
|
26
34
|
run_shards_signal_workers_term(pids)
|
|
27
35
|
run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
|
|
28
36
|
end
|
|
@@ -43,10 +43,11 @@ module Polyrun
|
|
|
43
43
|
config_path = nil
|
|
44
44
|
strict = false
|
|
45
45
|
json_out = false
|
|
46
|
+
report_format = nil
|
|
46
47
|
plan_paths = []
|
|
47
48
|
|
|
48
49
|
OptionParser.new do |opts|
|
|
49
|
-
opts.banner = "usage: polyrun report-spec-quality -i FILE [-o PATH] [--top N] [--profile LIST] [--strict] [--json]"
|
|
50
|
+
opts.banner = "usage: polyrun report-spec-quality -i FILE [-o PATH] [--top N] [--profile LIST] [--strict] [--format text|json|csv|markdown] [--json]"
|
|
50
51
|
opts.on("-i", "--input PATH", "Merged polyrun-spec-quality.json") { |v| input = v }
|
|
51
52
|
opts.on("-o", "--output PATH", "Write report to file instead of stdout") { |v| out_file = v }
|
|
52
53
|
opts.on("--top N", Integer) { |v| top = v }
|
|
@@ -54,6 +55,7 @@ module Polyrun
|
|
|
54
55
|
opts.on("-c", "--config PATH", "polyrun_spec_quality.yml path") { |v| config_path = v }
|
|
55
56
|
opts.on("--plan PATH", "Partition plan JSON (repeatable; polyrun plan output per shard)") { |v| plan_paths << v }
|
|
56
57
|
opts.on("--strict", "Exit 1 when gate thresholds fail") { strict = true }
|
|
58
|
+
opts.on("--format VAL", "text (default), json, csv, or markdown") { |v| report_format = v }
|
|
57
59
|
opts.on("--json", "Write analysis JSON instead of text report") { json_out = true }
|
|
58
60
|
end.parse!(argv)
|
|
59
61
|
input ||= argv.first
|
|
@@ -67,13 +69,20 @@ module Polyrun
|
|
|
67
69
|
cfg = load_spec_quality_config(config_path)
|
|
68
70
|
strict = true if cfg["strict"] || strict
|
|
69
71
|
plan_shards = Polyrun::SpecQuality::PlanLoader.load_shards(plan_paths)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
resolved_format = report_format || (json_out ? "json" : "text")
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
text = Polyrun::SpecQuality::Report.render(
|
|
76
|
+
merged,
|
|
77
|
+
format: resolved_format,
|
|
78
|
+
cfg: cfg,
|
|
79
|
+
top: top,
|
|
80
|
+
profile: profile,
|
|
81
|
+
plan_shards: plan_shards
|
|
76
82
|
)
|
|
83
|
+
rescue Polyrun::Error => e
|
|
84
|
+
Polyrun::Log.warn e.message.to_s
|
|
85
|
+
return 2
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
if out_file
|
data/lib/polyrun/cli.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "cli/run_queue_command"
|
|
|
14
14
|
require_relative "cli/queue_command"
|
|
15
15
|
require_relative "cli/timing_command"
|
|
16
16
|
require_relative "cli/spec_quality_commands"
|
|
17
|
+
require_relative "cli/benchmark_commands"
|
|
17
18
|
require_relative "cli/init_command"
|
|
18
19
|
require_relative "cli/quick_command"
|
|
19
20
|
require_relative "cli/ci_shard_run_parse"
|
|
@@ -32,7 +33,7 @@ module Polyrun
|
|
|
32
33
|
|
|
33
34
|
# Keep in sync with +dispatch_cli_command_subcommands+ (+when+ branches). Used for implicit path routing.
|
|
34
35
|
DISPATCH_SUBCOMMAND_NAMES = %w[
|
|
35
|
-
plan prepare merge-coverage merge-failures merge-spec-quality report-coverage report-junit report-timing report-spec-quality
|
|
36
|
+
plan prepare merge-coverage merge-failures merge-spec-quality report-coverage report-junit report-timing report-spec-quality report-benchmark bench
|
|
36
37
|
env config merge-timing db:setup-template db:setup-shard db:clone-shards
|
|
37
38
|
run-shards parallel-rspec start build-paths init queue run-queue quick hook
|
|
38
39
|
].freeze
|
|
@@ -55,6 +56,7 @@ module Polyrun
|
|
|
55
56
|
include QueueCommand
|
|
56
57
|
include TimingCommand
|
|
57
58
|
include SpecQualityCommands
|
|
59
|
+
include BenchmarkCommands
|
|
58
60
|
include InitCommand
|
|
59
61
|
include QuickCommand
|
|
60
62
|
include CiShardRunParse
|
|
@@ -120,6 +122,7 @@ module Polyrun
|
|
|
120
122
|
config_path = argv.shift or break
|
|
121
123
|
when "-v", "--verbose"
|
|
122
124
|
@verbose = true
|
|
125
|
+
ENV["POLYRUN_VERBOSE"] = "1"
|
|
123
126
|
argv.shift
|
|
124
127
|
when "-h", "--help"
|
|
125
128
|
print_help
|
|
@@ -170,6 +173,10 @@ module Polyrun
|
|
|
170
173
|
cmd_merge_spec_quality(argv)
|
|
171
174
|
when "report-spec-quality"
|
|
172
175
|
cmd_report_spec_quality(argv)
|
|
176
|
+
when "report-benchmark"
|
|
177
|
+
cmd_report_benchmark(argv)
|
|
178
|
+
when "bench"
|
|
179
|
+
cmd_bench(argv)
|
|
173
180
|
when "db:setup-template"
|
|
174
181
|
cmd_db_setup_template(argv, config_path)
|
|
175
182
|
when "db:setup-shard"
|
|
@@ -1,63 +1,124 @@
|
|
|
1
|
+
require_relative "example_diff_track_filter"
|
|
2
|
+
require_relative "example_diff_snapshot"
|
|
3
|
+
|
|
1
4
|
module Polyrun
|
|
2
5
|
module Coverage
|
|
3
6
|
# Per-example line hit deltas from stdlib +Coverage.peek_result+ snapshots.
|
|
4
7
|
module ExampleDiff
|
|
5
8
|
module_function
|
|
6
9
|
|
|
7
|
-
# @return [Hash{String=>Hash}] path =>
|
|
8
|
-
def peek_blob
|
|
10
|
+
# @return [Hash{String=>Hash}] path => sparse line snapshot
|
|
11
|
+
def peek_blob(root: nil, track_under: nil, ignore_paths: nil)
|
|
9
12
|
return {} unless coverage_active?
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
snapshot_peek(
|
|
15
|
+
::Coverage.peek_result,
|
|
16
|
+
root: root,
|
|
17
|
+
track_under: track_under,
|
|
18
|
+
ignore_paths: ignore_paths
|
|
19
|
+
)
|
|
12
20
|
end
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
# Frozen snapshot for storage between peeks (stdlib mutates line arrays in place).
|
|
23
|
+
def snapshot_peek(raw, root: nil, track_under: nil, ignore_paths: nil)
|
|
24
|
+
return {} if raw.nil? || raw.empty?
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
filter = track_filter_for(root: root, track_under: track_under, ignore_paths: ignore_paths)
|
|
19
27
|
out = {}
|
|
20
|
-
raw.each do |path,
|
|
21
|
-
next unless
|
|
28
|
+
raw.each do |path, coverage_entry|
|
|
29
|
+
next unless coverage_entry.is_a?(Hash)
|
|
30
|
+
next if filter && !filter.include_path?(path.to_s)
|
|
22
31
|
|
|
23
|
-
lines =
|
|
32
|
+
lines = coverage_entry[:lines] || coverage_entry["lines"]
|
|
24
33
|
next unless lines.is_a?(Array)
|
|
25
34
|
|
|
26
|
-
out[path.to_s] =
|
|
35
|
+
out[path.to_s] = Snapshot.sparse_snapshot_lines(lines)
|
|
27
36
|
end
|
|
28
37
|
out
|
|
29
38
|
end
|
|
30
39
|
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
# +after_source+ may be a normalized blob or a raw +Coverage.peek_result+ hash.
|
|
41
|
+
def diff(before_blob, after_source, root: nil, track_under: nil, ignore_paths: nil)
|
|
42
|
+
filter = track_filter_for(root: root, track_under: track_under, ignore_paths: ignore_paths)
|
|
43
|
+
if raw_peek_result?(after_source)
|
|
44
|
+
diff_against_peek(before_blob, after_source, filter: filter)
|
|
45
|
+
else
|
|
46
|
+
diff_blobs(before_blob, after_source, filter: filter)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def coverage_active?
|
|
51
|
+
defined?(::Coverage) && ::Coverage.respond_to?(:running?) && ::Coverage.running?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def diff_blobs(before_blob, after_blob, filter: nil)
|
|
34
55
|
before_blob ||= {}
|
|
35
56
|
after_blob ||= {}
|
|
36
|
-
|
|
57
|
+
paths = collect_diff_paths(before_blob, after_blob, filter: filter)
|
|
58
|
+
diff_paths(paths, before_blob, after_blob, raw_after: false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def diff_against_peek(before_blob, after_raw, filter: nil)
|
|
62
|
+
before_blob ||= {}
|
|
63
|
+
after_raw ||= {}
|
|
64
|
+
paths = collect_diff_paths(before_blob, after_raw, filter: filter)
|
|
65
|
+
diff_paths(paths, before_blob, after_raw, raw_after: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def filter_lines(lines, root:, track_under:, ignore_paths: [])
|
|
69
|
+
filter = TrackFilter.new(root: root, track_under: track_under, ignore_paths: ignore_paths)
|
|
70
|
+
lines.select { |path, _line, _delta| filter.include_path?(path) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_track_under(delta, root:, track_under:, ignore_paths: [])
|
|
74
|
+
filtered = filter_lines(delta[:lines] || [], root: root, track_under: track_under, ignore_paths: ignore_paths)
|
|
75
|
+
unique = filtered.size
|
|
76
|
+
churn = filtered.sum { |(_path, _line, hit_delta)| hit_delta }
|
|
77
|
+
max_churn = filtered.map { |(_path, _line, hit_delta)| hit_delta }.max || 0
|
|
78
|
+
{
|
|
79
|
+
unique_lines: unique,
|
|
80
|
+
line_churn: churn,
|
|
81
|
+
max_line_churn: max_churn,
|
|
82
|
+
lines: filtered
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def line_array(entry)
|
|
87
|
+
return [] unless entry.is_a?(Hash)
|
|
88
|
+
return [] if entry["sparse"]
|
|
37
89
|
|
|
90
|
+
arr = entry["lines"] || entry[:lines]
|
|
91
|
+
arr.is_a?(Array) ? arr : []
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raw_peek_result?(source)
|
|
95
|
+
return false unless source.is_a?(Hash)
|
|
96
|
+
return false if source.empty?
|
|
97
|
+
|
|
98
|
+
entry = source.values.first
|
|
99
|
+
entry.is_a?(Hash) && (entry.key?(:lines) || entry.key?(:branches))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def diff_paths(paths, before_blob, after_blob, raw_after:)
|
|
38
103
|
line_entries = []
|
|
39
104
|
unique = 0
|
|
40
105
|
churn = 0
|
|
41
106
|
max_churn = 0
|
|
42
107
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
next if a.nil? && b.nil?
|
|
108
|
+
paths.each do |path|
|
|
109
|
+
before_entry = before_blob[path]
|
|
110
|
+
after_lines =
|
|
111
|
+
if raw_after
|
|
112
|
+
lines_from_peek_entry(path, after_blob)
|
|
113
|
+
else
|
|
114
|
+
line_array(after_blob[path])
|
|
115
|
+
end
|
|
52
116
|
|
|
53
|
-
|
|
54
|
-
next unless delta.positive?
|
|
55
|
-
|
|
56
|
-
line_no = i + 1
|
|
57
|
-
line_entries << [path, line_no, delta]
|
|
117
|
+
accumulate_line_delta(before_entry, after_lines) do |delta, line_no|
|
|
58
118
|
unique += 1
|
|
59
119
|
churn += delta
|
|
60
120
|
max_churn = delta if delta > max_churn
|
|
121
|
+
line_entries << [path, line_no, delta]
|
|
61
122
|
end
|
|
62
123
|
end
|
|
63
124
|
|
|
@@ -68,55 +129,61 @@ module Polyrun
|
|
|
68
129
|
lines: line_entries
|
|
69
130
|
}
|
|
70
131
|
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
132
|
|
|
82
|
-
|
|
133
|
+
def collect_diff_paths(before_blob, after_blob, filter: nil)
|
|
134
|
+
keys = before_blob.keys.dup
|
|
135
|
+
after_blob.each_key do |path|
|
|
136
|
+
string_path = path.to_s
|
|
137
|
+
keys << string_path unless keys.include?(string_path)
|
|
83
138
|
end
|
|
84
|
-
|
|
139
|
+
return keys unless filter
|
|
85
140
|
|
|
86
|
-
|
|
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
|
-
}
|
|
141
|
+
keys.select { |path| filter.include_path?(path) }
|
|
97
142
|
end
|
|
98
143
|
|
|
99
|
-
def
|
|
100
|
-
|
|
144
|
+
def accumulate_line_delta(before_entry, after_lines)
|
|
145
|
+
before_hits = Snapshot.hits_map(before_entry)
|
|
146
|
+
before_max = before_hits.empty? ? 0 : before_hits.keys.max + 1
|
|
147
|
+
max_len = [after_lines.size, before_max].max
|
|
101
148
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
149
|
+
(0...max_len).each do |index|
|
|
150
|
+
before_hit = before_hits[index]
|
|
151
|
+
after_hit = after_lines[index]
|
|
152
|
+
next if after_hit.nil? && before_hit.nil?
|
|
153
|
+
|
|
154
|
+
delta = integer_hit(after_hit) - integer_hit(before_hit)
|
|
155
|
+
next unless delta.positive?
|
|
156
|
+
|
|
157
|
+
yield(delta, index + 1)
|
|
158
|
+
end
|
|
105
159
|
end
|
|
106
160
|
|
|
107
|
-
def
|
|
161
|
+
def lines_from_peek_entry(path, after_raw)
|
|
162
|
+
entry = after_raw[path] || after_raw[path.to_s]
|
|
108
163
|
return [] unless entry.is_a?(Hash)
|
|
109
164
|
|
|
110
|
-
arr = entry[
|
|
165
|
+
arr = entry[:lines] || entry["lines"]
|
|
111
166
|
arr.is_a?(Array) ? arr : []
|
|
112
167
|
end
|
|
113
168
|
|
|
169
|
+
def track_filter_for(root:, track_under:, ignore_paths:)
|
|
170
|
+
return nil if track_under.nil? && ignore_paths.nil?
|
|
171
|
+
|
|
172
|
+
TrackFilter.new(
|
|
173
|
+
root: root || Dir.pwd,
|
|
174
|
+
track_under: track_under,
|
|
175
|
+
ignore_paths: ignore_paths || []
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
114
179
|
def integer_hit(value)
|
|
115
180
|
return 0 if value.nil?
|
|
181
|
+
return 0 if value == "ignored"
|
|
116
182
|
|
|
117
183
|
value.is_a?(Integer) ? value : value.to_i
|
|
118
184
|
end
|
|
119
|
-
private_class_method :
|
|
185
|
+
private_class_method :diff_paths, :collect_diff_paths, :accumulate_line_delta,
|
|
186
|
+
:lines_from_peek_entry, :track_filter_for, :integer_hit, :raw_peek_result?
|
|
120
187
|
end
|
|
121
188
|
end
|
|
122
189
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Coverage
|
|
3
|
+
module ExampleDiff
|
|
4
|
+
# Sparse line-hit snapshots for per-example diff storage.
|
|
5
|
+
module Snapshot
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def sparse_snapshot_lines(lines)
|
|
9
|
+
{"sparse" => true, "hits" => dense_hits_map(lines)}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def hits_map(entry)
|
|
13
|
+
return {} if entry.nil?
|
|
14
|
+
|
|
15
|
+
if entry.is_a?(Hash) && entry["sparse"]
|
|
16
|
+
entry["hits"] || {}
|
|
17
|
+
else
|
|
18
|
+
dense_hits_map(ExampleDiff.line_array(entry))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def dense_hits_map(lines)
|
|
23
|
+
hits = {}
|
|
24
|
+
lines.each_with_index do |value, index|
|
|
25
|
+
stored = snapshot_line_value(value)
|
|
26
|
+
hits[index] = stored unless stored.nil?
|
|
27
|
+
end
|
|
28
|
+
hits
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def snapshot_line_value(value)
|
|
32
|
+
case value
|
|
33
|
+
when nil then nil
|
|
34
|
+
when "ignored" then "ignored"
|
|
35
|
+
when Integer then value
|
|
36
|
+
else
|
|
37
|
+
parsed = Integer(value, exception: false)
|
|
38
|
+
parsed.nil? ? value : parsed
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Coverage
|
|
3
|
+
module ExampleDiff
|
|
4
|
+
# Path inclusion for scoped snapshots and track-aware diffs.
|
|
5
|
+
class TrackFilter
|
|
6
|
+
def initialize(root:, track_under: nil, ignore_paths: [])
|
|
7
|
+
@root = root ? File.expand_path(root) : nil
|
|
8
|
+
@track_under = Array(track_under).map(&:to_s).reject(&:empty?)
|
|
9
|
+
@ignore = Array(ignore_paths).map(&:to_s).reject(&:empty?)
|
|
10
|
+
@prefixes =
|
|
11
|
+
if @root && !@track_under.empty?
|
|
12
|
+
@track_under.map { |directory| File.join(@root, directory) }
|
|
13
|
+
else
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def include_path?(path)
|
|
19
|
+
absolute = absolute_path(path)
|
|
20
|
+
return false if ignored?(absolute)
|
|
21
|
+
|
|
22
|
+
return true if @prefixes.empty?
|
|
23
|
+
|
|
24
|
+
@prefixes.any? { |prefix| absolute == prefix || absolute.start_with?(prefix + "/") }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def absolute_path(path)
|
|
30
|
+
if @root
|
|
31
|
+
File.expand_path(path.to_s, @root)
|
|
32
|
+
else
|
|
33
|
+
File.expand_path(path.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ignored?(absolute)
|
|
38
|
+
@ignore.any? { |pattern| path_matches_ignore?(absolute, pattern) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def path_matches_ignore?(path, pattern)
|
|
42
|
+
return path.include?(pattern) if !pattern.start_with?("/")
|
|
43
|
+
|
|
44
|
+
path.match?(Regexp.new(pattern))
|
|
45
|
+
rescue RegexpError
|
|
46
|
+
path.include?(pattern)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "../export/csv"
|
|
2
|
+
require_relative "../export/markdown"
|
|
3
|
+
|
|
4
|
+
module Polyrun
|
|
5
|
+
module Coverage
|
|
6
|
+
# Per-file line coverage tables for CSV and Markdown exports.
|
|
7
|
+
module FileStatsReport
|
|
8
|
+
HEADERS = %w[path line_percent lines_covered lines_relevant].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def file_rows(coverage_blob)
|
|
13
|
+
coverage_blob.map do |path, file_entry|
|
|
14
|
+
line_percent, lines_relevant, lines_covered = Merge.file_line_stats(file_entry)
|
|
15
|
+
[path, format("%.2f", line_percent), lines_covered, lines_relevant]
|
|
16
|
+
end.sort_by { |row| row[1].to_f }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def emit_csv(coverage_blob)
|
|
20
|
+
Export::Csv.generate(HEADERS, file_rows(coverage_blob))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def emit_markdown(coverage_blob, title: "Polyrun coverage report")
|
|
24
|
+
summary = Merge.console_summary(coverage_blob)
|
|
25
|
+
summary_line = Merge.format_console_summary(summary).strip
|
|
26
|
+
Export::Markdown.document(
|
|
27
|
+
title,
|
|
28
|
+
[
|
|
29
|
+
{body: summary_line},
|
|
30
|
+
{heading: "Per-file coverage", headers: HEADERS, rows: file_rows(coverage_blob)}
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|