polyrun 1.0.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 +7 -0
- data/CODE_OF_CONDUCT.md +31 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +140 -0
- data/SECURITY.md +27 -0
- data/bin/polyrun +6 -0
- data/docs/SETUP_PROFILE.md +106 -0
- data/lib/polyrun/cli/coverage_commands.rb +150 -0
- data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
- data/lib/polyrun/cli/database_commands.rb +149 -0
- data/lib/polyrun/cli/env_commands.rb +43 -0
- data/lib/polyrun/cli/helpers.rb +113 -0
- data/lib/polyrun/cli/init_command.rb +99 -0
- data/lib/polyrun/cli/plan_command.rb +134 -0
- data/lib/polyrun/cli/prepare_command.rb +71 -0
- data/lib/polyrun/cli/prepare_recipe.rb +77 -0
- data/lib/polyrun/cli/queue_command.rb +101 -0
- data/lib/polyrun/cli/quick_command.rb +13 -0
- data/lib/polyrun/cli/report_commands.rb +94 -0
- data/lib/polyrun/cli/run_shards_command.rb +88 -0
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
- data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
- data/lib/polyrun/cli/run_shards_planning.rb +124 -0
- data/lib/polyrun/cli/run_shards_run.rb +168 -0
- data/lib/polyrun/cli/start_bootstrap.rb +99 -0
- data/lib/polyrun/cli/timing_command.rb +31 -0
- data/lib/polyrun/cli.rb +184 -0
- data/lib/polyrun/config.rb +61 -0
- data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
- data/lib/polyrun/coverage/collector.rb +184 -0
- data/lib/polyrun/coverage/collector_finish.rb +95 -0
- data/lib/polyrun/coverage/filter.rb +22 -0
- data/lib/polyrun/coverage/formatter.rb +115 -0
- data/lib/polyrun/coverage/merge/formatters.rb +181 -0
- data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
- data/lib/polyrun/coverage/merge.rb +127 -0
- data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
- data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
- data/lib/polyrun/coverage/rails.rb +128 -0
- data/lib/polyrun/coverage/reporting.rb +41 -0
- data/lib/polyrun/coverage/result.rb +18 -0
- data/lib/polyrun/coverage/track_files.rb +141 -0
- data/lib/polyrun/data/cached_fixtures.rb +122 -0
- data/lib/polyrun/data/factory_counts.rb +35 -0
- data/lib/polyrun/data/factory_instrumentation.rb +50 -0
- data/lib/polyrun/data/fixtures.rb +68 -0
- data/lib/polyrun/data/parallel_provisioning.rb +93 -0
- data/lib/polyrun/data/snapshot.rb +84 -0
- data/lib/polyrun/database/clone_shards.rb +81 -0
- data/lib/polyrun/database/provision.rb +72 -0
- data/lib/polyrun/database/shard.rb +63 -0
- data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
- data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
- data/lib/polyrun/database/url_builder/connection.rb +191 -0
- data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
- data/lib/polyrun/database/url_builder.rb +160 -0
- data/lib/polyrun/debug.rb +81 -0
- data/lib/polyrun/env/ci.rb +65 -0
- data/lib/polyrun/log.rb +70 -0
- data/lib/polyrun/minitest.rb +17 -0
- data/lib/polyrun/partition/constraints.rb +69 -0
- data/lib/polyrun/partition/hrw.rb +33 -0
- data/lib/polyrun/partition/min_heap.rb +64 -0
- data/lib/polyrun/partition/paths.rb +28 -0
- data/lib/polyrun/partition/paths_build.rb +128 -0
- data/lib/polyrun/partition/plan.rb +189 -0
- data/lib/polyrun/partition/plan_lpt.rb +49 -0
- data/lib/polyrun/partition/plan_sharding.rb +48 -0
- data/lib/polyrun/partition/stable_shuffle.rb +18 -0
- data/lib/polyrun/prepare/artifacts.rb +40 -0
- data/lib/polyrun/prepare/assets.rb +57 -0
- data/lib/polyrun/queue/file_store.rb +199 -0
- data/lib/polyrun/queue/file_store_pending.rb +48 -0
- data/lib/polyrun/quick/assertions.rb +32 -0
- data/lib/polyrun/quick/errors.rb +6 -0
- data/lib/polyrun/quick/example_group.rb +66 -0
- data/lib/polyrun/quick/example_runner.rb +93 -0
- data/lib/polyrun/quick/matchers.rb +156 -0
- data/lib/polyrun/quick/reporter.rb +42 -0
- data/lib/polyrun/quick/runner.rb +180 -0
- data/lib/polyrun/quick.rb +1 -0
- data/lib/polyrun/railtie.rb +7 -0
- data/lib/polyrun/reporting/junit.rb +125 -0
- data/lib/polyrun/reporting/junit_emit.rb +58 -0
- data/lib/polyrun/reporting/rspec_junit.rb +39 -0
- data/lib/polyrun/rspec.rb +15 -0
- data/lib/polyrun/templates/POLYRUN.md +45 -0
- data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
- data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
- data/lib/polyrun/timing/merge.rb +35 -0
- data/lib/polyrun/timing/summary.rb +25 -0
- data/lib/polyrun/version.rb +3 -0
- data/lib/polyrun.rb +58 -0
- data/polyrun.gemspec +37 -0
- data/sig/polyrun/cli.rbs +6 -0
- data/sig/polyrun/config.rbs +20 -0
- data/sig/polyrun/debug.rbs +12 -0
- data/sig/polyrun/log.rbs +12 -0
- data/sig/polyrun/minitest.rbs +5 -0
- data/sig/polyrun/quick.rbs +19 -0
- data/sig/polyrun/rspec.rbs +5 -0
- data/sig/polyrun.rbs +11 -0
- metadata +288 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Coverage
|
|
3
|
+
# Lists Cobertura +line+ elements with +hits="0"+ (optional dev aid). Set +SHOW_ZERO_COVERAGE=1+ and run
|
|
4
|
+
# after Cobertura XML exists (e.g. after {Collector} with Cobertura formatter).
|
|
5
|
+
# Uses a small string scan (no REXML) so the gem stays stdlib-only everywhere REXML may be omitted.
|
|
6
|
+
module CoberturaZeroLines
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def run(xml_path:, filename_prefix: "lib/")
|
|
10
|
+
return unless ENV["SHOW_ZERO_COVERAGE"] == "1"
|
|
11
|
+
return unless File.file?(xml_path)
|
|
12
|
+
|
|
13
|
+
uncovered = extract(File.read(xml_path), filename_prefix: filename_prefix)
|
|
14
|
+
uncovered.sort_by { |e| [e[:file], e[:line]] }.each do |line_info|
|
|
15
|
+
Polyrun::Log.puts "#{line_info[:file]}:#{line_info[:line]}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def extract(xml_text, filename_prefix: "lib/")
|
|
20
|
+
uncovered = []
|
|
21
|
+
xml_text.scan(/<class[^>]+filename="([^"]+)"[^>]*>(.*?)<\/class>/m) do |filename, class_body|
|
|
22
|
+
next unless filename.start_with?(filename_prefix)
|
|
23
|
+
|
|
24
|
+
class_body.scan(/<line number="(\d+)" hits="(\d+)"/) do |num_s, hits_s|
|
|
25
|
+
uncovered << {file: filename, line: num_s.to_i} if hits_s.to_i == 0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
uncovered
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require "coverage"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "cobertura_zero_lines"
|
|
6
|
+
require_relative "filter"
|
|
7
|
+
require_relative "formatter"
|
|
8
|
+
require_relative "merge"
|
|
9
|
+
require_relative "result"
|
|
10
|
+
require_relative "track_files"
|
|
11
|
+
require_relative "../debug"
|
|
12
|
+
|
|
13
|
+
module Polyrun
|
|
14
|
+
module Coverage
|
|
15
|
+
# Stdlib +Coverage+ → SimpleCov-compatible JSON for +merge-coverage+ / +report-coverage+.
|
|
16
|
+
# No SimpleCov gem. Enable with +POLYRUN_COVERAGE=1+ or call +start!+ from spec_helper.
|
|
17
|
+
#
|
|
18
|
+
# Disable with +POLYRUN_COVERAGE_DISABLE=1+ or +SIMPLECOV_DISABLE=1+ (migration alias).
|
|
19
|
+
#
|
|
20
|
+
# Branch coverage: set +POLYRUN_COVERAGE_BRANCHES=1+ so stdlib +Coverage.start+ records branches;
|
|
21
|
+
# +merge-coverage+ merges branch keys when present in fragments.
|
|
22
|
+
module Collector
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param root [String] project root (absolute or relative)
|
|
26
|
+
# @param reject_patterns [Array<String>] path substrings to drop (like SimpleCov add_filter)
|
|
27
|
+
# @param output_path [String, nil] default coverage/polyrun-fragment-<shard>.json
|
|
28
|
+
# @param minimum_line_percent [Float, nil] exit 1 if below (when strict)
|
|
29
|
+
# @param strict [Boolean] whether to exit non-zero on threshold failure (default true when minimum set)
|
|
30
|
+
# @param track_under [Array<String>] when +track_files+ is nil, only keep coverage keys under these dirs relative to +root+. Default +["lib"]+.
|
|
31
|
+
# @param track_files [String, Array<String>, nil] glob(s) relative to +root+ (e.g. +"{lib,app}/**/*.rb"+). Adds never-loaded files with simulated lines, like SimpleCov +track_files+.
|
|
32
|
+
# @param groups [Hash{String=>String}] group name => glob relative to +root+ (SimpleCov +add_group+); JSON gets +lines.covered_percent+ per group and optional "Ungrouped".
|
|
33
|
+
# @param meta [Hash] extra keys under merged JSON meta
|
|
34
|
+
# @param formatter [Object, nil] Object responding to +format(result, output_dir:, basename:)+ like SimpleCov formatters (e.g. {Formatter.multi} or {Formatter::MultiFormatter})
|
|
35
|
+
# @param report_output_dir [String, nil] directory for +formatter+ outputs (default +coverage/+ under +root+)
|
|
36
|
+
# @param report_basename [String] file prefix for formatter outputs (default +polyrun-coverage+)
|
|
37
|
+
def start!(root:, reject_patterns: [], track_under: ["lib"], track_files: nil, groups: nil, output_path: nil, minimum_line_percent: nil, strict: nil, meta: {}, formatter: nil, report_output_dir: nil, report_basename: "polyrun-coverage")
|
|
38
|
+
return if disabled?
|
|
39
|
+
|
|
40
|
+
root = File.expand_path(root)
|
|
41
|
+
shard = ENV.fetch("POLYRUN_SHARD_INDEX", "0")
|
|
42
|
+
output_path ||= File.join(root, "coverage", "polyrun-fragment-#{shard}.json")
|
|
43
|
+
strict = if minimum_line_percent.nil?
|
|
44
|
+
false
|
|
45
|
+
else
|
|
46
|
+
strict.nil? || strict
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@config = {
|
|
50
|
+
root: root,
|
|
51
|
+
track_under: Array(track_under).map(&:to_s),
|
|
52
|
+
track_files: track_files,
|
|
53
|
+
groups: normalize_groups(groups),
|
|
54
|
+
reject_patterns: reject_patterns,
|
|
55
|
+
output_path: output_path,
|
|
56
|
+
minimum_line_percent: minimum_line_percent,
|
|
57
|
+
strict: strict,
|
|
58
|
+
meta: meta,
|
|
59
|
+
formatter: formatter,
|
|
60
|
+
report_output_dir: report_output_dir,
|
|
61
|
+
report_basename: report_basename,
|
|
62
|
+
shard_total_at_start: ENV["POLYRUN_SHARD_TOTAL"].to_i
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
unless ::Coverage.running?
|
|
66
|
+
::Coverage.start(lines: true, branches: branch_coverage_enabled?)
|
|
67
|
+
end
|
|
68
|
+
unless instance_variable_defined?(:@collector_finish_at_exit_registered)
|
|
69
|
+
@collector_finish_at_exit_registered = true
|
|
70
|
+
at_exit { finish }
|
|
71
|
+
end
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def branch_coverage_enabled?
|
|
76
|
+
%w[1 true yes].include?(ENV["POLYRUN_COVERAGE_BRANCHES"]&.downcase)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def disabled?
|
|
80
|
+
%w[1 true yes].include?(ENV["POLYRUN_COVERAGE_DISABLE"]&.downcase) ||
|
|
81
|
+
%w[1 true yes].include?(ENV["SIMPLECOV_DISABLE"]&.downcase)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True after a successful {start!} in this process (stdlib +Coverage+ is active).
|
|
85
|
+
def self.started?
|
|
86
|
+
instance_variable_defined?(:@config) && @config
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether +polyrun quick+ should call {Rails.start!} before loading quick files: not disabled,
|
|
90
|
+
# and (+POLYRUN_COVERAGE=1+ or (+config/polyrun_coverage.yml+ exists and +POLYRUN_QUICK_COVERAGE=1+)).
|
|
91
|
+
def self.coverage_requested_for_quick?(root = Dir.pwd)
|
|
92
|
+
return false if disabled?
|
|
93
|
+
return true if %w[1 true yes].include?(ENV["POLYRUN_COVERAGE"]&.to_s&.downcase)
|
|
94
|
+
|
|
95
|
+
path = File.join(File.expand_path(root), "config", "polyrun_coverage.yml")
|
|
96
|
+
return false unless File.file?(path)
|
|
97
|
+
|
|
98
|
+
# Config file alone is for merge/report defaults; opt-in so test suites that only
|
|
99
|
+
# keep polyrun_coverage.yml for gates do not start Collector during `polyrun quick`.
|
|
100
|
+
%w[1 true yes].include?(ENV["POLYRUN_QUICK_COVERAGE"]&.to_s&.downcase)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# When +POLYRUN_SHARD_TOTAL+ > 1, each worker only writes the JSON fragment; merged reports
|
|
104
|
+
# (+merge-coverage+ / +report-coverage+) are authoritative. Set +POLYRUN_COVERAGE_WORKER_FORMATS=1+
|
|
105
|
+
# to force per-worker formatter output (debug only; duplicates work N times).
|
|
106
|
+
def run_formatter_per_worker?
|
|
107
|
+
return true if ENV["POLYRUN_COVERAGE_WORKER_FORMATS"] == "1"
|
|
108
|
+
|
|
109
|
+
ENV["POLYRUN_SHARD_TOTAL"].to_i <= 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.finish_debug_time_label
|
|
113
|
+
if ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
|
|
114
|
+
"worker pid=#{$$} shard=#{ENV.fetch("POLYRUN_SHARD_INDEX", "?")} Coverage::Collector.finish (write fragment)"
|
|
115
|
+
else
|
|
116
|
+
"Coverage::Collector.finish (write fragment)"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_meta(cfg)
|
|
121
|
+
m = (cfg[:meta] || {}).transform_keys(&:to_s)
|
|
122
|
+
m["polyrun_version"] = Polyrun::VERSION
|
|
123
|
+
m["timestamp"] ||= Time.now.to_i
|
|
124
|
+
m["command_name"] ||= "rspec"
|
|
125
|
+
m["polyrun_coverage_root"] = cfg[:root].to_s
|
|
126
|
+
if cfg[:groups]
|
|
127
|
+
m["polyrun_coverage_groups"] = cfg[:groups].transform_keys(&:to_s).transform_values(&:to_s)
|
|
128
|
+
end
|
|
129
|
+
if cfg[:track_files]
|
|
130
|
+
m["polyrun_track_files"] = cfg[:track_files]
|
|
131
|
+
end
|
|
132
|
+
m
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Normalizes stdlib Coverage.result to merge-compatible file entries (lines; branches when collected).
|
|
136
|
+
def result_to_blob(raw)
|
|
137
|
+
out = {}
|
|
138
|
+
raw.each do |path, cov|
|
|
139
|
+
next unless cov.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
lines = cov[:lines] || cov["lines"]
|
|
142
|
+
next unless lines.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
entry = {"lines" => lines.map { |x| x }}
|
|
145
|
+
br = cov[:branches] || cov["branches"]
|
|
146
|
+
entry["branches"] = br if br
|
|
147
|
+
out[path.to_s] = entry
|
|
148
|
+
end
|
|
149
|
+
out
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def normalize_blob_paths(blob, root)
|
|
153
|
+
root = File.expand_path(root)
|
|
154
|
+
blob.each_with_object({}) do |(path, entry), acc|
|
|
155
|
+
acc[File.expand_path(path.to_s, root)] = entry
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def normalize_groups(groups)
|
|
160
|
+
return nil if groups.nil?
|
|
161
|
+
|
|
162
|
+
h = groups.is_a?(Hash) ? groups : {}
|
|
163
|
+
return nil if h.empty?
|
|
164
|
+
|
|
165
|
+
h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def keep_under_root(blob, root, track_under)
|
|
169
|
+
return blob if track_under.nil? || track_under.empty?
|
|
170
|
+
|
|
171
|
+
root = File.expand_path(root)
|
|
172
|
+
prefixes = track_under.map { |d| File.join(root, d) }
|
|
173
|
+
blob.each_with_object({}) do |(path, entry), acc|
|
|
174
|
+
p = path.to_s
|
|
175
|
+
next unless prefixes.any? { |pre| p == pre || p.start_with?(pre + "/") }
|
|
176
|
+
|
|
177
|
+
acc[path] = entry
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
require_relative "collector_finish"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Polyrun
|
|
5
|
+
module Coverage
|
|
6
|
+
module Collector
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def finish
|
|
10
|
+
cfg = @config || return # rubocop:disable ThreadSafety/ClassInstanceVariable -- Collector stores @config from start! (same process)
|
|
11
|
+
Polyrun::Debug.log_worker_kv(
|
|
12
|
+
collector_finish: "start",
|
|
13
|
+
polyrun_shard_index: ENV["POLYRUN_SHARD_INDEX"],
|
|
14
|
+
polyrun_shard_total: ENV["POLYRUN_SHARD_TOTAL"],
|
|
15
|
+
output_path: cfg[:output_path]
|
|
16
|
+
)
|
|
17
|
+
Polyrun::Debug.time(Collector.finish_debug_time_label) do
|
|
18
|
+
blob = Collector.send(:prepare_finish_blob, cfg)
|
|
19
|
+
summary = Merge.console_summary(blob)
|
|
20
|
+
group_payload = cfg[:groups] ? TrackFiles.group_summaries(blob, cfg[:root], cfg[:groups]) : nil
|
|
21
|
+
|
|
22
|
+
Collector.send(:exit_if_below_minimum_line_percent, cfg, summary)
|
|
23
|
+
|
|
24
|
+
Collector.send(:write_finish_fragment!, cfg, blob, group_payload)
|
|
25
|
+
Collector.send(:run_finish_formatter!, cfg, blob, group_payload)
|
|
26
|
+
Polyrun::Log.warn Merge.format_console_summary(summary) if ENV["POLYRUN_COVERAGE_VERBOSE"]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.prepare_finish_blob(cfg)
|
|
31
|
+
raw = ::Coverage.result
|
|
32
|
+
blob = Collector.result_to_blob(raw)
|
|
33
|
+
blob = Collector.normalize_blob_paths(blob, cfg[:root])
|
|
34
|
+
blob = Collector.send(:track_blob_for_finish, cfg, blob)
|
|
35
|
+
Filter.reject_matching_paths(blob, cfg[:reject_patterns])
|
|
36
|
+
end
|
|
37
|
+
private_class_method :prepare_finish_blob
|
|
38
|
+
|
|
39
|
+
def self.track_blob_for_finish(cfg, blob)
|
|
40
|
+
sharded = ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
|
|
41
|
+
if cfg[:track_files]
|
|
42
|
+
return Collector.keep_under_root(blob, cfg[:root], cfg[:track_under]) if sharded
|
|
43
|
+
|
|
44
|
+
TrackFiles.merge_untracked_into_blob(blob, cfg[:root], cfg[:track_files])
|
|
45
|
+
else
|
|
46
|
+
Collector.keep_under_root(blob, cfg[:root], cfg[:track_under])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :track_blob_for_finish
|
|
50
|
+
|
|
51
|
+
def self.write_finish_fragment!(cfg, blob, group_payload)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(cfg[:output_path]))
|
|
53
|
+
payload = Merge.to_simplecov_json(blob, meta: Collector.build_meta(cfg), groups: group_payload)
|
|
54
|
+
File.write(cfg[:output_path], JSON.generate(payload))
|
|
55
|
+
Polyrun::Debug.log_worker("Collector.finish: wrote #{cfg[:output_path]} bytes=#{File.size(cfg[:output_path])}")
|
|
56
|
+
end
|
|
57
|
+
private_class_method :write_finish_fragment!
|
|
58
|
+
|
|
59
|
+
def self.run_finish_formatter!(cfg, blob, group_payload)
|
|
60
|
+
return unless cfg[:formatter]
|
|
61
|
+
|
|
62
|
+
if Collector.run_formatter_per_worker?
|
|
63
|
+
dir = cfg[:report_output_dir] || File.join(cfg[:root], "coverage")
|
|
64
|
+
base = cfg[:report_basename] || "polyrun-coverage"
|
|
65
|
+
result = Result.new(blob, meta: Collector.build_meta(cfg), groups: group_payload)
|
|
66
|
+
Polyrun::Debug.time("Collector formatter (per-shard reports)") do
|
|
67
|
+
cfg[:formatter].format(result, output_dir: dir, basename: base)
|
|
68
|
+
xml_path = File.join(dir, "#{base}.xml")
|
|
69
|
+
CoberturaZeroLines.run(xml_path: xml_path, filename_prefix: "lib/") if File.file?(xml_path)
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
shard_total = ENV.fetch("POLYRUN_SHARD_TOTAL", "nil")
|
|
73
|
+
Polyrun::Debug.log_worker(
|
|
74
|
+
"Collector.finish: skipping per-worker formatter (POLYRUN_SHARD_TOTAL=#{shard_total}); " \
|
|
75
|
+
"use merge-coverage / report-coverage for full LCOV/Cobertura/HTML"
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_class_method :run_finish_formatter!
|
|
80
|
+
|
|
81
|
+
def self.exit_if_below_minimum_line_percent(cfg, summary)
|
|
82
|
+
shard_total = (cfg[:shard_total_at_start] || ENV["POLYRUN_SHARD_TOTAL"]).to_i
|
|
83
|
+
return unless cfg[:minimum_line_percent] && shard_total <= 1
|
|
84
|
+
|
|
85
|
+
min = cfg[:minimum_line_percent].to_f
|
|
86
|
+
return if summary[:line_percent].round >= min.round
|
|
87
|
+
|
|
88
|
+
Polyrun::Log.warn Merge.format_console_summary(summary)
|
|
89
|
+
Polyrun::Log.warn "Polyrun coverage: #{summary[:line_percent].round(2)}% rounds below minimum #{min}%."
|
|
90
|
+
exit 1 if cfg[:strict]
|
|
91
|
+
end
|
|
92
|
+
private_class_method :exit_if_below_minimum_line_percent
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Coverage
|
|
3
|
+
# Drop paths from a coverage blob when the path matches any reject pattern (substring).
|
|
4
|
+
# Mirrors SimpleCov +add_filter+ style paths (e.g. "/lib/generators/").
|
|
5
|
+
module Filter
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def reject_matching_paths(blob, patterns)
|
|
9
|
+
return blob unless blob.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
pats = Array(patterns).map(&:to_s).reject(&:empty?)
|
|
12
|
+
return blob if pats.empty?
|
|
13
|
+
|
|
14
|
+
blob.each_with_object({}) do |(path, entry), acc|
|
|
15
|
+
next if pats.any? { |p| path.to_s.include?(p) }
|
|
16
|
+
|
|
17
|
+
acc[path] = entry
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
require_relative "merge"
|
|
5
|
+
require_relative "result"
|
|
6
|
+
|
|
7
|
+
module Polyrun
|
|
8
|
+
module Coverage
|
|
9
|
+
# Formatters matching SimpleCov: +#format(result)+ receives a {Result} (and optional +output_dir+/+basename+).
|
|
10
|
+
# Use {MultiFormatter} to run several outputs; {Formatter.multi} builds that from symbols.
|
|
11
|
+
module Formatter
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def multi(*names, output_dir:, basename: "polyrun-coverage")
|
|
15
|
+
names = names.flatten.compact
|
|
16
|
+
raise ArgumentError, "formatter.multi: need at least one format" if names.empty?
|
|
17
|
+
|
|
18
|
+
formatters = names.map { |n| builtin(n).new(output_dir: output_dir, basename: basename) }
|
|
19
|
+
MultiFormatter.new(formatters)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def builtin(name)
|
|
23
|
+
case name.to_sym
|
|
24
|
+
when :json then JsonFormatter
|
|
25
|
+
when :lcov then LcovFormatter
|
|
26
|
+
when :cobertura then CoberturaFormatter
|
|
27
|
+
when :console then ConsoleFormatter
|
|
28
|
+
when :html then HtmlFormatter
|
|
29
|
+
else
|
|
30
|
+
raise ArgumentError, "unknown coverage format: #{name.inspect} (expected :json, :lcov, :cobertura, :console, :html)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Base: subclasses implement +#write_files(result, output_dir, basename)+ returning +{ key => path }+.
|
|
35
|
+
class Base
|
|
36
|
+
def initialize(output_dir: nil, basename: "polyrun-coverage")
|
|
37
|
+
@default_output_dir = output_dir
|
|
38
|
+
@default_basename = basename
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format(result, output_dir: @default_output_dir, basename: @default_basename)
|
|
42
|
+
od = output_dir
|
|
43
|
+
raise ArgumentError, "#{self.class}: output_dir is required" if od.nil? || od.to_s.empty?
|
|
44
|
+
|
|
45
|
+
bn = basename || "polyrun-coverage"
|
|
46
|
+
FileUtils.mkdir_p(od)
|
|
47
|
+
write_files(result, od.to_s, bn.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_files(_result, _output_dir, _basename)
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs each formatter in order; merges returned path hashes (later keys win on duplicate).
|
|
56
|
+
class MultiFormatter
|
|
57
|
+
def initialize(formatters)
|
|
58
|
+
@formatters = Array(formatters)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :formatters
|
|
62
|
+
|
|
63
|
+
def format(result, **kwargs)
|
|
64
|
+
@formatters.each_with_object({}) do |f, acc|
|
|
65
|
+
acc.merge!(f.format(result, **kwargs))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class JsonFormatter < Base
|
|
71
|
+
def write_files(result, output_dir, basename)
|
|
72
|
+
path = File.join(output_dir, "#{basename}.json")
|
|
73
|
+
payload = Merge.to_simplecov_json(result.coverage_blob, meta: result.meta, groups: result.groups)
|
|
74
|
+
File.write(path, JSON.generate(payload))
|
|
75
|
+
{json: path}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class LcovFormatter < Base
|
|
80
|
+
def write_files(result, output_dir, basename)
|
|
81
|
+
path = File.join(output_dir, "#{basename}.lcov")
|
|
82
|
+
File.write(path, Merge.emit_lcov(result.coverage_blob))
|
|
83
|
+
{lcov: path}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class CoberturaFormatter < Base
|
|
88
|
+
def write_files(result, output_dir, basename)
|
|
89
|
+
path = File.join(output_dir, "#{basename}.xml")
|
|
90
|
+
root = result.meta && (result.meta["polyrun_coverage_root"] || result.meta[:polyrun_coverage_root])
|
|
91
|
+
File.write(path, Merge.emit_cobertura(result.coverage_blob, root: root))
|
|
92
|
+
{cobertura: path}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class ConsoleFormatter < Base
|
|
97
|
+
def write_files(result, output_dir, basename)
|
|
98
|
+
path = File.join(output_dir, "#{basename}-summary.txt")
|
|
99
|
+
summary = Merge.console_summary(result.coverage_blob)
|
|
100
|
+
File.write(path, Merge.format_console_summary(summary))
|
|
101
|
+
{console: path}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class HtmlFormatter < Base
|
|
106
|
+
def write_files(result, output_dir, basename)
|
|
107
|
+
path = File.join(output_dir, "#{basename}.html")
|
|
108
|
+
title = (result.meta && result.meta["title"]) || (result.meta && result.meta[:title]) || "Polyrun coverage"
|
|
109
|
+
File.write(path, Merge.emit_html(result.coverage_blob, title: title))
|
|
110
|
+
{html: path}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module Polyrun
|
|
5
|
+
module Coverage
|
|
6
|
+
module Merge
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def to_simplecov_json(coverage_blob, meta: {}, groups: nil, strip_internal_meta: true)
|
|
10
|
+
m = meta.is_a?(Hash) ? meta : {}
|
|
11
|
+
meta_out = {}
|
|
12
|
+
m.each { |k, v| meta_out[k.to_s] = v }
|
|
13
|
+
if strip_internal_meta
|
|
14
|
+
INTERNAL_META_KEYS.each { |k| meta_out.delete(k) }
|
|
15
|
+
end
|
|
16
|
+
meta_out["simplecov_version"] ||= Polyrun::VERSION
|
|
17
|
+
g =
|
|
18
|
+
if groups.nil?
|
|
19
|
+
{}
|
|
20
|
+
else
|
|
21
|
+
stringify_keys_deep(groups)
|
|
22
|
+
end
|
|
23
|
+
{
|
|
24
|
+
"meta" => meta_out,
|
|
25
|
+
"coverage" => stringify_keys_deep(coverage_blob),
|
|
26
|
+
"groups" => g
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stringify_keys_deep(obj)
|
|
31
|
+
case obj
|
|
32
|
+
when Hash
|
|
33
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
|
|
34
|
+
when Array
|
|
35
|
+
obj.map { |e| stringify_keys_deep(e) }
|
|
36
|
+
else
|
|
37
|
+
obj
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def emit_lcov(coverage_blob)
|
|
42
|
+
lines = []
|
|
43
|
+
coverage_blob.each do |path, file|
|
|
44
|
+
phys = path.to_s
|
|
45
|
+
lines << "TN:polyrun"
|
|
46
|
+
lines << "SF:#{phys}"
|
|
47
|
+
line_arr = line_array_from_file_entry(file)
|
|
48
|
+
next unless line_arr.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
line_arr.each_with_index do |hit, i|
|
|
51
|
+
next if hit.nil? || hit == "ignored"
|
|
52
|
+
|
|
53
|
+
n = hit.to_i
|
|
54
|
+
lines << "DA:#{i + 1},#{n}" if n >= 0
|
|
55
|
+
end
|
|
56
|
+
lines << "end_of_record"
|
|
57
|
+
end
|
|
58
|
+
lines.join("\n") + "\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Cobertura XML (no extra gems). Root metrics match common consumers (spec3.md).
|
|
62
|
+
# When +root+ is set, +filename+ on each +class+ is relative to that directory (for tools that expect +lib/...+).
|
|
63
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- linear XML assembly
|
|
64
|
+
def emit_cobertura(coverage_blob, root: nil)
|
|
65
|
+
lines_valid = 0
|
|
66
|
+
lines_covered = 0
|
|
67
|
+
coverage_blob.each_value do |file|
|
|
68
|
+
line_arr = line_array_from_file_entry(file)
|
|
69
|
+
next unless line_arr.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
line_arr.each do |hit|
|
|
72
|
+
next if hit.nil? || hit == "ignored"
|
|
73
|
+
|
|
74
|
+
lines_valid += 1
|
|
75
|
+
lines_covered += 1 if hit.to_i > 0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
line_rate = lines_valid.positive? ? (lines_covered.to_f / lines_valid) : 0.0
|
|
79
|
+
ts = Time.now.to_i
|
|
80
|
+
|
|
81
|
+
lines = []
|
|
82
|
+
lines << '<?xml version="1.0" encoding="UTF-8"?>'
|
|
83
|
+
lines << '<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">'
|
|
84
|
+
lines << %(<coverage line-rate="#{line_rate}" branch-rate="0" lines-covered="#{lines_covered}" lines-valid="#{lines_valid}" branches-covered="0" branches-valid="0" complexity="0" timestamp="#{ts}" version="1">)
|
|
85
|
+
lines << '<packages><package name="app"><classes>'
|
|
86
|
+
coverage_blob.each do |path, file|
|
|
87
|
+
line_arr = line_array_from_file_entry(file)
|
|
88
|
+
next unless line_arr.is_a?(Array)
|
|
89
|
+
|
|
90
|
+
fname = CGI.escapeHTML(cobertura_display_path(path, root)).gsub("'", "'")
|
|
91
|
+
lines << %(<class name="#{fname}" filename="#{fname}"><lines>)
|
|
92
|
+
line_arr.each_with_index do |hit, i|
|
|
93
|
+
next if hit.nil? || hit == "ignored"
|
|
94
|
+
|
|
95
|
+
n = hit.to_i
|
|
96
|
+
lines << %(<line number="#{i + 1}" hits="#{n}"/>)
|
|
97
|
+
end
|
|
98
|
+
lines << "</lines></class>"
|
|
99
|
+
end
|
|
100
|
+
lines << "</classes></package></packages></coverage>\n"
|
|
101
|
+
lines.join
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
104
|
+
|
|
105
|
+
def cobertura_display_path(path, root)
|
|
106
|
+
p = path.to_s
|
|
107
|
+
return p if root.nil? || root.to_s.empty?
|
|
108
|
+
|
|
109
|
+
abs = File.expand_path(p)
|
|
110
|
+
r = File.expand_path(root.to_s)
|
|
111
|
+
Pathname.new(abs).relative_path_from(Pathname.new(r)).to_s
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
abs
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Aggregate stats for a SimpleCov-compatible coverage blob (lines arrays only).
|
|
117
|
+
def console_summary(coverage_blob)
|
|
118
|
+
files = 0
|
|
119
|
+
relevant = 0
|
|
120
|
+
covered = 0
|
|
121
|
+
coverage_blob.each_value do |file|
|
|
122
|
+
line_arr = line_array_from_file_entry(file)
|
|
123
|
+
next unless line_arr.is_a?(Array)
|
|
124
|
+
|
|
125
|
+
files += 1
|
|
126
|
+
line_arr.each do |h|
|
|
127
|
+
next if h.nil? || h == "ignored"
|
|
128
|
+
|
|
129
|
+
relevant += 1
|
|
130
|
+
covered += 1 if h.to_i > 0
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
|
|
134
|
+
{
|
|
135
|
+
files: files,
|
|
136
|
+
lines_relevant: relevant,
|
|
137
|
+
lines_covered: covered,
|
|
138
|
+
line_percent: pct
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_console_summary(summary)
|
|
143
|
+
s = summary.is_a?(Hash) ? summary : console_summary(summary)
|
|
144
|
+
format(
|
|
145
|
+
"Polyrun coverage summary: %.2f%% lines (%d / %d) across %d files\n",
|
|
146
|
+
s[:line_percent] || s["line_percent"],
|
|
147
|
+
s[:lines_covered] || s["lines_covered"],
|
|
148
|
+
s[:lines_relevant] || s["lines_relevant"],
|
|
149
|
+
s[:files] || s["files"]
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Per-file line stats for HTML and other formatters.
|
|
154
|
+
# Integer line counts for one file entry (for O(files x groups) group aggregation).
|
|
155
|
+
def line_counts(file_entry)
|
|
156
|
+
line_arr = line_array_from_file_entry(file_entry)
|
|
157
|
+
return {relevant: 0, covered: 0} unless line_arr.is_a?(Array)
|
|
158
|
+
|
|
159
|
+
relevant = 0
|
|
160
|
+
covered = 0
|
|
161
|
+
line_arr.each do |h|
|
|
162
|
+
next if h.nil? || h == "ignored"
|
|
163
|
+
|
|
164
|
+
relevant += 1
|
|
165
|
+
covered += 1 if h.to_i > 0
|
|
166
|
+
end
|
|
167
|
+
{relevant: relevant, covered: covered}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def file_line_stats(file)
|
|
171
|
+
c = line_counts(file)
|
|
172
|
+
rel = c[:relevant]
|
|
173
|
+
cov = c[:covered]
|
|
174
|
+
pct = rel.positive? ? (100.0 * cov / rel) : 0.0
|
|
175
|
+
[pct, rel, cov]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
require_relative "formatters_html"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module Polyrun
|
|
4
|
+
module Coverage
|
|
5
|
+
module Merge
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Minimal standalone HTML report (no extra gems), index listing similar to SimpleCov.
|
|
9
|
+
def emit_html(coverage_blob, title: "Polyrun coverage")
|
|
10
|
+
summary = console_summary(coverage_blob)
|
|
11
|
+
rows = []
|
|
12
|
+
coverage_blob.keys.sort.each do |path|
|
|
13
|
+
file = coverage_blob[path]
|
|
14
|
+
pct, rel, cov = file_line_stats(file)
|
|
15
|
+
esc = CGI.escapeHTML(path.to_s)
|
|
16
|
+
rows << "<tr><td class=\"path\">#{esc}</td><td class=\"pct\">#{format("%.2f", pct)}</td><td class=\"hits\">#{cov} / #{rel}</td></tr>"
|
|
17
|
+
end
|
|
18
|
+
esc_title = CGI.escapeHTML(title.to_s)
|
|
19
|
+
<<~HTML
|
|
20
|
+
<!DOCTYPE html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="utf-8"/>
|
|
24
|
+
<title>#{esc_title}</title>
|
|
25
|
+
<style>
|
|
26
|
+
body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #1a1a1a; }
|
|
27
|
+
h1 { font-size: 1.25rem; }
|
|
28
|
+
.summary { margin: 1rem 0; }
|
|
29
|
+
table { border-collapse: collapse; width: 100%; max-width: 56rem; }
|
|
30
|
+
th, td { border: 1px solid #ccc; padding: 0.35rem 0.5rem; text-align: left; }
|
|
31
|
+
th { background: #f4f4f4; }
|
|
32
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
33
|
+
td.path { word-break: break-all; font-size: 0.9rem; }
|
|
34
|
+
td.pct { white-space: nowrap; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<h1>#{esc_title}</h1>
|
|
39
|
+
<p class="summary">
|
|
40
|
+
<strong>#{format("%.2f", summary[:line_percent])}%</strong> lines
|
|
41
|
+
(#{summary[:lines_covered]} / #{summary[:lines_relevant]}) across #{summary[:files]} files
|
|
42
|
+
</p>
|
|
43
|
+
<table>
|
|
44
|
+
<thead><tr><th>File</th><th>Coverage</th><th>Lines (covered / relevant)</th></tr></thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
#{rows.join("\n")}
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
51
|
+
HTML
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|