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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +31 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +140 -0
  6. data/SECURITY.md +27 -0
  7. data/bin/polyrun +6 -0
  8. data/docs/SETUP_PROFILE.md +106 -0
  9. data/lib/polyrun/cli/coverage_commands.rb +150 -0
  10. data/lib/polyrun/cli/coverage_merge_io.rb +124 -0
  11. data/lib/polyrun/cli/database_commands.rb +149 -0
  12. data/lib/polyrun/cli/env_commands.rb +43 -0
  13. data/lib/polyrun/cli/helpers.rb +113 -0
  14. data/lib/polyrun/cli/init_command.rb +99 -0
  15. data/lib/polyrun/cli/plan_command.rb +134 -0
  16. data/lib/polyrun/cli/prepare_command.rb +71 -0
  17. data/lib/polyrun/cli/prepare_recipe.rb +77 -0
  18. data/lib/polyrun/cli/queue_command.rb +101 -0
  19. data/lib/polyrun/cli/quick_command.rb +13 -0
  20. data/lib/polyrun/cli/report_commands.rb +94 -0
  21. data/lib/polyrun/cli/run_shards_command.rb +88 -0
  22. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +91 -0
  23. data/lib/polyrun/cli/run_shards_plan_options.rb +45 -0
  24. data/lib/polyrun/cli/run_shards_planning.rb +124 -0
  25. data/lib/polyrun/cli/run_shards_run.rb +168 -0
  26. data/lib/polyrun/cli/start_bootstrap.rb +99 -0
  27. data/lib/polyrun/cli/timing_command.rb +31 -0
  28. data/lib/polyrun/cli.rb +184 -0
  29. data/lib/polyrun/config.rb +61 -0
  30. data/lib/polyrun/coverage/cobertura_zero_lines.rb +32 -0
  31. data/lib/polyrun/coverage/collector.rb +184 -0
  32. data/lib/polyrun/coverage/collector_finish.rb +95 -0
  33. data/lib/polyrun/coverage/filter.rb +22 -0
  34. data/lib/polyrun/coverage/formatter.rb +115 -0
  35. data/lib/polyrun/coverage/merge/formatters.rb +181 -0
  36. data/lib/polyrun/coverage/merge/formatters_html.rb +55 -0
  37. data/lib/polyrun/coverage/merge.rb +127 -0
  38. data/lib/polyrun/coverage/merge_fragment_meta.rb +47 -0
  39. data/lib/polyrun/coverage/merge_merge_two.rb +117 -0
  40. data/lib/polyrun/coverage/rails.rb +128 -0
  41. data/lib/polyrun/coverage/reporting.rb +41 -0
  42. data/lib/polyrun/coverage/result.rb +18 -0
  43. data/lib/polyrun/coverage/track_files.rb +141 -0
  44. data/lib/polyrun/data/cached_fixtures.rb +122 -0
  45. data/lib/polyrun/data/factory_counts.rb +35 -0
  46. data/lib/polyrun/data/factory_instrumentation.rb +50 -0
  47. data/lib/polyrun/data/fixtures.rb +68 -0
  48. data/lib/polyrun/data/parallel_provisioning.rb +93 -0
  49. data/lib/polyrun/data/snapshot.rb +84 -0
  50. data/lib/polyrun/database/clone_shards.rb +81 -0
  51. data/lib/polyrun/database/provision.rb +72 -0
  52. data/lib/polyrun/database/shard.rb +63 -0
  53. data/lib/polyrun/database/url_builder/connection/infer.rb +49 -0
  54. data/lib/polyrun/database/url_builder/connection/url_builders.rb +43 -0
  55. data/lib/polyrun/database/url_builder/connection.rb +191 -0
  56. data/lib/polyrun/database/url_builder/template_prepare.rb +21 -0
  57. data/lib/polyrun/database/url_builder.rb +160 -0
  58. data/lib/polyrun/debug.rb +81 -0
  59. data/lib/polyrun/env/ci.rb +65 -0
  60. data/lib/polyrun/log.rb +70 -0
  61. data/lib/polyrun/minitest.rb +17 -0
  62. data/lib/polyrun/partition/constraints.rb +69 -0
  63. data/lib/polyrun/partition/hrw.rb +33 -0
  64. data/lib/polyrun/partition/min_heap.rb +64 -0
  65. data/lib/polyrun/partition/paths.rb +28 -0
  66. data/lib/polyrun/partition/paths_build.rb +128 -0
  67. data/lib/polyrun/partition/plan.rb +189 -0
  68. data/lib/polyrun/partition/plan_lpt.rb +49 -0
  69. data/lib/polyrun/partition/plan_sharding.rb +48 -0
  70. data/lib/polyrun/partition/stable_shuffle.rb +18 -0
  71. data/lib/polyrun/prepare/artifacts.rb +40 -0
  72. data/lib/polyrun/prepare/assets.rb +57 -0
  73. data/lib/polyrun/queue/file_store.rb +199 -0
  74. data/lib/polyrun/queue/file_store_pending.rb +48 -0
  75. data/lib/polyrun/quick/assertions.rb +32 -0
  76. data/lib/polyrun/quick/errors.rb +6 -0
  77. data/lib/polyrun/quick/example_group.rb +66 -0
  78. data/lib/polyrun/quick/example_runner.rb +93 -0
  79. data/lib/polyrun/quick/matchers.rb +156 -0
  80. data/lib/polyrun/quick/reporter.rb +42 -0
  81. data/lib/polyrun/quick/runner.rb +180 -0
  82. data/lib/polyrun/quick.rb +1 -0
  83. data/lib/polyrun/railtie.rb +7 -0
  84. data/lib/polyrun/reporting/junit.rb +125 -0
  85. data/lib/polyrun/reporting/junit_emit.rb +58 -0
  86. data/lib/polyrun/reporting/rspec_junit.rb +39 -0
  87. data/lib/polyrun/rspec.rb +15 -0
  88. data/lib/polyrun/templates/POLYRUN.md +45 -0
  89. data/lib/polyrun/templates/ci_matrix.polyrun.yml +14 -0
  90. data/lib/polyrun/templates/minimal_gem.polyrun.yml +13 -0
  91. data/lib/polyrun/templates/rails_prepare.polyrun.yml +31 -0
  92. data/lib/polyrun/timing/merge.rb +35 -0
  93. data/lib/polyrun/timing/summary.rb +25 -0
  94. data/lib/polyrun/version.rb +3 -0
  95. data/lib/polyrun.rb +58 -0
  96. data/polyrun.gemspec +37 -0
  97. data/sig/polyrun/cli.rbs +6 -0
  98. data/sig/polyrun/config.rbs +20 -0
  99. data/sig/polyrun/debug.rbs +12 -0
  100. data/sig/polyrun/log.rbs +12 -0
  101. data/sig/polyrun/minitest.rbs +5 -0
  102. data/sig/polyrun/quick.rbs +19 -0
  103. data/sig/polyrun/rspec.rbs +5 -0
  104. data/sig/polyrun.rbs +11 -0
  105. 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("'", "&#39;")
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