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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/CONTRIBUTING.md +23 -0
  4. data/docs/SETUP_PROFILE.md +29 -0
  5. data/ext/polyrun_coverage_merge/coverage_merge.c +492 -0
  6. data/ext/polyrun_coverage_merge/extconf.rb +3 -0
  7. data/lib/polyrun/benchmark/profile.rb +191 -0
  8. data/lib/polyrun/benchmark/report.rb +87 -0
  9. data/lib/polyrun/cli/benchmark_commands.rb +66 -0
  10. data/lib/polyrun/cli/coverage_commands.rb +4 -4
  11. data/lib/polyrun/cli/coverage_merge_io.rb +30 -4
  12. data/lib/polyrun/cli/failure_commands.rb +10 -3
  13. data/lib/polyrun/cli/help.rb +14 -7
  14. data/lib/polyrun/cli/report_commands.rb +25 -11
  15. data/lib/polyrun/cli/run_queue_command.rb +5 -32
  16. data/lib/polyrun/cli/run_shards_parallel_children.rb +7 -0
  17. data/lib/polyrun/cli/run_shards_parallel_wait.rb +3 -0
  18. data/lib/polyrun/cli/run_shards_plan_options.rb +1 -1
  19. data/lib/polyrun/cli/run_shards_run.rb +1 -0
  20. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +9 -1
  21. data/lib/polyrun/cli/spec_quality_commands.rb +16 -7
  22. data/lib/polyrun/cli.rb +8 -1
  23. data/lib/polyrun/coverage/example_diff.rb +127 -60
  24. data/lib/polyrun/coverage/example_diff_snapshot.rb +44 -0
  25. data/lib/polyrun/coverage/example_diff_track_filter.rb +51 -0
  26. data/lib/polyrun/coverage/file_stats_report.rb +36 -0
  27. data/lib/polyrun/coverage/formatter.rb +22 -1
  28. data/lib/polyrun/coverage/merge/formatters.rb +11 -3
  29. data/lib/polyrun/coverage/merge_merge_two.rb +112 -63
  30. data/lib/polyrun/coverage/merge_native.rb +37 -0
  31. data/lib/polyrun/coverage/reporting.rb +1 -1
  32. data/lib/polyrun/export/csv.rb +25 -0
  33. data/lib/polyrun/export/markdown.rb +47 -0
  34. data/lib/polyrun/hooks/shell_runner.rb +33 -0
  35. data/lib/polyrun/hooks.rb +4 -7
  36. data/lib/polyrun/partition/paths_build.rb +8 -0
  37. data/lib/polyrun/queue/worker_loop.rb +39 -0
  38. data/lib/polyrun/reporting/failure_merge.rb +48 -12
  39. data/lib/polyrun/reporting/junit.rb +8 -7
  40. data/lib/polyrun/reporting/junit_report.rb +87 -0
  41. data/lib/polyrun/rspec/example_debug.rb +158 -0
  42. data/lib/polyrun/rspec/example_debug_instrumentation.rb +73 -0
  43. data/lib/polyrun/rspec/sharded_formatter_compat.rb +57 -0
  44. data/lib/polyrun/rspec.rb +34 -0
  45. data/lib/polyrun/spec_quality/profile.rb +24 -12
  46. data/lib/polyrun/spec_quality/report.rb +93 -0
  47. data/lib/polyrun/spec_quality.rb +19 -11
  48. data/lib/polyrun/timing/summary.rb +55 -5
  49. data/lib/polyrun/version.rb +1 -1
  50. data/lib/polyrun/worker_output.rb +159 -0
  51. data/lib/polyrun/worker_output_forwarders.rb +90 -0
  52. data/polyrun.gemspec +8 -1
  53. data/sig/polyrun/rspec.rbs +10 -0
  54. data/sig/polyrun/worker_output.rbs +11 -0
  55. metadata +65 -2
@@ -14,26 +14,26 @@ module Polyrun
14
14
  module Junit
15
15
  module_function
16
16
 
17
- def write_from_json_file(json_path, output_path:)
17
+ def write_from_json_file(json_path, output_path:, format: "xml")
18
18
  data = JSON.parse(File.read(json_path))
19
- write_from_hash(data, output_path: output_path)
19
+ write_from_hash(data, output_path: output_path, format: format)
20
20
  end
21
21
 
22
22
  # Merge several RSpec JSON outputs (parallel shards) by concatenating +examples+.
