simplecov 1.1.1 → 1.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +148 -5
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +19 -37
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +16 -10
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +37 -55
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +15 -9
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -19
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -55
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +56 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +34 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +57 -113
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -5
@@ -3,61 +3,87 @@
3
3
  require "json"
4
4
  require "optparse"
5
5
  require_relative "command_helpers"
6
+ require_relative "patch/output"
7
+ require_relative "show/annotator"
8
+ require_relative "uncovered/misses"
6
9
 
7
10
  module SimpleCov
8
11
  module CLI
9
- # `simplecov uncovered [--threshold N] [--top N] [--criterion C]` — list
10
- # the lowest-coverage files (by the chosen criterion, ascending), so a
11
- # developer can answer "where should I add tests next?" without
12
- # opening a browser. Reads coverage.json directly.
12
+ # `simplecov uncovered`: the lowest-coverage files by the chosen criterion,
13
+ # ascending, so a developer can answer "where should I add tests next?"
14
+ # without opening a browser.
13
15
  module Uncovered
14
16
  extend CommandHelpers
15
17
 
16
18
  DEFAULT_TOP = 10
17
19
 
18
- module_function
20
+ extend self
19
21
 
20
22
  def run(args, stdout:, stderr:, **)
21
23
  opts = parse(args)
22
- keys = CoverageFile::CRITERIA[opts[:criterion]]
23
- return unknown_criterion(opts[:criterion], stderr) unless keys
24
+ issue = precheck(opts)
25
+ return error(stderr, issue) if issue
24
26
 
27
+ keys = CoverageFile::CRITERIA[opts.fetch(:criterion)]
28
+ return unknown_criterion(opts.fetch(:criterion), stderr) unless keys
29
+
30
+ opts[:missing] = true if opts.fetch(:annotate)
25
31
  report(opts, keys, stdout, stderr)
26
32
  end
27
33
 
34
+ def precheck(opts)
35
+ return nil unless opts.fetch(:annotate)
36
+ unless opts.fetch(:annotate).eql?("github")
37
+ return "unknown --annotate #{opts.fetch(:annotate).inspect} (only github is supported)"
38
+ end
39
+
40
+ "cannot combine --annotate with --json" if opts.fetch(:json)
41
+ end
42
+
28
43
  def report(opts, keys, stdout, stderr)
29
- coverage = CoverageFile.load_coverage(opts[:input], command: "uncovered", stderr: stderr)
44
+ coverage = CoverageFile.load_coverage(opts.fetch(:input), command: "uncovered", stderr: stderr)
30
45
  return 1 unless coverage
31
46
 
32
- files = rank(coverage, opts[:threshold], keys).first(opts[:top])
33
- return stdout.puts(empty_message(opts[:json])) || 0 if files.empty?
47
+ files = rank(coverage, opts, keys).first(opts.fetch(:top))
48
+ return empty(opts, stdout) if files.empty?
34
49
 
35
50
  emit(stdout, files, opts)
36
51
  0
37
52
  end
38
53
 
54
+ def empty(opts, stdout)
55
+ stdout.puts(empty_message(opts.fetch(:json))) unless opts.fetch(:annotate)
56
+ 0
57
+ end
58
+
39
59
  def unknown_criterion(criterion, stderr)
40
60
  stderr.puts("simplecov uncovered: unknown --criterion #{criterion.inspect} (expected line, branch, or method)")
41
61
  1
42
62
  end
43
63
 
44
64
  def emit(stdout, files, opts)
45
- opts[:json] ? emit_json(stdout, files) : emit_text(stdout, files, SimpleCov::CLI.color_enabled?(opts, stdout))
65
+ return Misses.annotate(stdout, files) if opts.fetch(:annotate)
66
+
67
+ opts.fetch(:json) ? emit_json(stdout, files) : emit_text(stdout, files, CLI.color_enabled?(opts, stdout))
46
68
  end
