simplecov 0.22.0 → 1.3.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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
data/exe/simplecov ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require "simplecov/cli"
8
+ exit SimpleCov::CLI.run(ARGV)
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # How minitest plugins. See https://github.com/simplecov-ruby/simplecov/pull/756 for why we need this.
4
- # https://github.com/seattlerb/minitest#writing-extensions
5
3
  module Minitest
4
+ # Handles the SimpleCov-first / Minitest-second ordering: `SimpleCov.start`
5
+ # runs before `require "minitest/autorun"`, so its start-time detection can't
6
+ # see Minitest yet, but by the time this plugin fires Minitest is loaded and
7
+ # the same switch can be flipped. The opposite ordering is handled in
8
+ # `SimpleCov.install_at_exit_hook` (#756).
9
+ #
10
+ # The respond_to? guards cover a process where only simplecov's version file
11
+ # was loaded (#877), which defines the SimpleCov constant without any of its
12
+ # configuration.
6
13
  def self.plugin_simplecov_init(_options)
7
- if defined?(SimpleCov)
8
- SimpleCov.external_at_exit = true
14
+ return unless defined?(SimpleCov)
9
15
 
10
- Minitest.after_run do
11
- SimpleCov.at_exit_behavior
12
- end
16
+ SimpleCov::TestTracker.install_minitest_hook if SimpleCov.respond_to?(:track_tests?) && SimpleCov.track_tests?
17
+
18
+ return if SimpleCov.external_at_exit?
19
+
20
+ SimpleCov.external_at_exit = true
21
+
22
+ Minitest.after_run do
23
+ SimpleCov.at_exit_behavior
13
24
  end
14
25
  end
