polyrun 1.5.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +2 -2
  4. data/docs/SETUP_PROFILE.md +2 -0
  5. data/lib/polyrun/cli/help.rb +7 -2
  6. data/lib/polyrun/cli/helpers.rb +16 -0
  7. data/lib/polyrun/cli/init_command.rb +8 -1
  8. data/lib/polyrun/cli/partition_diagnostics.rb +22 -0
  9. data/lib/polyrun/cli/plan_command.rb +47 -18
  10. data/lib/polyrun/cli/queue_command.rb +25 -2
  11. data/lib/polyrun/cli/run_queue_command.rb +145 -0
  12. data/lib/polyrun/cli/run_shards_command.rb +6 -1
  13. data/lib/polyrun/cli/run_shards_parallel_children.rb +2 -1
  14. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +47 -2
  15. data/lib/polyrun/cli/run_shards_plan_options.rb +12 -2
  16. data/lib/polyrun/cli/run_shards_planning.rb +20 -12
  17. data/lib/polyrun/cli/run_shards_run.rb +21 -4
  18. data/lib/polyrun/cli/spec_quality_commands.rb +140 -0
  19. data/lib/polyrun/cli.rb +16 -2
  20. data/lib/polyrun/coverage/example_diff.rb +122 -0
  21. data/lib/polyrun/data/factory_counts.rb +14 -1
  22. data/lib/polyrun/database/clone_shards.rb +2 -0
  23. data/lib/polyrun/database/shard.rb +2 -1
  24. data/lib/polyrun/minitest.rb +9 -0
  25. data/lib/polyrun/partition/hrw.rb +40 -3
  26. data/lib/polyrun/partition/paths_build.rb +8 -3
  27. data/lib/polyrun/partition/plan.rb +88 -19
  28. data/lib/polyrun/partition/plan_lpt.rb +49 -7
  29. data/lib/polyrun/partition/plan_sharding.rb +8 -0
  30. data/lib/polyrun/partition/reports.rb +139 -0
  31. data/lib/polyrun/partition/timing_diagnostics.rb +139 -0
  32. data/lib/polyrun/partition/timing_keys.rb +2 -1
  33. data/lib/polyrun/queue/duration.rb +30 -0
  34. data/lib/polyrun/queue/file_store.rb +107 -3
  35. data/lib/polyrun/quick/example_runner.rb +2 -0
  36. data/lib/polyrun/quick/runner.rb +21 -0
  37. data/lib/polyrun/rspec.rb +8 -0
  38. data/lib/polyrun/spec_quality/config.rb +134 -0
  39. data/lib/polyrun/spec_quality/fragment.rb +39 -0
  40. data/lib/polyrun/spec_quality/merge.rb +78 -0
  41. data/lib/polyrun/spec_quality/minitest_hook.rb +42 -0
  42. data/lib/polyrun/spec_quality/plan_loader.rb +47 -0
  43. data/lib/polyrun/spec_quality/profile.rb +91 -0
  44. data/lib/polyrun/spec_quality/report.rb +261 -0
  45. data/lib/polyrun/spec_quality/rspec_hook.rb +55 -0
  46. data/lib/polyrun/spec_quality/sql_counter.rb +34 -0
  47. data/lib/polyrun/spec_quality.rb +205 -0
  48. data/lib/polyrun/templates/POLYRUN.md +6 -0
  49. data/lib/polyrun/templates/ci_matrix.polyrun.yml +4 -0
  50. data/lib/polyrun/templates/polyrun_hooks_spec_quality.rb +12 -0
  51. data/lib/polyrun/templates/polyrun_spec_quality.yml +20 -0
  52. data/lib/polyrun/templates/rails_prepare.polyrun.yml +5 -0
  53. data/lib/polyrun/timing/merge.rb +5 -5
  54. data/lib/polyrun/timing/stats.rb +76 -0
  55. data/lib/polyrun/timing/summary.rb +5 -2
  56. data/lib/polyrun/timing/variance_report.rb +51 -0
  57. data/lib/polyrun/version.rb +1 -1
  58. metadata +22 -1