47
69
 
48
70
  def parse(args)
49
- opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line) do |o, options|
50
- o.on("--threshold N", Float) { |v| options[:threshold] = v }
51
- o.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
52
- o.on("--criterion C") { |v| options[:criterion] = v.to_sym }
53
- end
71
+ opts, = parse_common(args, threshold: 100.0, top: DEFAULT_TOP, criterion: :line,
72
+ missing: false, annotate: nil) { |o, options| own_options(o, options) }
54
73
  opts
55
74
  end
56
75
 
57
- # A negative count would raise from `Array#first`; report it as
58
- # the parse error it is (`--top 0` is allowed and shows nothing).
59
- # OptionParser prepends the offending option's name, so the raise
60
- # carries only the reason.
76
+ def own_options(parser, options)
77
+ parser.on("--threshold N", Float) { |v| options[:threshold] = v }
78
+ parser.on("--top N", Integer) { |v| options[:top] = validate_top(v) }
79
+ parser.on("--criterion C") { |v| options[:criterion] = v.to_sym }
80
+ parser.on("--missing") { options[:missing] = true }
81
+ parser.on("--annotate KIND") { |v| options[:annotate] = v }
82
+ end
83
+
84
+ # A negative count would raise from `Array#first`, so it is reported as the
85
+ # parse error it is. `--top 0` is allowed and shows nothing. OptionParser
86
+ # prepends the offending option's name, so the raise carries only the reason.
61
87
  def validate_top(value)
62
88
  raise OptionParser::InvalidArgument, "must not be negative" if value.negative?
63
89
 
@@ -65,12 +91,18 @@ module SimpleCov
65
91
  end
66
92
 
67
93
  def emit_text(stdout, files, color)
68
- files.each { |fname, pct, covered, total| stdout.puts(format_row(fname, pct, covered, total, color)) }
94
+ files.each do |fname, pct, covered, total, missed|
95
+ row = format_row(fname, pct, covered, total, color)
96
+ row += " missing #{Patch::Output.ranges(missed, ",")}" if missed&.any?
97
+ stdout.puts(row)
98
+ end
69
99
  end
70
100
 
71
101
  def emit_json(stdout, files)
72
- rows = files.map do |fname, pct, covered, total|
73
- {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
102
+ rows = files.map do |fname, pct, covered, total, missed|
103
+ row = {"file" => fname, "percent" => pct, "covered" => covered, "total" => total}
104
+ row["missing"] = missed if missed
105
+ row
74
106
  end
75
107
  stdout.puts(JSON.pretty_generate(rows))
76
108
  end
@@ -79,24 +111,30 @@ module SimpleCov
79
111
  json ? "[]" : "simplecov uncovered: nothing to report"
80
112
  end
81
113
 
82
- def rank(coverage_hash, threshold, keys)
83
- rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, threshold, keys) }
114
+ def rank(coverage_hash, opts, keys)
115
+ rows = coverage_hash.filter_map { |fname, payload| row_for(fname, payload, opts, keys) }
84
116
  rows.sort_by { |_fname, pct, _c, _t| pct }
85
117
  end
86
118
 
87
- def row_for(fname, payload, threshold, keys)
88
- return unless payload.is_a?(Hash) && payload[keys[:total]].to_i.positive?
119
+ def row_for(fname, payload, opts, keys)
120
+ return unless payload.instance_of?(Hash) && payload[keys.fetch(:total)].to_i.positive?
121
+
122
+ pct = payload[keys.fetch(:percent)].to_f
123
+ return if pct >= opts.fetch(:threshold)
89
124
 
90
- pct = payload[keys[:percent]].to_f
91
- return if pct >= threshold
125
+ build_row(fname, pct, payload, opts, keys)
126
+ end
92
127
 