15
26
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+
6
+ module SimpleCov
7
+ # Replaces an artifact through a collision-safe temporary file in the
8
+ # destination directory, so readers see either the old or the complete new
9
+ # file.
10
+ module AtomicFile
11
+ DEFAULT_MODE = 0o666
12
+ private_constant :DEFAULT_MODE
13
+
14
+ def self.write(path, content, binary: false)
15
+ directory = File.dirname(path)
16
+ FileUtils.mkdir_p(directory)
17
+ mode = destination_mode(path)
18
+
19
+ Tempfile.create([".simplecov-", ".tmp"], directory) do |temp|
20
+ replace(temp, path, content, mode, binary: binary)
21
+ end
22
+
23
+ nil
24
+ end
25
+
26
+ # Closing flushes the last buffered bytes, so the rename publishes a complete
27
+ # file rather than whatever happened to reach disk. No cleanup of its own:
28
+ # `Tempfile.create` closes and removes the file when its block ends, however
29
+ # it ends.
30
+ def self.replace(temp, path, content, mode, binary:)
31
+ temp.binmode if binary
32
+ temp.write(content)
33
+ temp.close
34
+ File.chmod(mode, temp)
35
+ rename_over(temp, path)
36
+ end
37
+ private_class_method :replace
38
+
39
+ # On POSIX systems rename replaces the destination atomically. On Windows it
40
+ # fails with EACCES when the destination is open in a reader or
41
+ # mid-replacement by a concurrent writer, so retry briefly there.
42
+ def self.rename_over(temp, path)
43
+ remaining = 10
44
+ begin
45
+ File.rename(temp, path)
46
+ rescue Errno::EACCES
47
+ raise unless Gem.win_platform?
48
+
49
+ remaining -= 1
50
+ raise if remaining.zero?
51
+
52
+ sleep(0.01)
53
+ retry
54
+ end
55
+ end
56
+ private_class_method :rename_over
57
+
58
+ def self.destination_mode(path)
59
+ return DEFAULT_MODE & ~File.umask if File.symlink?(path)
60
+
61
+ File.stat(path).mode & 0o777
62
+ rescue Errno::ENOENT
63
+ DEFAULT_MODE & ~File.umask
64
+ end
65
+ private_class_method :destination_mode
66
+ end
67
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded via `RUBYOPT="-rsimplecov/autostart"` from `simplecov run`. The require
4
+ # below also auto-loads `~/.simplecov` and the project's `.simplecov`, which may
5
+ # already call `SimpleCov.start`. That call is idempotent, so making it
6
+ # unconditionally is the safe way to ensure the report is formatted at exit.
7
+
8
+ require "simplecov"
9
+ SimpleCov.start
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Baseline
5
+ # Turns the YAML document of a baseline file into the
6
+ # `{path => {criterion => Floor}}` entries `Baseline` holds. The canonical
7
+ # shape is the criteria Hash `to_yaml` writes, but the lenient shapes stay
8
+ # readable: a bare number is a line-percent floor, and a criterion may carry
9
+ # a bare percent instead of the percent/missed pair. Anything else raises
10
+ # ConfigurationError, naming the file and the offending entry: a malformed
11
+ # policy must fail loudly rather than silently un-enforce every floor.
12
+ module Parser
13
+ extend self
14
+
15
+ def call(data, path)
16
+ raise ConfigurationError, "baseline file #{path} must map file paths to floors" unless data.is_a?(Hash)
17
+
18
+ data.to_h { |file, entry| [file.to_s, parse_entry(file, entry, path)] }
19
+ end
20
+
21
+ def parse_entry(file, entry, path)
22
+ return {line: Floor.new(percent: entry, missed: nil)} if entry.is_a?(Numeric)
23
+
24
+ unless entry.is_a?(Hash)
25
+ raise ConfigurationError, "baseline file #{path}: entry for #{file} must be a number or a criteria Hash"
26
+ end
27
+
28
+ entry.to_h do |criterion_key, floor|
29
+ [parse_criterion(file, criterion_key, path), parse_floor(file, floor, path)]
30
+ end
31
+ end
32
+
33
+ def parse_criterion(file, criterion_key, path)
34
+ CRITERIA[criterion_key.to_s] || raise(
35
+ ConfigurationError,
36
+ "baseline file #{path}: unknown criterion #{criterion_key.inspect} for #{file} " \
37
+ "(expected lines, branches, or methods)"
38
+ )
39
+ end
40
+
41
+ def parse_floor(file, floor, path)
42
+ return Floor.new(percent: floor, missed: nil) if floor.is_a?(Numeric)
43
+
44
+ percent = floor["percent"] if floor.is_a?(Hash)
45
+ unless percent.is_a?(Numeric)
46
+ raise ConfigurationError, "baseline file #{path}: floor for #{file} needs a numeric percent"
47
+ end
48
+
49
+ missed = floor["missed"]
50
+ unless missed.nil? || missed.instance_of?(Integer)
51
+ raise ConfigurationError, "baseline file #{path}: missed count for #{file} must be an integer"
52
+ end
53
+
54
+ Floor.new(percent: percent, missed: missed)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SimpleCov
6
+ # The checked-in per-file coverage floor behind `simplecov ratchet` (#1268):
7
+ # `.rubocop_todo.yml` applied to coverage. Each entry records, per criterion,
8
+ # the percent a file has already reached and the number of misses it had when
9
+ # that percent was recorded.
10
+ #
11
+ # A floor carries both numbers because each covers for the other's blind
12
+ # spot. The percent is the policy, but a percent moves when a file is edited
13
+ # without any coverage change at all, so the missed count acts as the
14
+ # dampener. A violation therefore requires both a lower percent and more
15
+ # misses, which is the honest reading of "this file got worse".
16
+ class Baseline
17
+ DEFAULT_FILENAME = ".simplecov_baseline.yml"
18
+
19
+ HEADER = <<~HEADER
20
+ # Per-file coverage floors. A file that drops below its floor fails the run,
21
+ # and files with no entry fall through to the global per-file minimum.
22
+ # Regenerate with `simplecov ratchet`. Floors only ever tighten.
23
+ HEADER
24
+ private_constant :HEADER
25
+
26
+ # The YAML file speaks the report's vocabulary; internally floors are keyed
27
+ # by criterion the way the threshold configuration is.
28
+ CRITERIA = {"lines" => :line, "branches" => :branch, "methods" => :method}.freeze
29
+ private_constant :CRITERIA
30
+
31
+ # `missed` is nil for a hand-written percent-only entry, meaning the
32
+ # dampener is absent and the percent alone decides.
33
+ Floor = Data.define(:percent, :missed)
34
+
35
+ # The tightened baseline plus the path lists the CLI summarizes (each path
36
+ # appears in exactly one).
37
+ Outcome = Data.define(:baseline, :tightened, :pruned, :regressed, :unchanged)
38
+
39
+ # A file that exists but cannot be read as a baseline raises
40
+ # ConfigurationError: a malformed policy must fail loudly rather than
41
+ # silently un-enforce every floor it carried.
42
+ def self.read_if_exists(path)
43
+ return nil unless File.exist?(path)
44
+
45
+ new(Parser.call(YAML.safe_load_file(path), path))
46
+ rescue Psych::Exception => e
47
+ # Interpolating the exception renders the message Psych objected with.
48
+ raise ConfigurationError, "baseline file #{path} is not valid YAML: #{e}"
49
+ end
50
+
51
+ # `current` is a `{project_filename => {criterion => {percent:, missed:}}}`
52
+ # Hash of the measured state. Everything the report carries gets a floor at
53
+ # its current coverage.
54
+ def self.read(path)
55
+ Deprecation.warn("`SimpleCov::Baseline.read` is deprecated. Replace with `read_if_exists`.")
56
+ read_if_exists(path)
57
+ end
58
+
59
+ def self.generate(current)
60
+ new(current.transform_values do |criteria|
61
+ criteria.transform_values { |floor| Floor.new(percent: floor.fetch(:percent), missed: floor.fetch(:missed)) }
62
+ end)
63
+ end
64
+
65
+ attr_reader :entries
66
+
67
+ def initialize(entries)
68
+ @entries = entries
69
+ end
70
+
71
+ def entry_for(project_filename)
72
+ entries[project_filename]
73
+ end
74
+
75
+ def floor_for(project_filename, criterion)
76
+ entries.dig(project_filename, criterion)
77
+ end
78
+
79
+ # Which is also what exempts the pair from the per-file thresholds.
80
+ def covers?(project_filename, criterion)
81
+ !floor_for(project_filename, criterion).nil?
82
+ end
83
+
84
+ # Floors only move in the tightening direction, each axis independently:
85
+ # the percent keeps its best, the missed count its lowest. Entries for
86
+ # files `current` does not carry are pruned; files `current` carries
87
+ # without an entry stay uncovered, so new code answers to the global
88
+ # standard rather than to a floor cut at whatever it launched with. A
89
+ # criterion the current run did not measure keeps its floor, and a newly
90
+ # measured criterion joins the entry, so a later `enable_coverage :branch`
91
+ # grandfathers the legacy files' branch state.
92
+ def ratchet(current)
93
+ buckets = {tightened: [], pruned: [], regressed: [], unchanged: []} #: Hash[Symbol, Array[String]]
94
+ ratcheted = entries.filter_map do |file, entry|
95
+ step = ratchet_entry(entry, current[file])
96
+ buckets.fetch(step.fetch(:bucket)) << file
97
+ merged = step[:entry]
98
+ [file, merged] if merged
99
+ end.to_h
100
+
101
+ Outcome.new(baseline: Baseline.new(ratcheted), tightened: buckets.fetch(:tightened),
102
+ pruned: buckets.fetch(:pruned), regressed: buckets.fetch(:regressed),
103
+ unchanged: buckets.fetch(:unchanged))
104
+ end
105
+
106
+ # Sorted by path so a ratchet rewrite diffs as the set of floors that
107
+ # actually moved.
108
+ def to_yaml
109
+ document = entries.sort.to_h do |file, entry|
110
+ [file, entry.to_h { |criterion, floor| [CRITERIA.key(criterion), dump_floor(floor)] }]
111
+ end
112
+ HEADER + YAML.dump(document).delete_prefix("---\n")
113
+ end
114
+
115
+ private
116
+
117
+ # A nil current prunes the entry, which carries no replacement and so
118
+ # answers with the bucket alone. Otherwise every measured criterion
119
+ # tightens, keeping the ones this run did not measure.
120
+ def ratchet_entry(entry, current_entry)
121
+ return {bucket: :pruned} unless current_entry
122
+
123
+ merged = (entry.keys | current_entry.keys).to_h do |criterion|
124
+ [criterion, tighten(entry[criterion], current_entry[criterion])]
125
+ end
126
+ {bucket: bucket_for(entry, merged, current_entry), entry: merged}
127
+ end
128
+
129
+ # The regressed bucket mirrors the violation rule exactly, so "below its
130
+ # floor" in the ratchet summary always means the exit check fails the file,
131
+ # never a percent drift the dampener tolerates.
132
+ def bucket_for(entry, merged, current_entry)
133
+ # Exact equality rather than numeric: tightening keeps the entry's own
134
+ # floor on a tie, so an entry that did not move is the very values it
135
+ # came in with.
136
+ return :tightened unless merged.eql?(entry)
137
+
138
+ regressed = entry.any? do |criterion, floor|
139
+ current = current_entry[criterion]
140
+ current && current.fetch(:percent) < floor.percent &&
141
+ (floor.missed.nil? || current.fetch(:missed) > floor.missed)
142
+ end
143
+ regressed ? :regressed : :unchanged
144
+ end
145
+
146
+ # Either side may be absent: a floor with no current measurement stays, a
147
+ # measurement with no floor becomes one.
148
+ def tighten(floor, current)
149
+ # The keys union in ratchet_entry guarantees at least one side is present,
150
+ # which the cast restates for the type checker.
151
+ return (_ = floor) unless current
152
+
153
+ current_floor = Floor.new(percent: current.fetch(:percent), missed: current.fetch(:missed))
154
+ return current_floor unless floor
155
+
156
+ Floor.new(
157
+ percent: [floor.percent, current_floor.percent].max,
158
+ missed: [floor.missed, current_floor.missed].compact.min
159
+ )
160
+ end
161
+
162
+ def dump_floor(floor)
163
+ dumped = {"percent" => floor.percent} #: Hash[String, untyped]
164
+ dumped["missed"] = floor.missed unless floor.missed.nil?
165
+ dumped
166
+ end
167
+ end
168
+ end
169
+
170
+ require_relative "baseline/parser"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../git"
4
+
5
+ module SimpleCov
6
+ module CLI
7
+ module Affected
8
+ # The change as git sees it, as `{root:, changed:}`: the files that differ
9
+ # between the merge base of the given ref and the working tree, so commits
10
+ # that landed on the base after the branch point stay out while uncommitted
11
+ # work stays in, unioned with untracked files, since a brand-new spec exists
12
+ # in no diff. Everything runs against the repository toplevel rather than the
13
+ # cwd, so a subdirectory run selects over the whole change. NUL separation
14
+ # keeps exotic filenames literal instead of quoted.
15
+ module ChangedFiles
16
+ extend self
17
+
18
+ def call(base, stderr)
19
+ return report(stderr, "invalid base ref #{base.inspect}") if Git.option_like_ref?(base)
20
+
21
+ root = repository_toplevel(stderr)
22
+ return nil unless root
23
+
24
+ diff = list("diff", ["-C", root, "diff", "--name-only", "-z", "--merge-base", base, "--"], stderr)
25
+ return nil unless diff
26
+
27
+ untracked = list("ls-files", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z"], stderr)
28
+ untracked && {root: root, changed: (diff + untracked).uniq}
29
+ end
30
+
31
+ # nil stdout from `Git.capture` means git never ran; a run that failed
32
+ # means the cwd is outside a working tree.
33
+ def repository_toplevel(stderr)
34
+ stdout, detail, success = Git.capture("rev-parse", "--show-toplevel")
35
+ return (_ = stdout).chomp if success
36
+ return report(stderr, "cannot run git (#{detail})") if stdout.nil?
37
+
38
+ report(stderr, "not inside a git working tree")
39
+ end
40
+
41
+ # git's own first stderr line is relayed on failure, so a bad ref or an old
42
+ # git names itself.
43
+ def list(command, argv, stderr)
44
+ stdout, detail, success = Git.capture(*argv)
45
+ return (_ = stdout).split("\0") if success
46
+ return report(stderr, "cannot run git (#{detail})") if stdout.nil?
47
+
48
+ report(stderr, "`git #{command}` failed: #{detail}")
49
+ end
50
+
51
+ def report(stderr, message)
52
+ stderr.puts("simplecov affected: #{message}")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module CLI
5
+ module Affected
6
+ module Selection
7
+ TEST_FILE_NAME = /\A(?:test_.*|.*_(?:test|spec))\.rb\z/
8
+
9
+ extend self
10
+
11
+ def build(changed, document, contexts, opts, stderr, root:)
12
+ none = {} #: Hash[String, untyped]
13
+ coverage = document.fetch("coverage", none)
14
+ unless coverage.instance_of?(Hash)
15
+ return CoverageFile.report_invalid(stderr, "affected", opts.fetch(:input), '"coverage" must be an object')
16
+ end
17
+
18
+ state = initial_state(changed, contexts, coverage, opts, stderr, root: root)
19
+ return nil unless changed.all? { |path| classify(path, state) }
20
+
21
+ {tests: state.fetch(:tests).uniq.sort, triggers: state.fetch(:triggers)}
22
+ end
23
+
24
+ def initial_state(changed, contexts, coverage, opts, stderr, root:)
25
+ {
26
+ tests: [], triggers: [], changed: changed, contexts: contexts,
27
+ known: contexts.each_with_object(Set.new) { |id, set| set << test_file_of(id) },
28
+ index: CoverageFile.exact_index(coverage), root: root, opts: opts, stderr: stderr
29
+ } #: Hash[Symbol, untyped]
30
+ end
31
+
32
+ # A recorded test file selects itself, a file the report covers selects the
33
+ # files of the tests touching it, a test-looking file the map has never seen
34
+ # always runs, and anything else is a trigger. Changed paths are exact
35
+ # root-relative names, so they resolve against the report exactly: a
36
+ # lookalike entry elsewhere must not stand in for a file it doesn't carry.
37
+ def classify(path, state)
38
+ entry = entry_for(path, state)
39
+ if state.fetch(:known).include?(path)
40
+ select_test_file(path, state)
41
+ elsif entry
42
+ select_covering(path, entry, state)
43
+ elsif TEST_FILE_NAME.match?(File.basename(path))
44
+ select_test_file(path, state, note_deleted: false)
45
+ else
46
+ note_untracked(path, state)
47
+ end
48
+ end
49
+
50
+ def note_untracked(path, state)
51
+ state.fetch(:triggers) << "#{path} changed but #{state.fetch(:opts).fetch(:input)} has no data for it"
52
+ end
53
+
54
+ # A changed test file runs, unless the change is its deletion: its tests are
55
+ # gone, which removes them from the answer without distrusting the map.
56
+ def select_test_file(path, state, note_deleted: true)
57
+ if on_disk?(path, state)
58
+ state.fetch(:tests) << path
59
+ elsif note_deleted
60
+ state.fetch(:stderr).puts("simplecov affected: skipping deleted test file #{path}")
61
+ end
62
+ path
63
+ end
64
+
65
+ # Existence judged at the repository root the paths are relative to, not the
66
+ # cwd, so a subdirectory run answers the same.
67
+ def on_disk?(path, state)
68
+ File.exist?(File.expand_path(path, state.fetch(:root)))
69
+ end
70
+
71
+ def entry_for(path, state)
72
+ state.fetch(:index)[File.expand_path(path, state.fetch(:root))] || state.fetch(:index)[path]
73
+ end
74
+
75
+ def select_covering(path, entry, state)
76
+ table = covering_table(path, entry, state)
77
+ table&.each_key { |index| select_context(state.fetch(:contexts).fetch(index), path, state) }
78
+ end
79
+
80
+ def covering_table(path, entry, state)
81
+ unless entry.instance_of?(Hash)
82
+ return CoverageFile.report_invalid(state.fetch(:stderr), "affected", state.fetch(:opts).fetch(:input),
83
+ "entry for #{path} must be an object")
84
+ end
85
+
86
+ raw = entry["contexts"] || {}
87
+ table = raw.instance_of?(Hash) && Tests.decode_table(raw, state.fetch(:contexts).size)
88
+ return table if table
89
+
90
+ CoverageFile.report_invalid(state.fetch(:stderr), "affected", state.fetch(:opts).fetch(:input),
91
+ "entry for #{path} carries a malformed \"contexts\" table")
92
+ end
93
+
94
+ # Selected via its file, or a staleness trigger when it has no file to
95
+ # select: a locationless id, or a file that exists neither on disk nor in
96
+ # the change.
97
+ def select_context(id, path, state)
98
+ file = test_file_of(id)
99
+ if file.nil?
100
+ state.fetch(:triggers) << "recorded test #{id} touches #{path} but has no file location"
101
+ elsif on_disk?(file, state)
102
+ state.fetch(:tests) << file
103
+ elsif !state.fetch(:changed).include?(file)
104
+ state.fetch(:triggers) << "recorded test file #{file} no longer exists"
105
+ end
106
+ end
107
+
108
+ # "spec/a_spec.rb:12" and the :file granularity's bare "spec/a_spec.rb" both
109
+ # name a file, while a locationless fallback id names none.
110
+ def test_file_of(id)
111
+ base = id.sub(/:\d+\z/, "")
112
+ base if base.include?("/") || base.end_with?(".rb")
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "command_helpers"
6
+ require_relative "tests"
7
+ require_relative "affected/changed_files"
8
+ require_relative "affected/selection"
9
+
10
+ module SimpleCov
11
+ module CLI
12
+ # `simplecov affected`: select the test files whose recorded tests touch the
13
+ # code changed since a git base ref, from a coverage.json written under
14
+ # `track_tests`. The change is everything between the merge base of `--base`
15
+ # and the working tree, plus untracked files.
16
+ #
17
+ # The set intersection is the easy half; the hard half is knowing when to
18
+ # distrust the map, because a test map is stale the moment something changes
19
+ # that no test mentions by name. Any changed file outside the tracked set
20
+ # fails open to the full suite, out loud: each trigger is named on stderr and
21
+ # stdout stays empty, which a bare runner reads as "run everything". Test
22
+ # files always select themselves, recorded or not.
23
+ module Affected
24
+ extend CommandHelpers
25
+
26
+ extend self
27
+
28
+ def run(args, stdout:, stderr:)
29
+ opts = parse(args)
30
+ issue = precheck(opts)
31
+ return error(stderr, issue) if issue
32
+
33
+ document = CoverageFile.load_document(opts.fetch(:input), command: "affected", stderr: stderr)
34
+ return 1 unless document
35
+
36
+ contexts = recorded_contexts(document, opts, stderr)
37
+ return 1 unless contexts
38
+
39
+ diffed = ChangedFiles.call(opts[:base] ||= Git.default_base, stderr)
40
+ return 1 unless diffed
41
+
42
+ respond(diffed, document, contexts, opts, stdout: stdout, stderr: stderr)
43
+ end
44
+
45
+ def parse(args)
46
+ head, runner = split_runner(args)
47
+ opts, rest = parse_common(head, base: nil, run: runner) do |parser, into|
48
+ parser.on("--base REF") { |v| into[:base] = v }
49
+ end
50
+ opts[:rest] = rest
51
+ opts
52
+ end
53
+
54
+ # Everything after `--run` is the runner command, verbatim, so the command's
55
+ # own flags never collide with ours.
56
+ def split_runner(args)
57
+ index = args.index("--run")
58
+ return [args, nil] unless index
59
+
60
+ [args.take(index), args.drop(index + 1)]
61
+ end
62
+
63
+ # A stray positional looks exactly like a forgotten `--base` ref, and
64
+ # silently dropping it would select tests for the wrong change.
65
+ def precheck(opts)
66
+ stray = opts.fetch(:rest).first
67
+ return "unexpected argument #{stray.inspect} (did you mean `--base #{stray}`?)" if stray
68
+ return "missing command after --run" if opts.fetch(:run) && opts.fetch(:run).empty?
69
+
70
+ "--run and --json cannot be combined" if opts.fetch(:run) && opts.fetch(:json)
71
+ end
72
+
73
+ def respond(diffed, document, contexts, opts, stdout:, stderr:)
74
+ opts[:root] = diffed.fetch(:root)
75
+ return no_changes(opts, stdout, stderr) if diffed.fetch(:changed).empty?
76
+
77
+ selection = Selection.build(diffed.fetch(:changed), document, contexts, opts, stderr, root: diffed.fetch(:root))
78
+ return 1 unless selection
79
+
80
+ deliver(selection, opts, stdout, stderr)
81
+ end
82
+
83
+ def no_changes(opts, stdout, stderr)
84
+ stderr.puts("simplecov affected: no changes against #{opts.fetch(:base)}")
85
+ empty = {tests: [], triggers: []} #: Hash[Symbol, Array[String]]
86
+ emit_json(empty, false, stdout) if opts.fetch(:json)
87
+ 0
88
+ end
89
+
90
+ def deliver(selection, opts, stdout, stderr)
91
+ full = !selection.fetch(:triggers).empty?
92
+ selection.fetch(:triggers).each { |trigger| stderr.puts("simplecov affected: #{trigger}") }
93
+ stderr.puts("simplecov affected: falling back to the full suite") if full
94
+ return emit_json(selection, full, stdout) if opts.fetch(:json)
95
+ return execute(selection, full, opts, stderr) if opts.fetch(:run)
96
+
97
+ emit_text(selection, full, stdout, stderr)
98
+ end
99
+
100
+ def emit_json(selection, full, stdout)
101
+ none = [] #: Array[String]
102
+ stdout.puts(JSON.pretty_generate("full_suite" => full, "triggers" => selection.fetch(:triggers),
103
+ "tests" => full ? none : selection.fetch(:tests)))
104
+ 0
105
+ end
106
+
107
+ # Stdout carries the selected files and nothing else, so the list can feed a
108
+ # runner directly, and a full-suite fallback prints nothing, which
109
+ # `rspec $(simplecov affected)` reads as "run everything".
110
+ def emit_text(selection, full, stdout, stderr)
111
+ return 0 if full
112
+
113
+ tests = selection.fetch(:tests)
114
+ if tests.empty?
115
+ note_untouched(stderr)
116
+ else
117
+ tests.each { |file| stdout.puts(file) }
118
+ 0
119
+ end
120
+ end
121
+
122
+ def note_untouched(stderr)
123
+ stderr.puts("simplecov affected: no recorded test touches the changed code")
124
+ 0
125
+ end
126
+
127
+ def execute(selection, full, opts, stderr)
128
+ return run_command(opts.fetch(:run), opts.fetch(:root), stderr) if full
129
+ return note_untouched(stderr) if selection.fetch(:tests).empty?
130
+
131
+ count = selection.fetch(:tests).size
132
+ stderr.puts("simplecov affected: running #{count} test file#{"s" unless count.eql?(1)}")
133
+ run_command(opts.fetch(:run) + selection.fetch(:tests), opts.fetch(:root), stderr)
134
+ end
135
+
136
+ # The selection's paths are relative to the repository root, so the runner
137
+ # starts there. The command is named explicitly in the failure because not
138
+ # every engine's exception message carries it.
139
+ def run_command(command, root, stderr)
140
+ _, status = Process.wait2(spawn(*command, chdir: root))
141
+ status.exitstatus || 1
142
+ rescue SystemCallError => e
143
+ stderr.puts("simplecov affected: cannot run #{command.first.inspect} (#{e})")
144
+ 127
145
+ end
146
+ end
147
+ end
148
+ end