@@ -0,0 +1,261 @@
1
+ # rubocop:disable Polyrun/FileLength, Metrics/ModuleLength -- report analysis + text formatting
2
+ module Polyrun
3
+ module SpecQuality
4
+ # Human-readable spec quality report from merged JSON.
5
+ module Report
6
+ module_function
7
+
8
+ # rubocop:disable Metrics/AbcSize -- merged example analysis
9
+ def analyze(merged, cfg = {}, plan_shards: nil)
10
+ cfg = default_cfg(cfg)
11
+ examples = merged["examples"] || {}
12
+ hot_lines = merged["hot_lines"] || {}
13
+ shard_summary = merged["shard_summary"] || Merge.shard_summary(examples)
14
+
15
+ zero_hit = examples.select { |_loc, row| row["unique_lines"].to_i.zero? }
16
+ churn = examples.select { |_loc, row| row["line_churn"].to_i >= cfg["min_line_churn"] }
17
+ .sort_by { |_loc, row| -row["line_churn"].to_i }
18
+ hot = hot_lines.select { |_line, h| h["example_count"].to_i >= cfg["hot_line_example_overlap"] }
19
+ .sort_by { |_line, h| [-h["example_count"].to_i, -h["total_hits"].to_i] }
20
+
21
+ outliers = build_outliers(examples, cfg)
22
+ partition_hints = partition_hints_for(hot, examples, plan_shards) if plan_shards && !plan_shards.empty?
23
+
24
+ {
25
+ zero_hit: zero_hit,
26
+ line_churn: churn,
27
+ hot_lines: hot,
28
+ outliers: outliers,
29
+ shard_summary: shard_summary,
30
+ partition_hints: partition_hints,
31
+ config: cfg
32
+ }
33
+ end
34
+ # rubocop:enable Metrics/AbcSize
35
+
36
+ def format_report(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
37
+ analysis = analyze(merged, cfg, plan_shards: plan_shards)
38
+ lines = ["Polyrun spec quality report", ""]
39
+
40
+ lines.concat(format_shard_summary_section(analysis[:shard_summary]))
41
+ lines << ""
42
+ lines.concat(format_zero_hit_section(analysis[:zero_hit], top))
43
+ lines << ""
44
+ lines.concat(format_hot_lines_section(analysis[:hot_lines], top))
45
+ lines << ""
46
+ lines.concat(format_churn_section(analysis[:line_churn], top))
47
+ hints_section = format_partition_hints_section(analysis[:partition_hints], top)
48
+ unless hints_section.empty?
49
+ lines << ""
50
+ lines.concat(hints_section)
51
+ end
52
+ lines << ""
53
+ lines.concat(format_outliers_section(analysis[:outliers], top, profile))
54
+
55
+ lines.join("\n") + "\n"
56
+ end
57
+
58
+ def gate_violations(merged, cfg = {})
59
+ cfg = default_cfg(cfg)
60
+ analysis = analyze(merged, cfg)
61
+ violations = []
62
+
63
+ max_zero = cfg["max_zero_hit_examples"]
64
+ if max_zero && analysis[:zero_hit].size > max_zero.to_i
65
+ violations << "zero_hit_examples=#{analysis[:zero_hit].size} max=#{max_zero}"
66
+ end
67
+
68
+ min_unique = cfg["minimum_unique_lines_per_example"]
69
+ if min_unique
70
+ bad = analysis[:zero_hit].keys
71
+ if bad.size.positive?
72
+ violations << "examples_below_minimum_unique_lines=#{bad.size} minimum=#{min_unique}"
73
+ end
74
+ end
75
+
76
+ max_hot = cfg["max_hot_line_overlap"]
77
+ if max_hot
78
+ over = analysis[:hot_lines].count { |_k, h| h["example_count"].to_i > max_hot.to_i }
79
+ violations << "hot_line_overlap_count=#{over} max=#{max_hot}" if over.positive?
80
+ end
81
+
82
+ violations
83
+ end
84
+
85
+ def emit_warnings!(merged, cfg = {})
86
+ analyze(merged, cfg).fetch(:line_churn, {}).each do |loc, row|
87
+ Polyrun::Log.warn "polyrun spec-quality line_churn: #{loc} churn=#{row["line_churn"]}"
88
+ end
89
+ end
90
+
91
+ def default_cfg(cfg)
92
+ h = cfg.is_a?(Hash) ? cfg.transform_keys(&:to_s) : {}
93
+ SpecQuality::Config::DEFAULTS.merge(h)
94
+ end
95
+
96
+ def format_shard_summary_section(shard_summary)
97
+ lines = ["Shard attribution:"]
98
+ if shard_summary.nil? || shard_summary.empty?
99
+ lines << " (none)"
100
+ return lines
101
+ end
102
+
103
+ shard_summary.sort_by { |k, _| k.to_s }.each do |shard, stats|
104
+ lines << format(
105
+ " shard %s — examples=%d zero_hit=%d line_churn=%d",
106
+ shard,
107
+ stats["examples"],
108
+ stats["zero_hit"],
109
+ stats["line_churn"]
110
+ )
111
+ end
112
+ lines
113
+ end
114
+
115
+ def format_partition_hints_section(hints, top)
116
+ return [] if hints.nil? || hints.empty?
117
+
118
+ lines = ["Partition hints (hot lines × shard):"]
119
+ hints.first(top).each do |h|
120
+ lines << format(" %s — shard %s (%d examples)", h[:line], h[:shard], h[:example_count])
121
+ end
122
+ lines << " …" if hints.size > top
123
+ lines
124
+ end
125
+
126
+ def partition_hints_for(hot_lines, examples, plan_shards)
127
+ hot_lines.filter_map do |line, h|
128
+ example_locs = h["examples"] || []
129
+ shard_counts = Hash.new(0)
130
+ example_locs.each do |loc|
131
+ s = PlanLoader.shard_for_example(loc, plan_shards) || examples.dig(loc, "polyrun_shard_index")&.to_s
132
+ shard_counts[s] += 1 if s
133
+ end
134
+ next if shard_counts.empty?
135
+
136
+ shard, count = shard_counts.max_by { |_s, n| n }
137
+ {line: line, shard: shard, example_count: count, total_hits: h["total_hits"]}
138
+ end.sort_by { |h| [-h[:example_count], -h[:total_hits]] }
139
+ end
140
+
141
+ def format_zero_hit_section(zero_hit, top)
142
+ lines = ["Zero production lines (#{zero_hit.size} examples):"]
143
+ if zero_hit.empty?
144
+ lines << " (none)"
145
+ return lines
146
+ end
147
+
148
+ zero_hit.keys.sort.first(top).each { |loc| lines << " #{loc}" }
149
+ lines << " …" if zero_hit.size > top
150
+ lines
151
+ end
152
+
153
+ def format_hot_lines_section(hot_lines, top)
154
+ lines = ["Hot lines (shared across examples):"]
155
+ if hot_lines.empty?
156
+ lines << " (none)"
157
+ return lines
158
+ end
159
+
160
+ hot_lines.first(top).each do |line, h|
161
+ lines << format(" %s — %d examples, %d cumulative hits", line, h["example_count"], h["total_hits"])
162
+ end
163
+ lines << " …" if hot_lines.size > top
164
+ lines
165
+ end
166
+
167
+ def format_churn_section(churn_rows, top)
168
+ lines = ["Per-example line churn (top #{[top, churn_rows.size].min}):"]
169
+ if churn_rows.empty?
170
+ lines << " (none)"
171
+ return lines
172
+ end
173
+
174
+ churn_rows.first(top).each do |loc, row|
175
+ lines << format(" %s — churn=%d max_line=%d", loc, row["line_churn"], row["max_line_churn"])
176
+ end
177
+ lines
178
+ end
179
+
180
+ # rubocop:disable Metrics/AbcSize -- outlier row filter
181
+ def build_outliers(examples, cfg)
182
+ examples.filter_map do |loc, row|
183
+ prof = row["profile"] || {}
184
+ unique = row["unique_lines"].to_i
185
+ wall = prof["wall"].to_f
186
+ alloc = prof["gc_allocated"].to_i
187
+ cpu = prof["cpu_user"].to_f + prof["cpu_system"].to_f
188
+ sql = row["sql_count"].to_i
189
+ factories = (row["factory_counts"] || {}).values.sum
190
+
191
+ score = 0
192
+ reasons = []
193
+ if unique.zero?
194
+ score += 10
195
+ reasons << "zero_lines"
196
+ end
197
+ if wall > 1.0 && unique < 3
198
+ score += 5
199
+ reasons << "slow_low_coverage"
200
+ end
201
+ if alloc > 50_000 && wall > 0.5
202
+ score += 3
203
+ reasons << "high_alloc"
204
+ end
205
+ if cpu > 0.5 && unique < 3
206
+ score += 3
207
+ reasons << "high_cpu_low_coverage"
208
+ end
209
+ if sql >= cfg["min_query_count"]
210
+ score += 4
211
+ reasons << "high_sql_count"
212
+ end
213
+ if factories >= 10
214
+ score += 2
215
+ reasons << "many_factories"
216
+ end
217
+ next if score.zero?
218
+
219
+ {location: loc, score: score, reasons: reasons, row: row}
220
+ end.sort_by { |h| -h[:score] }
221
+ end
222
+ # rubocop:enable Metrics/AbcSize
223
+
224
+ # rubocop:disable Metrics/AbcSize -- outlier text formatting
225
+ def format_outliers_section(outliers, top, profile)
226
+ lines = ["Correlated outliers (slow / empty / heavy):"]
227
+ if outliers.empty?
228
+ lines << " (none)"
229
+ return lines
230
+ end
231
+
232
+ dims = profile ? profile.to_s.split(",").map(&:strip) : nil
233
+ outliers.first(top).each do |o|
234
+ row = o[:row]
235
+ prof = row["profile"] || {}
236
+ detail = o[:reasons].join(", ")
237
+ if dims.nil? || dims.empty?
238
+ lines << format(" %s — score=%d (%s)", o[:location], o[:score], detail)
239
+ else
240
+ prof_bits = dims.filter_map do |d|
241
+ case d
242
+ when "wall" then "wall=#{format("%.2f", prof["wall"])}" if prof["wall"]
243
+ when "cpu" then "cpu=#{format("%.2f", prof["cpu_user"].to_f + prof["cpu_system"].to_f)}"
244
+ when "mem" then "alloc=#{prof["gc_allocated"]}" if prof["gc_allocated"]
245
+ when "io"
246
+ r = prof["io_read_bytes"]
247
+ w = prof["io_write_bytes"]
248
+ "io=#{r}/#{w}" if r || w
249
+ end
250
+ end
251
+ lines << format(" %s — score=%d (%s) %s", o[:location], o[:score], detail, prof_bits.join(" "))
252
+ end
253
+ end
254
+ lines << " …" if outliers.size > top
255
+ lines
256
+ end
257
+ # rubocop:enable Metrics/AbcSize
258
+ end
259
+ end
260
+ end
261
+ # rubocop:enable Polyrun/FileLength, Metrics/ModuleLength
@@ -0,0 +1,55 @@
1
+ module Polyrun
2
+ module SpecQuality
3
+ # RSpec hooks for per-example spec quality recording.
4
+ module RspecHook
5
+ module_function
6
+
7
+ def install!(only_if: nil, root: nil, output_path: nil)
8
+ pred = only_if || -> { Polyrun::SpecQuality.enabled? }
9
+ return unless pred.call
10
+
11
+ require "rspec/core"
12
+ ensure_started!(root: root, output_path: output_path)
13
+
14
+ ::RSpec.configure do |config|
15
+ config.before(:each) do |example|
16
+ next if example.pending?
17
+
18
+ Polyrun::SpecQuality.start_example!(
19
+ location: example.metadata[:location] || example.location
20
+ )
21
+ end
22
+
23
+ config.after(:each) do |example|
24
+ Polyrun::SpecQuality.finish_example!(
25
+ location: example.metadata[:location] || example.location,
26
+ pending: example.pending?
27
+ )
28
+ end
29
+ end
30
+ end
31
+
32
+ def ensure_started!(root: nil, output_path: nil)
33
+ return if Polyrun::SpecQuality.started?
34
+
35
+ Polyrun::SpecQuality.start!(
36
+ root: root || infer_root,
37
+ output_path: output_path
38
+ )
39
+ end
40
+
41
+ def infer_root
42
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
43
+ return ::Rails.root.to_s
44
+ end
45
+
46
+ caller_locations.each do |loc|
47
+ inferred = Polyrun::Coverage::Rails.infer_root_from_path(loc.path)
48
+ return inferred if inferred
49
+ end
50
+
51
+ Dir.pwd
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ module Polyrun
2
+ module SpecQuality
3
+ # Optional per-example SQL query counting via ActiveSupport::Notifications.
4
+ module SqlCounter
5
+ class << self
6
+ def install!
7
+ return false unless notifications_available?
8
+ return true if @installed
9
+
10
+ @installed = true
11
+ @subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
12
+ next unless Polyrun::SpecQuality.recording?
13
+
14
+ event = ActiveSupport::Notifications::Event.new(*args)
15
+ Polyrun::SpecQuality.record_sql!(event.payload[:sql].to_s)
16
+ end
17
+ true
18
+ end
19
+
20
+ def uninstall!
21
+ return unless @installed && @subscriber
22
+
23
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
24
+ @subscriber = nil
25
+ @installed = false
26
+ end
27
+
28
+ def notifications_available?
29
+ defined?(ActiveSupport::Notifications)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,205 @@
1
+ # rubocop:disable Polyrun/FileLength -- per-example recorder API
2
+ require "json"
3
+ require "fileutils"
4
+
5
+ require_relative "coverage/example_diff"
6
+ require_relative "partition/timing_keys"
7
+ require_relative "spec_quality/config"
8
+ require_relative "spec_quality/profile"
9
+ require_relative "spec_quality/fragment"
10
+ require_relative "spec_quality/sql_counter"
11
+ require_relative "spec_quality/merge"
12
+ require_relative "spec_quality/plan_loader"
13
+ require_relative "spec_quality/report"
14
+ require_relative "spec_quality/rspec_hook"
15
+ require_relative "spec_quality/minitest_hook"
16
+
17
+ module Polyrun
18
+ # Per-example spec quality: coverage line deltas, resource use, optional SQL counts.
19
+ # Opt-in with +POLYRUN_SPEC_QUALITY=1+ or +Polyrun::SpecQuality.start!+.
20
+ module SpecQuality
21
+ class << self
22
+ def start!(root: Dir.pwd, config_path: nil, output_path: nil, **overrides)
23
+ return if Config.disabled?
24
+
25
+ @config = Config.load(root: root, config_path: config_path, **overrides)
26
+ @output_path = output_path || Fragment.default_fragment_path
27
+ @pause_depth = 0
28
+ @current = nil
29
+ @rng = Random.new(Process.pid ^ Time.now.to_i)
30
+
31
+ Fragment.truncate_fragment!(@output_path) unless fragment_append_mode?
32
+ SqlCounter.install! if @config["sql_counter"]
33
+ self
34
+ end
35
+
36
+ def started?
37
+ instance_variable_defined?(:@config) && @config
38
+ end
39
+
40
+ def enabled?
41
+ Config.enabled? && !Config.disabled?
42
+ end
43
+
44
+ attr_reader :config
45
+
46
+ attr_reader :output_path
47
+
48
+ def recording?
49
+ started? && @current && @pause_depth.zero?
50
+ end
51
+
52
+ def start_example!(location:, wall_start: nil)
53
+ return unless started?
54
+ return if paused?
55
+ return if Config.ignored_example?(location, @config["ignore_examples"])
56
+ return unless sample_example?
57
+
58
+ @current = {
59
+ location: normalize_location(location),
60
+ wall_start: wall_start || Process.clock_gettime(Process::CLOCK_MONOTONIC),
61
+ coverage_before: Coverage::ExampleDiff.peek_blob,
62
+ profile_before: Profile.snapshot,
63
+ sql_count: 0,
64
+ sql_fingerprints: Hash.new(0),
65
+ factory_counts: {}
66
+ }
67
+ Polyrun::Data::FactoryCounts.reset_example! if defined?(Polyrun::Data::FactoryCounts)
68
+ nil
69
+ end
70
+
71
+ def finish_example!(location: nil, pending: false)
72
+ return unless started?
73
+ cur = @current
74
+ @current = nil
75
+ return if cur.nil? || paused?
76
+ return if pending
77
+
78
+ loc = location || cur[:location]
79
+ return if loc.nil? || loc.to_s.empty?
80
+
81
+ after_cov = Coverage::ExampleDiff.peek_blob
82
+ delta = Coverage::ExampleDiff.diff(cur[:coverage_before], after_cov)
83
+ delta = Coverage::ExampleDiff.apply_track_under(
84
+ delta,
85
+ root: @config["root"],
86
+ track_under: @config["track_under"],
87
+ ignore_paths: @config["ignore_paths"]
88
+ )
89
+
90
+ profile_after = Profile.snapshot
91
+ profile_delta = Profile.diff(cur[:profile_before], profile_after)
92
+ wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cur[:wall_start]
93
+ profile_delta["wall"] = wall
94
+
95
+ factory_counts =
96
+ if defined?(Polyrun::Data::FactoryCounts)
97
+ Polyrun::Data::FactoryCounts.example_counts
98
+ else
99
+ {}
100
+ end
101
+
102
+ row = build_row(cur, loc, delta, profile_delta, factory_counts)
103
+ Fragment.append_row!(@output_path, row)
104
+ row
105
+ end
106
+
107
+ def pause
108
+ @pause_depth += 1
109
+ if block_given?
110
+ begin
111
+ yield
112
+ ensure
113
+ resume
114
+ end
115
+ end
116
+ end
117
+
118
+ def resume
119
+ @pause_depth -= 1 if @pause_depth.positive?
120
+ end
121
+
122
+ def paused?
123
+ @pause_depth.positive?
124
+ end
125
+
126
+ def record_sql!(sql)
127
+ return unless recording?
128
+
129
+ @current[:sql_count] += 1
130
+ fp = normalize_sql(sql)
131
+ @current[:sql_fingerprints][fp] += 1
132
+ end
133
+
134
+ def spec_quality_requested_for_quick?(root = Dir.pwd)
135
+ return false if Config.disabled?
136
+ return true if %w[1 true yes].include?(ENV["POLYRUN_SPEC_QUALITY"]&.to_s&.downcase)
137
+ return true if %w[1 true yes].include?(ENV["POLYRUN_SPEC_QUALITY_FRAGMENTS"]&.to_s&.downcase)
138
+
139
+ path = File.join(File.expand_path(root), Config::DEFAULT_CONFIG_RELATIVE)
140
+ return false unless File.file?(path)
141
+
142
+ %w[1 true yes].include?(ENV["POLYRUN_QUICK_SPEC_QUALITY"]&.to_s&.downcase)
143
+ end
144
+
145
+ private
146
+
147
+ def fragment_append_mode?
148
+ truthy?(ENV["POLYRUN_SPEC_QUALITY_FRAGMENT_APPEND"])
149
+ end
150
+
151
+ def sample_example?
152
+ rate = @config["sample"].to_f
153
+ return true if rate >= 1.0
154
+ return false if rate <= 0.0
155
+
156
+ @rng.rand < rate
157
+ end
158
+
159
+ def normalize_location(location)
160
+ s = location.to_s.strip
161
+ return s if s.empty?
162
+
163
+ root = @config["root"]
164
+ if (m = s.match(/\A(.+):(\d+)\z/)) && m[2].match?(/\A\d+\z/)
165
+ fp = Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(m[1], root))
166
+ return "#{fp}:#{m[2]}"
167
+ end
168
+
169
+ Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(s, root))
170
+ end
171
+
172
+ def build_row(cur, location, delta, profile_delta, factory_counts)
173
+ profile = Profile.slice_profile(profile_delta, @config["profile"])
174
+ repeated_sql = cur[:sql_fingerprints].select { |_sql, n| n >= min_query_count }.transform_keys(&:to_s)
175
+
176
+ {
177
+ "example" => location.to_s,
178
+ "unique_lines" => delta[:unique_lines],
179
+ "line_churn" => delta[:line_churn],
180
+ "max_line_churn" => delta[:max_line_churn],
181
+ "lines" => delta[:lines],
182
+ "profile" => profile,
183
+ "sql_count" => cur[:sql_count],
184
+ "repeated_sql" => repeated_sql,
185
+ "factory_counts" => factory_counts.transform_keys(&:to_s),
186
+ "polyrun_shard_index" => ENV["POLYRUN_SHARD_INDEX"],
187
+ "polyrun_shard_total" => ENV["POLYRUN_SHARD_TOTAL"]
188
+ }.compact
189
+ end
190
+
191
+ def min_query_count
192
+ @config["min_query_count"].to_i
193
+ end
194
+
195
+ def normalize_sql(sql)
196
+ sql.to_s.gsub(/\s+/, " ").strip[0, 500]
197
+ end
198
+
199
+ def truthy?(value)
200
+ Config.send(:truthy?, value)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ # rubocop:enable Polyrun/FileLength
@@ -40,6 +40,12 @@ Do not combine Model A and Model B in one workflow without a documented reason (
40
40
  - `spec/spec_helper.rb`: `require "polyrun"` and collector or Rails helper as appropriate.
41
41
  - Fragments: `coverage/polyrun-fragment-<shard>.json` → `polyrun merge-coverage` → `polyrun report-coverage` / `report-junit` for CI.
42
42
 
43
+ ## Spec quality (optional)
44
+
45
+ - `POLYRUN_SPEC_QUALITY=1` and `Polyrun::RSpec.install_spec_quality!` in `spec_helper` (requires coverage enabled for line deltas).
46
+ - `config/polyrun_spec_quality.yml` — thresholds and CI gates (`polyrun init --profile spec-quality`).
47
+ - Parallel: `run-shards --merge-spec-quality` → `coverage/polyrun-spec-quality.json` → `report-spec-quality`.
48
+
43
49
  ## Further reading
44
50
 
45
51
  - Polyrun README: partition, prepare, databases, merge-coverage, `Polyrun::Env::Ci`
@@ -16,3 +16,7 @@ partition:
16
16
  paths_build:
17
17
  all_glob: spec/**/*_spec.rb
18
18
  stages: []
19
+
20
+ # Optional reporting (see docs/SPEC_QUALITY.md):
21
+ # reporting:
22
+ # merge_spec_quality: true
@@ -0,0 +1,12 @@
1
+ # Optional Polyrun hooks — copy to config/polyrun_hooks.rb and reference from polyrun.yml:
2
+ # hooks:
3
+ # ruby_file: config/polyrun_hooks.rb
4
+ #
5
+ # Worker-phase hooks run in each parallel test child (around bundle exec rspec).
6
+
7
+ before(:each) do |env|
8
+ next unless env["POLYRUN_HOOK_ORCHESTRATOR"] == "0"
9
+ next unless defined?(Polyrun::SpecQuality) && Polyrun::SpecQuality.enabled?
10
+
11
+ Polyrun::SpecQuality::RspecHook.ensure_started!
12
+ end
@@ -0,0 +1,20 @@
1
+ # Opt-in per-example spec quality (coverage deltas, resource use). See docs/SPEC_QUALITY.md.
2
+ track_under:
3
+ - lib
4
+ - app
5
+ min_line_churn: 50
6
+ min_query_count: 20
7
+ hot_line_example_overlap: 10
8
+ strict: false
9
+ sample: 1.0
10
+ ignore_examples: []
11
+ ignore_paths: []
12
+ ignore_query_patterns: []
13
+ profile:
14
+ - cpu
15
+ - mem
16
+ sql_counter: false
17
+ # CI gates (used with POLYRUN_SPEC_QUALITY_STRICT=1 or strict: true):
18
+ # minimum_unique_lines_per_example: 1
19
+ # max_zero_hit_examples: 0
20
+ # max_hot_line_overlap: 100
@@ -17,6 +17,11 @@ prepare:
17
17
  rails_root: .
18
18
  command: bundle exec ruby bin/test_prepare.rb
19
19
 
20
+ # Optional reporting (see docs/SPEC_QUALITY.md):
21
+ # reporting:
22
+ # merge_spec_quality: true
23
+ # merge_spec_quality_output: coverage/polyrun-spec-quality.json
24
+
20
25
  # Uncomment and edit when using Postgres template + per-shard DB names from polyrun env:
21
26
  # databases:
22
27
  # template_db: my_app_template
@@ -1,12 +1,11 @@
1
1
  require "json"
2
2
 
3
3
  require_relative "../debug"
4
+ require_relative "stats"
5
+ require_relative "variance_report"
4
6
 
5
7
  module Polyrun
6
8
  module Timing
7
- # Merges per-shard timing JSON files (spec2 §2.4): path => wall seconds (float), or (experimental)
8
- # +absolute_path:line+ => seconds for per-example timing.
9
- # Disjoint suites: values merged by taking the maximum per path when duplicates appear.
10
9
  module Merge
11
10
  module_function
12
11
 
@@ -18,8 +17,8 @@ module Polyrun
18
17
 
19
18
  data.each do |file, sec|
20
19
  f = file.to_s
21
- t = sec.to_f
22
- merged[f] = merged.key?(f) ? [merged[f], t].max : t
20
+ entry = sec
21
+ merged[f] = merged.key?(f) ? Stats.merge_entries(merged[f], entry) : Stats.normalize_entry(entry)
23
22
  end
24
23
  end
25
24
  merged
@@ -29,6 +28,7 @@ module Polyrun
29
28
  Polyrun::Debug.log_kv(merge_timing: "merge_and_write", input_count: paths.size, output_path: output_path)
30
29
  merged = Polyrun::Debug.time("Timing::Merge.merge_files") { merge_files(paths) }
31
30
  Polyrun::Debug.time("Timing::Merge.write JSON") { File.write(output_path, JSON.pretty_generate(merged)) }
31
+ Timing::VarianceReport.emit_warnings!(merged)
32
32
  merged
33
33
  end
34
34
  end