23
- def merge_rspec_json_files(paths, output_path:)
23
+ def merge_rspec_json_files(paths, output_path:, format: "xml")
24
24
  merged = {"examples" => []}
25
25
  paths.each do |p|
26
26
  data = JSON.parse(File.read(p))
27
27
  merged["examples"].concat(data["examples"] || [])
28
28
  end
29
29
  merged["summary"] = {"summary_line" => "merged #{paths.size} RSpec JSON file(s)"}
30
- write_from_hash(merged, output_path: output_path)
30
+ write_from_hash(merged, output_path: output_path, format: format)
31
31
  end
32
32
 
33
- def write_from_hash(data, output_path:)
33
+ def write_from_hash(data, output_path:, format: "xml")
34
34
  doc = parse_input(data)
35
- xml = emit_xml(doc)
36
- File.write(output_path, xml)
35
+ body = render(doc, format: format)
36
+ File.write(output_path, body)
37
37
  output_path
38
38
  end
39
39
 
@@ -123,3 +123,4 @@ module Polyrun
123
123
  end
124
124
 
125
125
  require_relative "junit_emit"
126
+ require_relative "junit_report"
@@ -0,0 +1,87 @@
1
+ require "json"
2
+
3
+ require_relative "../export/csv"
4
+ require_relative "../export/markdown"
5
+
6
+ module Polyrun
7
+ module Reporting
8
+ module Junit
9
+ CSV_HEADERS = %w[classname name status time_seconds failure_message].freeze
10
+ MARKDOWN_HEADERS = %w[classname name status time_seconds].freeze
11
+
12
+ module_function
13
+
14
+ def render(doc, format: "xml")
15
+ case format.to_s.downcase
16
+ when "xml" then emit_xml(doc)
17
+ when "csv" then emit_csv(doc)
18
+ when "markdown", "md" then emit_markdown(doc)
19
+ else
20
+ raise Polyrun::Error, "report-junit: unknown format #{format.inspect} (use xml, csv, or markdown)"
21
+ end
22
+ end
23
+
24
+ def emit_csv(doc)
25
+ rows = Array(doc["testcases"]).map { |testcase| csv_row(testcase) }
26
+ Export::Csv.generate(CSV_HEADERS, rows)
27
+ end
28
+
29
+ def csv_row(testcase)
30
+ testcase = testcase.transform_keys(&:to_s)
31
+ failure = testcase["failure"] || {}
32
+ failure = failure.transform_keys(&:to_s) if failure.is_a?(Hash)
33
+ [
34
+ testcase["classname"],
35
+ testcase["name"],
36
+ status_of(testcase),
37
+ format_float(testcase["time"] || 0),
38
+ failure["message"]
39
+ ]
40
+ end
41
+
42
+ def emit_markdown(doc)
43
+ cases = Array(doc["testcases"])
44
+ sections = [
45
+ markdown_summary_section(doc, cases),
46
+ markdown_testcases_section(cases)
47
+ ]
48
+ failure_section = markdown_failures_section(cases)
49
+ sections << failure_section if failure_section
50
+ Export::Markdown.document(doc["name"].to_s, sections)
51
+ end
52
+
53
+ def markdown_summary_section(doc, cases)
54
+ total_time = cases.sum { |testcase| (testcase["time"] || testcase[:time] || 0).to_f }
55
+ summary_rows = [
56
+ ["tests", cases.size],
57
+ ["failures", cases.count { |testcase| status_of(testcase) == "failed" }],
58
+ ["errors", cases.count { |testcase| status_of(testcase) == "error" }],
59
+ ["skipped", cases.count { |testcase| %w[pending skipped].include?(status_of(testcase)) }],
60
+ ["time_seconds", format_float(total_time)],
61
+ ["hostname", doc["hostname"]]
62
+ ]
63
+ {heading: "Summary", headers: %w[metric value], rows: summary_rows}
64
+ end
65
+
66
+ def markdown_testcases_section(cases)
67
+ testcase_rows = cases.map do |testcase|
68
+ testcase = testcase.transform_keys(&:to_s)
69
+ [testcase["classname"], testcase["name"], status_of(testcase), format_float(testcase["time"] || 0)]
70
+ end
71
+ {heading: "Test cases", headers: MARKDOWN_HEADERS, rows: testcase_rows}
72
+ end
73
+
74
+ def markdown_failures_section(cases)
75
+ failed = cases.select { |testcase| %w[failed error].include?(status_of(testcase)) }
76
+ return if failed.empty?
77
+
78
+ failure_rows = failed.map do |testcase|
79
+ testcase = testcase.transform_keys(&:to_s)
80
+ failure = (testcase["failure"] || {}).transform_keys(&:to_s)
81
+ [testcase["name"], status_of(testcase), failure["message"], failure["body"]]
82
+ end
83
+ {heading: "Failures", headers: %w[name status message body], rows: failure_rows}
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,158 @@
1
+ require "logger"
2
+ require "timeout"
3
+ require_relative "example_debug_instrumentation"
4
+
5
+ module Polyrun
6
+ module RSpec
7
+ # Per-example RSpec debugging for local investigation (SQL, TracePoint, timeouts).
8
+ #
9
+ # +POLYRUN_EXAMPLE_DEBUG=1+ enables installers; +DEBUG=1+ / +POLYRUN_DEBUG=1+ trace orchestration only.
10
+ # +POLYRUN_DEBUG_SQL=1+ or legacy +DEBUG_SQL=1+ logs mutating SQL.
11
+ # +POLYRUN_DEBUG_TRACE=1+ or legacy +DEBUG_TRACE=1+ traces :call/:raise under the app root.
12
+ # +DEBUG_LOG_LEVEL+ accepts Ruby Logger severities as integers (0 debug … 4 fatal) or names.
13
+ module ExampleDebug
14
+ LOG_LEVEL_BY_NAME = {
15
+ "debug" => Logger::DEBUG,
16
+ "info" => Logger::INFO,
17
+ "warn" => Logger::WARN,
18
+ "error" => Logger::ERROR,
19
+ "fatal" => Logger::FATAL
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def enabled?
25
+ truthy?(ENV["POLYRUN_EXAMPLE_DEBUG"])
26
+ end
27
+
28
+ def sql_enabled?
29
+ enabled? && (truthy?(ENV["POLYRUN_DEBUG_SQL"]) || truthy?(ENV["DEBUG_SQL"]))
30
+ end
31
+
32
+ def trace_enabled?
33
+ enabled? && (truthy?(ENV["POLYRUN_DEBUG_TRACE"]) || truthy?(ENV["DEBUG_TRACE"]))
34
+ end
35
+
36
+ def prosopite_enabled?
37
+ enabled? && truthy?(ENV["DEBUG_PROSOPITE"])
38
+ end
39
+
40
+ def print_spec_enabled?
41
+ enabled? && truthy?(ENV["DEBUG_PRINT_SPEC"])
42
+ end
43
+
44
+ def example_timeout_disabled?
45
+ enabled?
46
+ end
47
+
48
+ def log_level
49
+ parse_log_level(ENV.fetch("DEBUG_LOG_LEVEL", "debug"))
50
+ end
51
+
52
+ def rails_log_level
53
+ case log_level
54
+ when Logger::DEBUG then :debug
55
+ when Logger::INFO then :info
56
+ when Logger::WARN then :warn
57
+ when Logger::ERROR then :error
58
+ else :fatal
59
+ end
60
+ end
61
+
62
+ def parse_log_level(raw)
63
+ normalized = raw.to_s.strip.downcase
64
+ return LOG_LEVEL_BY_NAME.fetch(normalized) if LOG_LEVEL_BY_NAME.key?(normalized)
65
+
66
+ Integer(normalized)
67
+ rescue ArgumentError
68
+ Logger::DEBUG
69
+ end
70
+
71
+ def install!(rspec_config: fetch_rspec_configuration!)
72
+ install_spec_path_helpers!(rspec_config)
73
+ install_sql_debug!(rspec_config) if sql_enabled?
74
+ install_trace_debug!(rspec_config) if trace_enabled?
75
+ end
76
+
77
+ def install_rails_logging!(rspec_config: fetch_rspec_configuration!)
78
+ return unless enabled?
79
+ return unless defined?(Rails)
80
+
81
+ rspec_config.before do |example|
82
+ group = example.metadata[:example_group]
83
+ Polyrun::Log.puts "\n\nRunning #{group[:file_path]}:#{group[:line_number]}"
84
+
85
+ level = ExampleDebug.log_level
86
+ Rails.logger.level = level
87
+ if defined?(ActiveRecord::Base)
88
+ ar_logger = Logger.new(Polyrun::Log.stdout)
89
+ ar_logger.level = level
90
+ ActiveRecord::Base.logger = ar_logger
91
+ end
92
+ end
93
+ end
94
+
95
+ def install_example_timeout!(
96
+ rspec_config,
97
+ seconds: ENV.fetch("RSPEC_EXAMPLE_TIMEOUT_SEC", "30").to_f
98
+ )
99
+ return if seconds <= 0
100
+ return if example_timeout_disabled?
101
+
102
+ rspec_config.around(:each) do |example|
103
+ if example.metadata[:benchmark] || example.metadata[:slow]
104
+ example.run
105
+ else
106
+ Timeout.timeout(seconds) { example.run }
107
+ end
108
+ rescue Timeout::Error
109
+ raise "Example timed out after #{seconds}s (#{example.location})"
110
+ end
111
+ end
112
+
113
+ def install_prosopite!(rspec_config: fetch_rspec_configuration!)
114
+ return unless prosopite_enabled?
115
+ return unless defined?(Prosopite) && defined?(Rails)
116
+
117
+ log_path = Rails.root.join("tmp", "prosopite_#{Time.current.strftime("%Y%m%d_%H%M%S")}.log")
118
+ Prosopite.custom_logger = Logger.new(log_path)
119
+
120
+ rspec_config.before { Prosopite.scan }
121
+ rspec_config.after { Prosopite.finish }
122
+ end
123
+
124
+ def install_spec_path_helpers!(rspec_config)
125
+ rspec_config.before do |example|
126
+ group = example.metadata[:example_group]
127
+ spec_file_path = group[:file_path]
128
+
129
+ define_singleton_method(:spec_dirname) { File.dirname(spec_file_path) }
130
+ define_singleton_method(:spec_basename) { File.basename(spec_file_path) }
131
+
132
+ if ExampleDebug.print_spec_enabled?
133
+ Polyrun::Log.puts "\nRunning #{spec_file_path}:#{group[:line_number]}\n"
134
+ end
135
+ end
136
+
137
+ rspec_config.after do |example|
138
+ next unless ExampleDebug.print_spec_enabled?
139
+
140
+ group = example.metadata[:example_group]
141
+ Polyrun::Log.puts "\nFinished #{group[:file_path]}:#{group[:line_number]}\n"
142
+ end
143
+ end
144
+
145
+ def fetch_rspec_configuration!
146
+ require "rspec/core"
147
+ ::RSpec.configuration
148
+ end
149
+
150
+ def truthy?(value)
151
+ return false if value.nil?
152
+
153
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
154
+ end
155
+ private_class_method :truthy?, :parse_log_level, :fetch_rspec_configuration!
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,73 @@
1
+ module Polyrun
2
+ module RSpec
3
+ module ExampleDebug
4
+ SKIPPED_SQL_PREFIX = /\A(?:SELECT|SET|SHOW|BEGIN|COMMIT|ROLLBACK|RELEASE|SAVEPOINT)/
5
+
6
+ module_function
7
+
8
+ def install_sql_debug!(rspec_config, io: Polyrun::Log.stdout)
9
+ return unless defined?(ActiveSupport::Notifications)
10
+
11
+ rspec_config.around do |example|
12
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
13
+ payload = event.payload[:sql]
14
+ next unless loggable_sql?(payload)
15
+
16
+ line = sql_with_interpolated_binds(payload, event.payload[:type_casted_binds])
17
+ io.puts "+ #{line}"
18
+ end
19
+
20
+ example.run
21
+ ensure
22
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
23
+ end
24
+ end
25
+
26
+ def install_trace_debug!(rspec_config, root: trace_root, io: Polyrun::Log.stdout)
27
+ require "pp"
28
+ root_path = File.expand_path(root.to_s)
29
+ trace = TracePoint.new do |trace_point|
30
+ if trace_point.event == :call && trace_point.path.to_s.start_with?(root_path)
31
+ io.puts PP.pp({event: :call, path: "#{trace_point.path}:#{trace_point.lineno}"}, +"")
32
+ elsif trace_point.event == :raise
33
+ io.puts PP.pp(
34
+ {
35
+ event: :raise,
36
+ raised_exception: trace_point.raised_exception,
37
+ path: "#{trace_point.path}:#{trace_point.lineno}",
38
+ method_id: trace_point.method_id
39
+ },
40
+ +""
41
+ )
42
+ end
43
+ end
44
+
45
+ rspec_config.around do |example|
46
+ trace.enable
47
+ example.run
48
+ ensure
49
+ trace.disable
50
+ end
51
+ end
52
+
53
+ def loggable_sql?(sql)
54
+ !sql.to_s.match?(SKIPPED_SQL_PREFIX)
55
+ end
56
+
57
+ def sql_with_interpolated_binds(sql, binds)
58
+ output = sql.to_s.dup
59
+ Array(binds).each_with_index do |bind, index|
60
+ output = output.gsub("$#{index + 1}", "'#{bind}'")
61
+ end
62
+ output
63
+ end
64
+
65
+ def trace_root
66
+ return Rails.root.to_s if defined?(Rails)
67
+
68
+ Dir.pwd
69
+ end
70
+ private_class_method :trace_root
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,57 @@
1
+ module Polyrun
2
+ module RSpec
3
+ # Formatter tweaks for progress formatters under +POLYRUN_SHARD_*+ workers.
4
+ module ShardedFormatterCompat
5
+ TextFormatterSilencer = Module.new do
6
+ def seed(_notification)
7
+ end
8
+
9
+ def dump_summary(_notification)
10
+ end
11
+
12
+ def dump_pending(_notification)
13
+ end
14
+ end
15
+
16
+ FuubarSeedSilencer = Module.new do
17
+ def seed(_notification)
18
+ end
19
+ end
20
+
21
+ module_function
22
+
23
+ def install!(rspec_config: fetch_rspec_configuration!)
24
+ return unless sharded_worker?
25
+
26
+ rspec_config.silence_filter_announcements = true
27
+ silence_text_formatter_noise!
28
+ silence_fuubar_seed! if defined?(Fuubar)
29
+ end
30
+
31
+ def sharded_worker?
32
+ !ENV["POLYRUN_SHARD_TOTAL"].to_s.strip.empty?
33
+ end
34
+
35
+ def silence_text_formatter_noise!
36
+ require "rspec/core/formatters/base_text_formatter"
37
+ klass = ::RSpec::Core::Formatters::BaseTextFormatter
38
+ return if klass.ancestors.include?(TextFormatterSilencer)
39
+
40
+ klass.prepend(TextFormatterSilencer)
41
+ end
42
+
43
+ def silence_fuubar_seed!
44
+ return unless defined?(Fuubar)
45
+ return if Fuubar.ancestors.include?(FuubarSeedSilencer)
46
+
47
+ Fuubar.prepend(FuubarSeedSilencer)
48
+ end
49
+
50
+ def fetch_rspec_configuration!
51
+ require "rspec/core"
52
+ ::RSpec.configuration
53
+ end
54
+ private_class_method :fetch_rspec_configuration!
55
+ end
56
+ end
57
+ end
data/lib/polyrun/rspec.rb CHANGED
@@ -72,5 +72,39 @@ module Polyrun
72
72
 
73
73
  Polyrun::WorkerPing.ensure_interval_ping_thread!
74
74
  end
75
+
76
+ def install_example_debug!(rspec_config: nil)
77
+ require_relative "rspec/example_debug"
78
+ Polyrun::RSpec::ExampleDebug.install!(rspec_config: rspec_config || fetch_rspec_configuration!)
79
+ end
80
+
81
+ def install_example_timeout!(rspec_config: nil, seconds: ENV.fetch("RSPEC_EXAMPLE_TIMEOUT_SEC", "30").to_f)
82
+ require_relative "rspec/example_debug"
83
+ Polyrun::RSpec::ExampleDebug.install_example_timeout!(
84
+ rspec_config || fetch_rspec_configuration!,
85
+ seconds: seconds
86
+ )
87
+ end
88
+
89
+ def install_example_rails_logging!(rspec_config: nil)
90
+ require_relative "rspec/example_debug"
91
+ Polyrun::RSpec::ExampleDebug.install_rails_logging!(rspec_config: rspec_config || fetch_rspec_configuration!)
92
+ end
93
+
94
+ def install_example_prosopite!(rspec_config: nil)
95
+ require_relative "rspec/example_debug"
96
+ Polyrun::RSpec::ExampleDebug.install_prosopite!(rspec_config: rspec_config || fetch_rspec_configuration!)
97
+ end
98
+
99
+ def install_sharded_formatter_compat!(rspec_config: nil)
100
+ require_relative "rspec/sharded_formatter_compat"
101
+ Polyrun::RSpec::ShardedFormatterCompat.install!(rspec_config: rspec_config || fetch_rspec_configuration!)
102
+ end
103
+
104
+ def fetch_rspec_configuration!
105
+ require "rspec/core"
106
+ ::RSpec.configuration
107
+ end
108
+ private_class_method :fetch_rspec_configuration!
75
109
  end
76
110
  end
@@ -4,18 +4,30 @@ module Polyrun
4
4
  module Profile
5
5
  module_function
6
6
 
7
- def snapshot
8
- cpu = Process.times
9
- gc = GC.stat
10
- io = read_proc_io
11
- {
12
- "cpu_user" => cpu.utime,
13
- "cpu_system" => cpu.stime,
14
- "gc_allocated" => gc[:total_allocated_objects],
15
- "gc_heap_live" => gc[:heap_live_slots],
16
- "io_read_bytes" => io[:read_bytes],
17
- "io_write_bytes" => io[:write_bytes]
18
- }
7
+ def snapshot(dimensions: %w[cpu mem io wall])
8
+ dims = enabled_dimensions(dimensions)
9
+ capture_all = dims.empty?
10
+ out = {}
11
+
12
+ if capture_all || dims.include?("cpu") || dims.include?("wall")
13
+ cpu = Process.times
14
+ out["cpu_user"] = cpu.utime
15
+ out["cpu_system"] = cpu.stime
16
+ end
17
+
18
+ if capture_all || dims.include?("mem")
19
+ gc = GC.stat
20
+ out["gc_allocated"] = gc[:total_allocated_objects]
21
+ out["gc_heap_live"] = gc[:heap_live_slots]
22
+ end
23
+
24
+ if capture_all || dims.include?("io")
25
+ io = read_proc_io
26
+ out["io_read_bytes"] = io[:read_bytes]
27
+ out["io_write_bytes"] = io[:write_bytes]
28
+ end
29
+
30
+ out
19
31
  end
20
32
 
21
33
  def diff(before, after)
@@ -1,4 +1,7 @@
1
1
  # rubocop:disable Polyrun/FileLength, Metrics/ModuleLength -- report analysis + text formatting
2
+ require_relative "../export/csv"
3
+ require_relative "../export/markdown"
4
+
2
5
  module Polyrun
3
6
  module SpecQuality
4
7
  # Human-readable spec quality report from merged JSON.
@@ -55,6 +58,96 @@ module Polyrun
55
58
  lines.join("\n") + "\n"
56
59
  end
57
60
 
61
+ EXAMPLE_CSV_HEADERS = %w[example unique_lines line_churn max_line_churn shard_index].freeze
62
+
63
+ def format_csv(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
64
+ rows = merged.fetch("examples", {}).map do |location, row|
65
+ [
66
+ location,
67
+ row["unique_lines"],
68
+ row["line_churn"],
69
+ row["max_line_churn"],
70
+ row["polyrun_shard_index"]
71
+ ]
72
+ end.sort_by { |row| [-row[2].to_i, row[0].to_s] }
73
+ rows = rows.first(top) if top
74
+ Export::Csv.generate(EXAMPLE_CSV_HEADERS, rows)
75
+ end
76
+
77
+ def format_markdown(merged, cfg: {}, top: 30, profile: nil, plan_shards: nil)
78
+ analysis = analyze(merged, cfg, plan_shards: plan_shards)
79
+ sections = markdown_sections(analysis, top).compact
80
+ Export::Markdown.document("Polyrun spec quality report", sections)
81
+ end
82
+
83
+ def markdown_sections(analysis, top)
84
+ [
85
+ markdown_shard_section(analysis[:shard_summary]),
86
+ markdown_zero_hit_section(analysis[:zero_hit], top),
87
+ markdown_hot_lines_section(analysis[:hot_lines], top),
88
+ markdown_churn_section(analysis[:line_churn], top)
89
+ ]
90
+ end
91
+
92
+ def markdown_shard_section(shard_summary)
93
+ shard_rows = (shard_summary || {}).sort_by { |shard, _| shard.to_s }.map do |shard, stats|
94
+ [shard, stats["examples"], stats["zero_hit"], stats["line_churn"]]
95
+ end
96
+ return if shard_rows.empty?
97
+
98
+ {
99
+ heading: "Shard attribution",
100
+ headers: %w[shard examples zero_hit line_churn],
101
+ rows: shard_rows
102
+ }
103
+ end
104
+
105
+ def markdown_zero_hit_section(zero_hit, top)
106
+ zero_rows = zero_hit.keys.sort.first(top).zip
107
+ {
108
+ heading: "Zero production lines",
109
+ headers: %w[example],
110
+ rows: zero_rows.empty? ? [["(none)"]] : zero_rows
111
+ }
112
+ end
113
+
114
+ def markdown_hot_lines_section(hot_lines, top)
115
+ hot_rows = hot_lines.first(top).map do |line, stats|
116
+ [line, stats["example_count"], stats["total_hits"]]
117
+ end
118
+ {
119
+ heading: "Hot lines",
120
+ headers: %w[line example_count total_hits],
121
+ rows: hot_rows.empty? ? [["(none)", 0, 0]] : hot_rows
122
+ }
123
+ end
124
+
125
+ def markdown_churn_section(churn_rows, top)
126
+ rows = churn_rows.first(top).map do |location, row|
127
+ [location, row["line_churn"], row["max_line_churn"]]
128
+ end
129
+ {
130
+ heading: "Per-example line churn",
131
+ headers: %w[example line_churn max_line_churn],
132
+ rows: rows.empty? ? [["(none)", 0, 0]] : rows
133
+ }
134
+ end
135
+
136
+ def render(merged, format: "text", **kwargs)
137
+ case format.to_s.downcase
138
+ when "text", "console", "txt"
139
+ format_report(merged, **kwargs)
140
+ when "json"
141
+ JSON.pretty_generate(analyze(merged, kwargs[:cfg] || {}, plan_shards: kwargs[:plan_shards]))
142
+ when "csv"
143
+ format_csv(merged, **kwargs)
144
+ when "markdown", "md"
145
+ format_markdown(merged, **kwargs)
146
+ else
147
+ raise Polyrun::Error, "report-spec-quality: unknown format #{format.inspect} (use text, json, csv, or markdown)"
148
+ end
149
+ end
150
+
58
151
  def gate_violations(merged, cfg = {})
59
152
  cfg = default_cfg(cfg)
60
153
  analysis = analyze(merged, cfg)
@@ -58,8 +58,12 @@ module Polyrun
58
58
  @current = {
59
59
  location: normalize_location(location),
60
60
  wall_start: wall_start || Process.clock_gettime(Process::CLOCK_MONOTONIC),
61
- coverage_before: Coverage::ExampleDiff.peek_blob,
62
- profile_before: Profile.snapshot,
61
+ coverage_before: Coverage::ExampleDiff.peek_blob(
62
+ root: @config["root"],
63
+ track_under: @config["track_under"],
64
+ ignore_paths: @config["ignore_paths"]
65
+ ),
66
+ profile_before: Profile.snapshot(dimensions: @config["profile"]),
63
67
  sql_count: 0,
64
68
  sql_fingerprints: Hash.new(0),
65
69
  factory_counts: {}
@@ -78,16 +82,9 @@ module Polyrun
78
82
  loc = location || cur[:location]
79
83
  return if loc.nil? || loc.to_s.empty?
80
84
 
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
- )
85
+ delta = coverage_delta_for_example(cur)
89
86
 
90
- profile_after = Profile.snapshot
87
+ profile_after = Profile.snapshot(dimensions: @config["profile"])
91
88
  profile_delta = Profile.diff(cur[:profile_before], profile_after)
92
89
  wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cur[:wall_start]
93
90
  profile_delta["wall"] = wall
@@ -169,6 +166,17 @@ module Polyrun
169
166
  Polyrun::Partition::TimingKeys.canonical_file_path(File.expand_path(s, root))
170
167
  end
171
168
 
169
+ def coverage_delta_for_example(cur)
170
+ after_source = Coverage::ExampleDiff.coverage_active? ? ::Coverage.peek_result : {}
171
+ Coverage::ExampleDiff.diff(
172
+ cur[:coverage_before],
173
+ after_source,
174
+ root: @config["root"],
175
+ track_under: @config["track_under"],
176
+ ignore_paths: @config["ignore_paths"]
177
+ )
178
+ end
179
+
172
180
  def build_row(cur, location, delta, profile_delta, factory_counts)
173
181
  profile = Profile.slice_profile(profile_delta, @config["profile"])
174
182
  repeated_sql = cur[:sql_fingerprints].select { |_sql, n| n >= min_query_count }.transform_keys(&:to_s)