93
- [fname, pct, payload[keys[:covered]].to_i, payload[keys[:total]].to_i]
128
+ def build_row(fname, pct, payload, opts, keys)
129
+ row = [fname, pct, payload[keys.fetch(:covered)].to_i, payload.fetch(keys.fetch(:total)).to_i]
130
+ row << Misses.missed_for(payload, opts.fetch(:criterion)) if opts.fetch(:missing)
131
+ row
94
132
  end
95
133
 
96
134
  def format_row(fname, pct, covered, total, color)
97
135
  format("%<pct>s %<covered>d/%<total>d %<fname>s",
98
- pct: SimpleCov::Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
99
- covered: covered, total: total, fname: fname)
136
+ pct: Color.colorize_percent(pct, format("%6.2f%%", pct), enabled: color),
137
+ covered: covered, total: total, fname: fname)
100
138
  end
101
139
  end
102
140
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Usage
6
+ extend self
7
+
8
+ def for(cli, command)
9
+ sections = text(cli).split("\n\n")
10
+ row = sections.fetch(1).lines.find { |line| line.match?(/\A #{Regexp.escape(command)}[ \[]/) }
11
+ options = sections.select { |section| section_for?(section, command) }
12
+ ["Usage: simplecov #{command} [options]", row&.strip, *options].compact.join("\n\n")
13
+ end
14
+
15
+ def section_for?(section, command)
16
+ header = heading_of(section)
17
+ header.end_with?("options:") && header.delete_suffix("options:").split(%r{[\s/]+}).include?(command)
18
+ end
19
+
20
+ def heading_of(section)
21
+ section.lines.first.to_s.strip
22
+ end
23
+
24
+ # A method rather than a constant so its default paths resolve at call time
25
+ # against the active `.simplecov`.
26
+ def text(cli)
27
+ <<~USAGE
28
+ Usage: simplecov <command> [options]
29
+
30
+ Commands:
31
+ run <command...> Execute <command> with SimpleCov pre-loaded
32
+ (works without a test_helper hook)
33
+ coverage <path> Print coverage stats for the given file
34
+ show <path> Print the file's source annotated with hit counts and misses
35
+ report Print the overall summary and group totals
36
+ status Report freshness: age, commit distance, recorded tests
37
+ history Print the recorded coverage trend, a sparkline per criterion
38
+ badge Render the coverage percent as an SVG badge
39
+ uncovered List the lowest-coverage files
40
+ tests [<path>[:<line>]] List recorded tests for a file or line (needs track_tests)
41
+ affected List test files touching code changed since a git ref (needs track_tests)
42
+ merge <files...> Merge multiple .resultset.json files
43
+ diff <baseline> Show per-file coverage delta vs baseline
44
+ patch Show coverage of the lines a change touched
45
+ ratchet Rewrite the per-file coverage baseline, only ever tightening
46
+ dead-code Cross production coverage with the report to find dead code
47
+ open Open the HTML report in the default browser
48
+ serve Serve the coverage report over HTTP
49
+ watch <command...> Re-run <command> on save and live-reload the served report
50
+ clean Remove the coverage report directory
51
+ completions <shell> Emit the completion script for fish, bash, or zsh
52
+ help Show this message
53
+ version Print the version (also --version / -v)
54
+
55
+ Every command answers `--help` / `-h`. Default paths follow a project's
56
+ `.simplecov` SimpleCov.coverage_dir (#{cli.coverage_dir} for this run).
57
+
58
+ coverage / show / report / status / uncovered / tests / affected / diff / patch / ratchet / dead-code options:
59
+ --input PATH Read from PATH instead of #{cli.default_input}
60
+ --no-color Disable colorized percentages (also honors NO_COLOR / FORCE_COLOR env)
61
+
62
+ coverage options:
63
+ --json Print the file's JSON entry verbatim
64
+
65
+ show options:
66
+ --uncovered-only Print only <path>:<ranges> of the missed lines
67
+ --json Emit path, missed lines, per-line hits, and markers as JSON
68
+
69
+ report options:
70
+ --json Emit totals and group sections as JSON
71
+
72
+ status options:
73
+ --json Emit the freshness facts as a JSON object
74
+
75
+ history options:
76
+ --input PATH Read from PATH instead of the coverage directory's .history.json
77
+ --file PATH Follow one file's trajectory instead of the totals
78
+ --json Emit the entries (or the file's trajectory) as JSON
79
+ --no-color Disable colorized deltas (also honors NO_COLOR / FORCE_COLOR env)
80
+
81
+ badge options:
82
+ --input PATH Read from PATH instead of #{cli.default_input}
83
+ --output PATH Write the badge to PATH instead of stdout
84
+ --criterion C line, branch, or method (default: line)
85
+ --label TEXT The badge's left-side text (default: the criterion's, like "line coverage")
86
+
87
+ uncovered options:
88
+ --threshold N Only show files below N% coverage
89
+ --top N Show at most N files (default: 10)
90
+ --criterion C line, branch, or method (default: line)
91
+ --missing Append the missed line ranges to each row
92
+ --annotate github Emit ::warning workflow commands instead of rows
93
+ --json Emit results as a JSON array (for CI)
94
+
95
+ tests options:
96
+ --redundant List only tests whose covered lines other tests also cover
97
+ --json Emit the test ids as a JSON array
98
+
99
+ affected options:
100
+ --base REF Diff against the merge base of REF and HEAD (default: origin's HEAD, else main)
101
+ --run <command...> Run <command> (all args after --run) with the selection appended, or bare on a full-suite fallback, exiting with the command's status
102
+ --json Emit {full_suite, triggers, tests} as a JSON object
103
+
104
+ merge options:
105
+ --output PATH Write merged resultset to PATH (default: #{cli.default_resultset})
106
+ --honor-timeout Drop entries older than merge_timeout
107
+ --dry-run Print what would be written without writing
108
+ -q, --quiet Suppress the success status line
109
+
110
+ diff options:
111
+ --fail-on-drop Exit non-zero when any file's coverage dropped vs baseline (deletions don't count)
112
+ --json Emit results as a JSON array (for CI)
113
+ --threshold N Only show files whose absolute delta in any criterion is at least N%
114
+
115
+ patch options:
116
+ --base REF Diff against the merge-base of REF for the touched lines (default: origin's HEAD, else main, or in CI the PR's target branch)
117
+ --minimum N Exit non-zero when patch coverage on any measured criterion is below N%
118
+ --find-renames Follow a renamed file instead of counting the moved file as all-new
119
+ --json Emit results as a JSON array (for CI)
120
+
121
+ dead-code options:
122
+ --production PATH The production coverage file a SimpleCov::Production sink wrote (default: the project's `production_coverage`, required when none is configured)
123
+ --untested-in-production Print code production runs that no test covers, instead of the dead rows
124
+ --json Emit every category (dead, possibly dead, untested in production) as JSON
125
+
126
+ ratchet options:
127
+ --baseline PATH The baseline file to rewrite (default: the project's `baseline_file`, .simplecov_baseline.yml)
128
+ --init Regenerate from scratch: add entries for new files and reset every floor to the current state
129
+ --dry-run Print what would change without writing
130
+ --json Emit the summary (tightened / pruned / regressed entries) as JSON
131
+
132
+ open options:
133
+ --report PATH Open PATH instead of #{cli.default_report}
134
+
135
+ serve options:
136
+ --port N Bind to port N (default: random open port)
137
+ --host HOST Bind to HOST (default: 127.0.0.1)
138
+
139
+ watch options:
140
+ --port N Bind to port N (default: random open port)
141
+ --host HOST Bind to HOST (default: 127.0.0.1)
142
+ --interval SECONDS Poll the tracked files this often (default: 0.5)
143
+ --open Open the served report in the default browser
144
+
145
+ clean options:
146
+ --dry-run Print what would be removed without deleting
147
+ -q, --quiet Suppress status lines
148
+ USAGE
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Watch
6
+ # The live half of the watch server: an /events endpoint streaming
7
+ # server-sent events to every open report tab, and an index route that
8
+ # appends the reload listener to the artifact on the way out, so the report
9
+ # on disk stays byte-identical to a plain run's.
10
+ class LiveReport
11
+ RELOAD_SCRIPT = "\n<script>new EventSource('/events').onmessage = () => location.reload();</script>\n"
12
+
13
+ def initialize(dir)
14
+ @dir = dir
15
+ @queues = [] #: Array[Thread::Queue]
16
+ @lock = Mutex.new
17
+ end
18
+
19
+ def routes
20
+ page = method(:page)
21
+ {"/events" => method(:stream), "/" => page, "/index.html" => page}
22
+ end
23
+
24
+ def broadcast(event = "reload")
25
+ with_queues { |queues| queues.each { |queue| queue << event } }
26
+ end
27
+
28
+ private
29
+
30
+ # Holds the connection open, forwarding each broadcast as one SSE message,
31
+ # until the tab goes away and the write fails. A closed tab that never sees
32
+ # another broadcast parks its thread on the queue, a leak the dev-server
33
+ # trade accepts.
34
+ def stream(client)
35
+ queue = Queue.new #: Thread::Queue
36
+ begin
37
+ with_queues { |queues| queues << queue }
38
+ forward(client, queue)
39
+ ensure
40
+ with_queues { |queues| queues.delete(queue) }
41
+ end
42
+ end
43
+
44
+ def forward(client, queue)
45
+ client.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" \
46
+ "Cache-Control: no-cache\r\n\r\n")
47
+ loop { client.write("data: #{next_event(queue)}\n\n") }
48
+ rescue IOError, SystemCallError
49
+ nil
50
+ end
51
+
52
+ # mutant:disable
53
+ def next_event(queue)
54
+ queue.pop
55
+ end
56
+
57
+ def page(client)
58
+ body = File.binread(File.join(@dir, "index.html")) + RELOAD_SCRIPT
59
+ Serve::StaticFileHandler.respond(client, 200, body, "text/html; charset=utf-8")
60
+ rescue SystemCallError
61
+ Serve::StaticFileHandler.respond(client, 404)
62
+ end
63
+
64
+ # mutant:disable
65
+ # Every touch of the queue registry goes through the lock: an accept thread
66
+ # adds and drops its own queue while the session thread walks them all.
67
+ def with_queues
68
+ @lock.synchronize { yield @queues }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Watch
6
+ class Narrator
7
+ def initialize(stdout, root)
8
+ @stdout = stdout
9
+ @root = root
10
+ end
11
+
12
+ def banner(server, count)
13
+ host = Serve.url_host(server.addr.fetch(3))
14
+ @stdout.puts("watching #{count} file#{"s" unless count.eql?(1)}, " \
15
+ "serving http://#{host}:#{server.addr.fetch(1)}/")
16
+ @stdout.puts("Press Ctrl-C to stop.")
17
+ end
18
+
19
+ def change(changed, plan)
20
+ named = name(changed)
21
+ return @stdout.puts("#{named} changed, no recorded test touches it") unless plan.fetch(:run)
22
+
23
+ @stdout.print("#{named} changed, #{action(plan.fetch(:tests))}...")
24
+ end
25
+
26
+ def result(before, after)
27
+ return @stdout.puts unless after
28
+
29
+ delta = before ? format(" (%+.2f%%)", after - before) : ""
30
+ @stdout.puts(format(" %.2f%%", after) + delta)
31
+ end
32
+
33
+ def failed_refresh
34
+ @stdout.puts(" the report did not regenerate")
35
+ end
36
+
37
+ def stopping
38
+ @stdout.puts("\nsimplecov watch: stopping")
39
+ end
40
+
41
+ private
42
+
43
+ def name(changed)
44
+ relative = changed.map { |path| path.delete_prefix("#{@root}/") }
45
+ named = relative.first
46
+ relative.size.eql?(1) ? named : "#{named} and #{relative.size - 1} more"
47
+ end
48
+
49
+ def action(tests)
50
+ tests ? "running #{tests.size} file#{"s" unless tests.one?}" : "running the full suite"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Watch
6
+ # Watches a fixed set of paths by polling mtimes, the trade chosen over a
7
+ # filesystem-event dependency for a command that is optional to begin with.
8
+ # Editors that save through a temporary file plus rename still move the final
9
+ # path's mtime, which is all this looks at.
10
+ class Poller
11
+ def initialize
12
+ @snapshot = {} #: Hash[String, untyped]
13
+ end
14
+
15
+ # Replaces the watched set, snapshotting current mtimes. A path with no file
16
+ # snapshots as nil, so appearing counts as a change.
17
+ def watch(paths)
18
+ @snapshot = paths.to_h { |path| [path, stamp(path)] }
19
+ end
20
+
21
+ def size
22
+ @snapshot.size
23
+ end
24
+
25
+ def changes
26
+ changed = [] #: Array[String]
27
+ @snapshot.each_key do |path|
28
+ now = stamp(path)
29
+ next if now.eql?(@snapshot.fetch(path))
30
+
31
+ @snapshot[path] = now
32
+ changed << path
33
+ end
34
+ changed
35
+ end
36
+
37
+ def stamp(path)
38
+ File.mtime(path)
39
+ rescue SystemCallError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Watch
6
+ # One watch session: accept report connections in the background, poll the
7
+ # tracked set for changes, re-run tests on save (selecting over the recorded
8
+ # test map when the report carries one), and push a reload to every open tab
9
+ # when the report regenerates.
10
+ class Session
11
+ # The merge window the child runs get through SIMPLECOV_MERGE_TIMEOUT: a
12
+ # day, so subset re-runs keep merging into a whole report across a long
13
+ # session instead of eroding it after the default ten minutes.
14
+ MERGE_WINDOW = "86400"
15
+
16
+ TOTALS_KEYS = {"line" => "lines", "branch" => "branches", "method" => "methods"}.freeze
17
+
18
+ def initialize(command:, dir:, interval:, stdout:, stderr:)
19
+ @command = command
20
+ @dir = dir
21
+ @interval = interval
22
+ @stderr = stderr
23
+ @root = Dir.pwd
24
+ @narrator = Narrator.new(stdout, @root)
25
+ @live = LiveReport.new(dir)
26
+ @poller = Poller.new
27
+ @document = {} #: Hash[String, untyped]
28
+ end
29
+
30
+ def run(server)
31
+ accept_loop(server)
32
+ run_tests(nil) unless File.exist?(json_path)
33
+ return 1 unless refresh
34
+
35
+ @narrator.banner(server, @poller.size)
36
+ poll_forever
37
+ rescue Interrupt
38
+ @narrator.stopping
39
+ 0
40
+ end
41
+
42
+ def poll_forever
43
+ loop do
44
+ sleep(@interval)
45
+ step
46
+ end
47
+ end
48
+
49
+ def step
50
+ changed = settled_changes
51
+ return if changed.empty?
52
+
53
+ plan = plan_for(changed)
54
+ @narrator.change(changed, plan)
55
+ return unless plan.fetch(:run)
56
+
57
+ before = total_percent
58
+ run_tests(plan.fetch(:tests))
59
+ return @narrator.failed_refresh unless refresh
60
+
61
+ @live.broadcast
62
+ @narrator.result(before, total_percent)
63
+ end
64
+
65
+ private
66
+
67
+ def accept_loop(server)
68
+ Thread.new do
69
+ loop do
70
+ Thread.new(server.accept) do |client|
71
+ Serve::StaticFileHandler.handle_connection(client, @dir, @live.routes)
72
+ end
73
+ end
74
+ rescue IOError, SystemCallError
75
+ nil
76
+ end
77
+ end
78
+
79
+ def plan_for(changed)
80
+ relative = changed.map { |path| path.delete_prefix("#{@root}/") }
81
+ TestPlan.build(relative, @document, root: @root, input: json_path, stderr: @stderr)
82
+ end
83
+
84
+ # The command is the project's own test invocation and must produce the
85
+ # report itself. A selection is appended to it; no selection means the whole
86
+ # suite, which is the command on its own.
87
+ def run_tests(tests)
88
+ env = {"SIMPLECOV_MERGE_TIMEOUT" => MERGE_WINDOW}
89
+ Kernel.system(env, *(tests ? @command + tests : @command))
90
+ end
91
+
92
+ # The regenerated report is both the watch set and the totals the result line
93
+ # diffs against. The previous document is kept when the read fails, so one
94
+ # bad write doesn't blind the session.
95
+ def refresh
96
+ document = CoverageFile.load_document(json_path, command: "watch", stderr: @stderr)
97
+ return false unless document
98
+
99
+ @document = document
100
+ @poller.watch(TestPlan.watched_paths(document, @root))
101
+ document
102
+ end
103
+
104
+ # Editors save in bursts, so after the first detection keep polling until a
105
+ # full interval passes with nothing new.
106
+ def settled_changes
107
+ changed = @poller.changes
108
+ return changed if changed.empty?
109
+
110
+ loop do
111
+ sleep(@interval)
112
+ more = @poller.changes
113
+ break if more.empty?
114
+
115
+ changed |= more
116
+ end
117
+ changed
118
+ end
119
+
120
+ # Follows the report's own primary criterion, falling back to line coverage
121
+ # for documents from before meta carried one.
122
+ def total_percent
123
+ key = TOTALS_KEYS[@document.dig("meta", "primary_coverage")] || "lines"
124
+ value = @document.dig("total", key, "percent")
125
+ (_ = value).to_f if value.is_a?(Numeric)
126
+ end
127
+
128
+ def json_path
129
+ File.join(@dir, "coverage.json")
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Watch
6
+ module TestPlan
7
+ extend self
8
+
9
+ # {run:, tests:} where nil tests mean the full command. A report with no map,
10
+ # a malformed one, or any fail-open trigger runs everything; an empty
11
+ # selection runs nothing.
12
+ def build(changed, document, root:, input:, stderr:)
13
+ contexts = document["contexts"]
14
+ return {run: true, tests: nil} unless contexts.instance_of?(Array) && contexts.all?(String)
15
+
16
+ selection = Affected::Selection.build(changed, document, contexts, {input: input}, stderr, root: root)
17
+ return {run: true, tests: nil} if selection.nil? || !selection.fetch(:triggers).empty?
18
+ return {run: false, tests: []} if selection.fetch(:tests).empty?
19
+
20
+ {run: true, tests: selection.fetch(:tests)}
21
+ end
22
+
23
+ # The watch set a report defines: its tracked files plus the files of its
24
+ # recorded tests.
25
+ def watched_paths(document, root)
26
+ none = [] #: Array[String]
27
+ coverage = document["coverage"]
28
+ sources = coverage.instance_of?(Hash) ? coverage.keys : none
29
+ tests = recorded_test_files(document).map { |path| File.expand_path(path, root) }
30
+ (sources + tests).uniq
31
+ end
32
+
33
+ def recorded_test_files(document)
34
+ none = [] #: Array[String]
35
+ contexts = document["contexts"]
36
+ return none unless contexts.instance_of?(Array)
37
+
38
+ contexts.filter_map { |id| Affected::Selection.test_file_of(id.to_s) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end