simplecov 0.22.0 → 1.0.0.rc2

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/LICENSE +1 -1
  4. data/README.md +1009 -511
  5. data/doc/alternate-formatters.md +0 -5
  6. data/doc/commercial-services.md +5 -5
  7. data/exe/simplecov +11 -0
  8. data/lib/minitest/simplecov_plugin.rb +13 -5
  9. data/lib/simplecov/autostart.rb +11 -0
  10. data/lib/simplecov/cli/clean.rb +47 -0
  11. data/lib/simplecov/cli/coverage.rb +91 -0
  12. data/lib/simplecov/cli/diff.rb +151 -0
  13. data/lib/simplecov/cli/dotfile.rb +100 -0
  14. data/lib/simplecov/cli/merge.rb +116 -0
  15. data/lib/simplecov/cli/open.rb +50 -0
  16. data/lib/simplecov/cli/report.rb +84 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve.rb +139 -0
  19. data/lib/simplecov/cli/uncovered.rb +107 -0
  20. data/lib/simplecov/cli.rb +150 -0
  21. data/lib/simplecov/color.rb +74 -0
  22. data/lib/simplecov/combine/branches_combiner.rb +3 -2
  23. data/lib/simplecov/combine/files_combiner.rb +7 -1
  24. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  25. data/lib/simplecov/combine/methods_combiner.rb +26 -0
  26. data/lib/simplecov/combine/results_combiner.rb +5 -4
  27. data/lib/simplecov/command_guesser.rb +46 -32
  28. data/lib/simplecov/configuration/coverage.rb +171 -0
  29. data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
  30. data/lib/simplecov/configuration/filters.rb +197 -0
  31. data/lib/simplecov/configuration/formatting.rb +119 -0
  32. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  33. data/lib/simplecov/configuration/merging.rb +74 -0
  34. data/lib/simplecov/configuration/thresholds.rb +174 -0
  35. data/lib/simplecov/configuration.rb +86 -407
  36. data/lib/simplecov/coverage_statistics.rb +12 -9
  37. data/lib/simplecov/coverage_violations.rb +148 -0
  38. data/lib/simplecov/defaults.rb +27 -20
  39. data/lib/simplecov/deprecation.rb +47 -0
  40. data/lib/simplecov/directive.rb +162 -0
  41. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  47. data/lib/simplecov/exit_codes.rb +3 -0
  48. data/lib/simplecov/exit_handling.rb +158 -0
  49. data/lib/simplecov/file_list.rb +61 -17
  50. data/lib/simplecov/filter.rb +69 -24
  51. data/lib/simplecov/formatter/base.rb +101 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  53. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  57. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  58. data/lib/simplecov/formatter/html_formatter.rb +79 -0
  59. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
  60. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
  61. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  62. data/lib/simplecov/formatter/json_formatter.rb +77 -0
  63. data/lib/simplecov/formatter/multi_formatter.rb +4 -5
  64. data/lib/simplecov/formatter/simple_formatter.rb +9 -11
  65. data/lib/simplecov/formatter.rb +4 -0
  66. data/lib/simplecov/last_run.rb +10 -3
  67. data/lib/simplecov/lines_classifier.rb +26 -13
  68. data/lib/simplecov/load_global_config.rb +9 -4
  69. data/lib/simplecov/parallel_adapters/base.rb +51 -0
  70. data/lib/simplecov/parallel_adapters/generic.rb +42 -0
  71. data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
  72. data/lib/simplecov/parallel_adapters.rb +83 -0
  73. data/lib/simplecov/parallel_coordination.rb +95 -0
  74. data/lib/simplecov/process.rb +26 -14
  75. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  76. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  77. data/lib/simplecov/profiles/rails.rb +24 -10
  78. data/lib/simplecov/profiles/root_filter.rb +6 -5
  79. data/lib/simplecov/profiles/strict.rb +32 -0
  80. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  81. data/lib/simplecov/profiles.rb +32 -3
  82. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  83. data/lib/simplecov/result/source_file_builder.rb +51 -0
  84. data/lib/simplecov/result.rb +97 -19
  85. data/lib/simplecov/result_adapter.rb +68 -6
  86. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  87. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  88. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  89. data/lib/simplecov/result_merger.rb +54 -90
  90. data/lib/simplecov/result_processing.rb +162 -0
  91. data/lib/simplecov/simulate_coverage.rb +54 -8
  92. data/lib/simplecov/source_file/branch.rb +1 -3
  93. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  94. data/lib/simplecov/source_file/builder_context.rb +28 -0
  95. data/lib/simplecov/source_file/line.rb +7 -2
  96. data/lib/simplecov/source_file/line_builder.rb +43 -0
  97. data/lib/simplecov/source_file/method.rb +52 -0
  98. data/lib/simplecov/source_file/method_builder.rb +58 -0
  99. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  100. data/lib/simplecov/source_file/skip_chunks.rb +77 -0
  101. data/lib/simplecov/source_file/source_loader.rb +63 -0
  102. data/lib/simplecov/source_file/statistics.rb +57 -0
  103. data/lib/simplecov/source_file.rb +66 -232
  104. data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
  105. data/lib/simplecov/static_coverage_extractor.rb +111 -0
  106. data/lib/simplecov/useless_results_remover.rb +16 -7
  107. data/lib/simplecov/version.rb +1 -1
  108. data/lib/simplecov-html.rb +4 -0
  109. data/lib/simplecov.rb +148 -377
  110. data/lib/simplecov_json_formatter.rb +4 -0
  111. data/schemas/coverage-v1.0.schema.json +300 -0
  112. data/schemas/coverage.schema.json +300 -0
  113. metadata +89 -56
  114. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # simplecov:disable
4
+ # Methods below only fire under a parallel test runner; not reachable
5
+ # from a single-process rspec run. Cucumber's test_projects exercise
6
+ # the parallel_tests integration end-to-end in subprocesses, but
7
+ # those subprocesses don't merge their Coverage data back into the
8
+ # parent this dogfood report measures.
9
+ module SimpleCov
10
+ class << self
11
+ # @api private
12
+ # How long the first worker is willing to wait for all sibling
13
+ # workers' resultsets to appear in the cache before proceeding
14
+ # with whatever it has. Tuned generously enough that slow CI
15
+ # runners with one straggler don't trip the "incomplete results"
16
+ # path on a routine basis. See #1065 for the parallel_rspec /
17
+ # GenericAdapter case where there is no native wait primitive
18
+ # and this poll is the only synchronization available.
19
+ PARALLEL_RESULTS_WAIT_TIMEOUT = 60
20
+ private_constant :PARALLEL_RESULTS_WAIT_TIMEOUT
21
+
22
+ # @api private
23
+ def final_result_process?
24
+ adapter = SimpleCov::ParallelAdapters.current
25
+ return true unless adapter
26
+
27
+ adapter.first_worker?
28
+ end
29
+
30
+ # @api private
31
+ def wait_for_other_processes
32
+ adapter = SimpleCov::ParallelAdapters.current
33
+ return unless adapter && final_result_process?
34
+
35
+ # Native synchronization first (adapters that wrap a runner with a
36
+ # real "wait" primitive — parallel_tests'
37
+ # `wait_for_other_processes_to_finish` — implement this; adapters
38
+ # without a native API no-op and rely on the polling fallback below).
39
+ adapter.wait_for_siblings
40
+
41
+ # The native wait can return before sibling at_exit handlers finish
42
+ # writing resultsets, and adapters without a native wait have
43
+ # nothing else. Either way, poll the resultset cache until all
44
+ # expected workers have reported or a timeout is reached. Capture
45
+ # the outcome so `ready_to_process_results?` can suppress min/max
46
+ # threshold checks against a partial total.
47
+ @parallel_results_complete = wait_for_parallel_results(adapter.expected_worker_count)
48
+ end
49
+
50
+ # @api private — true when every sibling reported its resultset
51
+ # before the wait deadline. Defaults to true outside a parallel
52
+ # run (when `wait_for_other_processes` is a no-op).
53
+ def parallel_results_complete?
54
+ defined?(@parallel_results_complete) ? @parallel_results_complete : true
55
+ end
56
+
57
+ # @api private — returns true when every expected worker reported
58
+ # before the deadline, false on timeout. Single-process runs
59
+ # (expected <= 1) short-circuit to true with no waiting.
60
+ def wait_for_parallel_results(expected)
61
+ return true unless expected > 1 # simplecov:disable branch — only false in real parallel runs
62
+
63
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PARALLEL_RESULTS_WAIT_TIMEOUT
64
+ loop do
65
+ seen = SimpleCov::ResultMerger.read_resultset.size
66
+ return true if seen >= expected
67
+ return false if parallel_wait_timed_out?(deadline, expected, seen)
68
+
69
+ sleep 0.1
70
+ end
71
+ end
72
+
73
+ # @api private — true once the wait deadline has passed; warns on
74
+ # the first timeout so the user knows the merged total is partial.
75
+ def parallel_wait_timed_out?(deadline, expected, seen)
76
+ return false unless Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
77
+
78
+ warn_about_incomplete_parallel_results(expected, seen)
79
+ true
80
+ end
81
+
82
+ # @api private
83
+ def warn_about_incomplete_parallel_results(expected, seen)
84
+ return unless print_errors
85
+
86
+ warn SimpleCov::Color.colorize(
87
+ "Only #{seen} of #{expected} parallel-test workers reported within " \
88
+ "#{PARALLEL_RESULTS_WAIT_TIMEOUT}s. Coverage totals are partial; " \
89
+ "minimum / maximum coverage checks are skipped for this run.",
90
+ :yellow
91
+ )
92
+ end
93
+ end
94
+ end
95
+ # simplecov:enable
@@ -1,19 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Process
4
- class << self
5
- def fork_with_simplecov(&block)
6
- if defined?(SimpleCov) && SimpleCov.running
7
- fork_without_simplecov do
8
- SimpleCov.at_fork.call(Process.pid)
9
- block.call if block_given?
10
- end
11
- else
12
- fork_without_simplecov(&block)
13
- end
14
- end
3
+ # Hooks `Process._fork` (Ruby 3.1+) so child processes inherit SimpleCov's
4
+ # coverage tracking when `SimpleCov.enable_for_subprocesses?` is set.
5
+ #
6
+ # `Process._fork` is the official extension point: `Kernel#fork`,
7
+ # `Process.fork`, `IO.popen("-")`, and similar all funnel through it.
8
+ # Hooking `_fork` (instead of redefining `Process.fork`) composes
9
+ # correctly with other libraries doing the same — they each prepend
10
+ # their own module and chain via `super`.
15
11
 
16
- alias fork_without_simplecov fork
17
- alias fork fork_with_simplecov
12
+ module SimpleCov
13
+ # Prepended onto Process's singleton class so every fork — direct or
14
+ # via Kernel#fork / IO.popen — re-runs SimpleCov's at_fork callback in
15
+ # the child.
16
+ module ProcessForkHook
17
+ def _fork
18
+ active = defined?(SimpleCov) && Coverage.running?
19
+ # Assign the next serial in the PARENT, before the fork, so the child
20
+ # inherits its own stable ordinal via copy-on-write. The default
21
+ # at_fork uses it (instead of the child pid) to name the subprocess's
22
+ # result, keeping that name identical across runs. See issue #1171.
23
+ SimpleCov.next_subprocess_serial! if active
24
+ pid = super
25
+ SimpleCov.at_fork.call(::Process.pid) if pid.zero? && active
26
+ pid
27
+ end
18
28
  end
19
29
  end
30
+
31
+ Process.singleton_class.prepend(SimpleCov::ProcessForkHook)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "bundler_filter" do
4
- add_filter "/vendor/bundle/"
4
+ skip "/vendor/bundle/"
5
5
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "hidden_filter" do
4
- add_filter %r{^/\..*}
4
+ skip(/\A\..*/)
5
5
  end
@@ -3,16 +3,30 @@
3
3
  SimpleCov.profiles.define "rails" do
4
4
  load_profile "test_frameworks"
5
5
 
6
- add_filter %r{^/config/}
7
- add_filter %r{^/db/}
6
+ skip %r{\Aconfig/}
7
+ skip %r{\Adb/}
8
8
 
9
- add_group "Controllers", "app/controllers"
10
- add_group "Channels", "app/channels"
11
- add_group "Models", "app/models"
12
- add_group "Mailers", "app/mailers"
13
- add_group "Helpers", "app/helpers"
14
- add_group "Jobs", %w[app/jobs app/workers]
15
- add_group "Libraries", "lib/"
9
+ group "Controllers", "app/controllers"
10
+ group "Channels", "app/channels"
11
+ group "Models", "app/models"
12
+ group "Mailers", "app/mailers"
13
+ group "Helpers", "app/helpers"
14
+ group "Jobs", %w[app/jobs app/workers]
15
+ group "Libraries", "lib/"
16
16
 
17
- track_files "{app,lib}/**/*.rb"
17
+ # Preserve the legacy `track_files` semantics (additive disk-discovery
18
+ # without restricting the report's universe): write the ivar directly
19
+ # so loading the profile doesn't emit the public-API deprecation. Users
20
+ # migrating their own configs should prefer `cover "{app,lib}/**/*.rb"`,
21
+ # which both injects unloaded files and scopes the report to the match
22
+ # set — usually the intended behavior for a Rails project.
23
+ @tracked_files = "{app,lib}/**/*.rb"
24
+
25
+ # `parallelize(workers: ...)` forks worker processes that each run a
26
+ # slice of the suite. Without subprocess support, the workers' coverage
27
+ # is dropped on the floor and the parent records 0% for everything they
28
+ # touched. Hooking `Process._fork` makes each worker re-call
29
+ # `SimpleCov.start` with a unique command_name so the resultsets merge
30
+ # correctly.
31
+ merge_subprocesses true
18
32
  end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "root_filter" do
4
- # Exclude all files outside of simplecov root
5
- root_filter = nil
6
- add_filter do |src|
7
- root_filter ||= /\A#{Regexp.escape(SimpleCov.root + File::SEPARATOR)}/io
8
- src.filename !~ root_filter
4
+ # Exclude all files outside of simplecov root. Shares the regex with
5
+ # SimpleCov::UselessResultsRemover so the root-prefix logic lives in one
6
+ # place; this profile is the user-facing entry point that tools like
7
+ # `SimpleCov.filtered` apply.
8
+ skip do |src|
9
+ src.filename !~ SimpleCov::UselessResultsRemover.root_regex
9
10
  end
10
11
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A "no shortfalls allowed" profile: every coverage criterion is
4
+ # enabled and held to 100%. Per
5
+ # https://github.com/simplecov-ruby/simplecov/issues/1061, this lives as
6
+ # an opt-in profile rather than the default — most projects can't
7
+ # realistically start at 100% on day one — so a team that has paid down
8
+ # the debt can flip the strict switch without re-typing the threshold
9
+ # trio every time.
10
+ #
11
+ # JRuby gracefully degrades: `enable_coverage :branch` / `:method` are
12
+ # accepted (they just add to the configured criteria set), but the
13
+ # runtime can't actually measure them, so the corresponding stats never
14
+ # materialize. `CoverageViolations` skips threshold lookups for
15
+ # criteria not in the stats, so the `:branch` / `:method` clauses below
16
+ # silently no-op on JRuby and only `:line` is enforced.
17
+ #
18
+ # `:eval` widens the universe of code held to 100% to include strings
19
+ # passed through `Kernel#eval` (typically ERB templates, when the user
20
+ # sets `ERB#filename=`). Guarded on `coverage_for_eval_supported?` so
21
+ # the profile stays quiet on Ruby < 3.2, where `enable_coverage :eval`
22
+ # would otherwise warn about the missing runtime support every time
23
+ # the profile loads.
24
+ SimpleCov.profiles.define "strict" do
25
+ enable_coverage :branch
26
+ enable_coverage :method
27
+ # simplecov:disable branch — dogfood runs on Ruby >= 3.2 only, so
28
+ # the else arm (eval coverage not supported) is unreachable from CI.
29
+ enable_coverage :eval if coverage_for_eval_supported?
30
+ # simplecov:enable
31
+ minimum_coverage line: 100, branch: 100, method: 100
32
+ end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  SimpleCov.profiles.define "test_frameworks" do
4
- add_filter "/test/"
5
- add_filter "/features/"
6
- add_filter "/spec/"
7
- add_filter "/autotest/"
4
+ skip %r{\A(test|features|spec|autotest)/}
8
5
  end
@@ -17,7 +17,7 @@ module SimpleCov
17
17
  #
18
18
  def define(name, &blk)
19
19
  name = name.to_sym
20
- raise "SimpleCov Profile '#{name}' is already defined" unless self[name].nil?
20
+ raise SimpleCov::ConfigurationError, "SimpleCov Profile '#{name}' is already defined" unless self[name].nil?
21
21
 
22
22
  self[name] = blk
23
23
  end
@@ -26,10 +26,39 @@ module SimpleCov
26
26
  # Applies the profile of given name on SimpleCov.configure
27
27
  #
28
28
  def load(name)
29
+ SimpleCov.configure(&fetch_proc(name))
30
+ end
31
+
32
+ #
33
+ # Returns the proc registered for the given profile name, autoloading
34
+ # bundled or plugin-gem profiles on first lookup. Raises if the profile
35
+ # cannot be located.
36
+ #
37
+ # Lookup order:
38
+ # 1. already registered via #define
39
+ # 2. require "simplecov/profiles/<name>" (bundled profiles)
40
+ # 3. require "simplecov-profile-<name>" (third-party plugin gems)
41
+ #
42
+ def fetch_proc(name)
29
43
  name = name.to_sym
30
- raise "Could not find SimpleCov Profile called '#{name}'" unless key?(name)
44
+ autoload_profile(name) unless key?(name)
45
+ return self[name] if key?(name)
46
+
47
+ raise SimpleCov::ConfigurationError, "Could not find SimpleCov Profile called '#{name}'"
48
+ end
49
+
50
+ private
31
51
 
32
- SimpleCov.configure(&self[name])
52
+ def autoload_profile(name)
53
+ require "simplecov/profiles/#{name}"
54
+ rescue LoadError
55
+ begin
56
+ # simplecov:disable — third-party gem fallback (no such gem in test env)
57
+ require "simplecov-profile-#{name}"
58
+ # simplecov:enable
59
+ rescue LoadError
60
+ # fall through; #fetch_proc raises the user-facing error
61
+ end
33
62
  end
34
63
  end
35
64
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Result
5
+ # When a resultset references source files that don't exist on the local
6
+ # filesystem they're silently dropped — which produces an empty `0 / 0
7
+ # (100.00%)` report that looks like success but isn't. Emit a single
8
+ # warning summarizing the drop and, when every entry was lost, point at
9
+ # the typical cause (`SimpleCov.collate` invoked from a machine or path
10
+ # different from where the resultsets were generated). See #980.
11
+ class MissingSourceFilesReporter
12
+ def initialize(missing_paths, input_size:, every_entry_dropped:)
13
+ @missing_paths = missing_paths
14
+ @input_size = input_size
15
+ @every_entry_dropped = every_entry_dropped
16
+ end
17
+
18
+ def warn!
19
+ warn SimpleCov::Color.colorize(message, :yellow)
20
+ end
21
+
22
+ def message
23
+ @every_entry_dropped ? all_missing_warning : partial_missing_warning
24
+ end
25
+
26
+ private
27
+
28
+ def all_missing_warning
29
+ "SimpleCov dropped all #{@missing_paths.size} source file(s) from the result — " \
30
+ "none of the paths in the resultset exist on this filesystem: " \
31
+ "#{summary}. If you're running `SimpleCov.collate`, the source " \
32
+ "files must be available at the same absolute paths as when the individual resultsets " \
33
+ "were generated."
34
+ end
35
+
36
+ def partial_missing_warning
37
+ "SimpleCov dropped #{@missing_paths.size} source file(s) from the result because " \
38
+ "they don't exist on this filesystem: #{summary}. They were " \
39
+ "tracked in the resultset but have since moved or been removed."
40
+ end
41
+
42
+ def summary
43
+ sample = @missing_paths.first(5).join(", ")
44
+ remaining = @missing_paths.size - 5
45
+ remaining.positive? ? "#{sample} (+#{remaining} more)" : sample
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class Result
5
+ # Constructs `SimpleCov::SourceFile` instances from a raw coverage
6
+ # hash, sorts them by filename, and surfaces filenames whose source
7
+ # is no longer present on disk so the caller can warn about the
8
+ # silent drop (see #980).
9
+ class SourceFileBuilder
10
+ attr_reader :missing_source_files
11
+
12
+ def initialize(original_result, not_loaded_files:)
13
+ @original_result = original_result
14
+ @not_loaded_files = not_loaded_files
15
+ @missing_source_files = []
16
+ end
17
+
18
+ def call
19
+ SimpleCov::FileList.new(
20
+ @original_result
21
+ .filter_map { |filename, coverage| build_source_file(filename, coverage) }
22
+ .sort_by(&:filename)
23
+ )
24
+ end
25
+
26
+ private
27
+
28
+ def build_source_file(filename, coverage)
29
+ unless File.file?(filename)
30
+ @missing_source_files << filename
31
+ return
32
+ end
33
+
34
+ SimpleCov::SourceFile.new(
35
+ filename,
36
+ stringify_outer_keys(coverage),
37
+ loaded: !@not_loaded_files.include?(filename)
38
+ )
39
+ end
40
+
41
+ # `Coverage.result` returns symbol keys (`:lines`, `:branches`,
42
+ # `:methods`); resultsets loaded from disk are already string-keyed.
43
+ # SourceFile reads with strings, and handles both Array and
44
+ # stringified-Array branch/method keys via `restore_ruby_data_structure`,
45
+ # so only the outer hash needs normalizing.
46
+ def stringify_outer_keys(coverage)
47
+ coverage.transform_keys(&:to_s)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "digest/sha1"
4
4
  require "forwardable"
5
+ require_relative "result/missing_source_files_reporter"
6
+ require_relative "result/source_file_builder"
5
7
 
6
8
  module SimpleCov
7
9
  #
@@ -10,9 +12,11 @@ module SimpleCov
10
12
  #
11
13
  class Result
12
14
  extend Forwardable
15
+
13
16
  # Returns the original Coverage.result used for this instance of SimpleCov::Result
14
17
  attr_reader :original_result
15
- # Returns all files that are applicable to this result (sans filters!) as instances of SimpleCov::SourceFile. Aliased as :source_files
18
+ # Returns all files that are applicable to this result (sans filters!) as instances of
19
+ # SimpleCov::SourceFile. Aliased as :source_files
16
20
  attr_reader :files
17
21
  alias source_files files
18
22
  # Explicitly set the Time this result has been created
@@ -20,20 +24,47 @@ module SimpleCov
20
24
  # Explicitly set the command name that was used for this coverage result. Defaults to SimpleCov.command_name
21
25
  attr_writer :command_name
22
26
 
23
- def_delegators :files, :covered_percent, :covered_percentages, :least_covered_file, :covered_strength, :covered_lines, :missed_lines, :total_branches, :covered_branches, :missed_branches, :coverage_statistics, :coverage_statistics_by_file
27
+ def_delegators :files, :covered_percent, :covered_percentages, :least_covered_file, :covered_strength,
28
+ :covered_lines, :missed_lines,
29
+ :total_branches, :covered_branches, :missed_branches,
30
+ :total_methods, :covered_methods, :missed_methods,
31
+ :coverage_statistics, :coverage_statistics_by_file
24
32
  def_delegator :files, :lines_of_code, :total_lines
25
33
 
34
+ # Bundles the filter and grouping configuration a Result applies to its
35
+ # source files after building them. Each field defaults to the SimpleCov
36
+ # singleton's configuration, so ordinary callers never construct one;
37
+ # tests pass a custom instance to opt out of (or extend) the project's
38
+ # filters or groups (e.g. `filters: []` to keep every file). Grouping the
39
+ # three together keeps Result#initialize's parameter list small.
40
+ class FilterConfig
41
+ attr_reader :filters, :cover_filters, :groups
42
+
43
+ def initialize(filters: SimpleCov.filters, cover_filters: SimpleCov.cover_filters, groups: SimpleCov.groups)
44
+ @filters = filters
45
+ @cover_filters = cover_filters
46
+ @groups = groups
47
+ end
48
+ end
49
+
26
50
  # Initialize a new SimpleCov::Result from given Coverage.result (a Hash of filenames each containing an array of
27
- # coverage data)
28
- def initialize(original_result, command_name: nil, created_at: nil)
29
- result = original_result
30
- @original_result = result.freeze
51
+ # coverage data).
52
+ #
53
+ # `filter_config` defaults to the SimpleCov singleton's filter / group
54
+ # configuration so existing call sites are unchanged. Pass a custom
55
+ # FilterConfig to opt out — useful for tests that build synthetic Results
56
+ # and don't want the project's filters or groups applied.
57
+ def initialize(original_result, command_name: nil, created_at: nil, not_loaded_files: Set.new,
58
+ filter_config: FilterConfig.new)
59
+ @original_result = original_result.freeze
31
60
  @command_name = command_name
32
61
  @created_at = created_at
33
- @files = SimpleCov::FileList.new(result.map do |filename, coverage|
34
- SimpleCov::SourceFile.new(filename, JSON.parse(JSON.dump(coverage))) if File.file?(filename)
35
- end.compact.sort_by(&:filename))
36
- filter!
62
+ @groups_config = filter_config.groups
63
+ builder = SourceFileBuilder.new(original_result, not_loaded_files: not_loaded_files)
64
+ @files = builder.call
65
+ warn_about_missing_source_files(builder.missing_source_files, original_result.size)
66
+ apply_cover_filters!(filter_config.cover_filters)
67
+ apply_filters!(filter_config.filters)
37
68
  end
38
69
 
39
70
  # Returns all filenames for source files contained in this result
@@ -41,14 +72,37 @@ module SimpleCov
41
72
  files.map(&:filename)
42
73
  end
43
74
 
44
- # Returns a Hash of groups for this result. Define groups using SimpleCov.add_group 'Models', 'app/models'
75
+ # Returns the SimpleCov::SourceFile for the given path, or nil if no
76
+ # matching file is in this result. The path is resolved against
77
+ # SimpleCov.root, so callers can pass either an absolute path or a
78
+ # project-relative one.
79
+ def source_file_for(path)
80
+ target = File.expand_path(path, SimpleCov.root)
81
+ files.find { |file| file.filename == target }
82
+ end
83
+
84
+ # Returns the {line:/branch:/method:} coverage_statistics hash for the
85
+ # given file path, or nil if no matching source file is in this
86
+ # result. See SimpleCov::Result#source_file_for for path resolution.
87
+ def coverage_for(path)
88
+ source_file_for(path)&.coverage_statistics
89
+ end
90
+
91
+ # Returns a Hash of groups for this result. Define groups using SimpleCov.group 'Models', 'app/models'
45
92
  def groups
46
- @groups ||= SimpleCov.grouped(files)
93
+ @groups ||= SimpleCov.grouped(files, groups: @groups_config)
47
94
  end
48
95
 
49
- # Applies the configured SimpleCov.formatter on this result
96
+ # Applies the configured SimpleCov.formatter on this result. Returns
97
+ # nil if formatting has been opted out of (`SimpleCov.formatter false`
98
+ # / `SimpleCov.formatters []`) — the cheap path for non-final
99
+ # processes in a parallel CI run, which only need their
100
+ # `.resultset.json` on disk. See #964.
50
101
  def format!
51
- SimpleCov.formatter.new.format(self)
102
+ formatter = SimpleCov.formatter
103
+ return nil if formatter.nil?
104
+
105
+ formatter.new.format(self)
52
106
  end
53
107
 
54
108
  # Defines when this result has been created. Defaults to Time.now
@@ -81,14 +135,38 @@ module SimpleCov
81
135
 
82
136
  private
83
137
 
138
+ def warn_about_missing_source_files(missing, input_size)
139
+ return if missing.empty?
140
+
141
+ MissingSourceFilesReporter.new(
142
+ missing,
143
+ input_size: input_size,
144
+ every_entry_dropped: @files.empty? && missing.size == input_size
145
+ ).warn!
146
+ end
147
+
84
148
  def coverage
85
- keys = original_result.keys & filenames
86
- Hash[keys.zip(original_result.values_at(*keys))]
149
+ original_result.slice(*filenames)
87
150
  end
88
151
 
89
- # Applies all configured SimpleCov filters on this result's source files
90
- def filter!
91
- @files = SimpleCov.filtered(files)
152
+ # Applies the given filter chain to `@files`, dropping each source
153
+ # file that any filter matches.
154
+ def apply_filters!(filters)
155
+ filters.each do |filter|
156
+ @files = SimpleCov::FileList.new(@files.reject { |source_file| filter.matches?(source_file) })
157
+ end
158
+ end
159
+
160
+ # When any `cover` matcher is configured, restrict `@files` to source
161
+ # files matching at least one of them. With no cover matchers configured
162
+ # this is a no-op, preserving the historical "everything required, then
163
+ # filtered" universe.
164
+ def apply_cover_filters!(cover_filters)
165
+ return if cover_filters.empty?
166
+
167
+ @files = SimpleCov::FileList.new(
168
+ @files.select { |source_file| cover_filters.any? { |filter| filter.matches?(source_file) } }
169
+ )
92
170
  end
93
171
  end
94
172
  end
@@ -18,13 +18,75 @@ module SimpleCov
18
18
  def adapt
19
19
  return unless result
20
20
 
21
- result.each_with_object({}) do |(file_name, cover_statistic), adapted_result|
22
- if cover_statistic.is_a?(Array)
23
- adapted_result.merge!(file_name => {"lines" => cover_statistic})
24
- else
25
- adapted_result.merge!(file_name => cover_statistic)
26
- end
21
+ result.to_h do |file_name, cover_statistic|
22
+ [file_name, adapt_one(file_name, cover_statistic)]
27
23
  end
28
24
  end
25
+
26
+ private
27
+
28
+ # Pre-0.18 resultsets pointed each filename straight at a line-coverage
29
+ # array; everything since uses the `{lines:, branches:, methods:}`
30
+ # shape. Newer entries also need their methods table massaged before
31
+ # downstream code merges across processes.
32
+ def adapt_one(file_name, cover_statistic)
33
+ return {"lines" => cover_statistic} if cover_statistic.is_a?(Array)
34
+
35
+ adapt_oneshot_lines_if_needed(file_name, cover_statistic)
36
+ normalize_method_keys(cover_statistic)
37
+ cover_statistic
38
+ end
39
+
40
+ # Normalize memory addresses in method coverage keys so that results
41
+ # from different processes can be merged. Anonymous class names like
42
+ # "#<Class:0x00007ff19ab24790>" get inconsistent addresses across runs.
43
+ # Address widths vary by runtime (32-bit hosts: 8 hex chars; 64-bit
44
+ # CRuby: 16; some JVM/TruffleRuby formats may differ), so match any
45
+ # length of hex digits and collapse to a single placeholder.
46
+ ADDRESS_PATTERN = /0x\h+/
47
+ private_constant :ADDRESS_PATTERN
48
+
49
+ ADDRESS_PLACEHOLDER = "0x0"
50
+ private_constant :ADDRESS_PLACEHOLDER
51
+
52
+ # Strip the `#<Class:Foo>` wrapper Ruby's Coverage adds to singleton-class
53
+ # method keys. `module_function` and class methods get recorded both as
54
+ # singleton (`[#<Class:Foo>, :m, …]`) and instance/module (`[Foo, :m, …]`)
55
+ # entries pointing at the same source location; only one of the two is
56
+ # ever reachable at runtime, so we merge them. Only applies to named
57
+ # constants — anonymous-class addresses like `#<Class:0x0>` are left
58
+ # alone (handled by ADDRESS_PATTERN above).
59
+ SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
60
+ private_constant :SINGLETON_WRAPPER_PATTERN
61
+
62
+ def normalize_method_keys(cover_statistic)
63
+ methods = cover_statistic[:methods]
64
+ return unless methods
65
+
66
+ cover_statistic[:methods] = methods.each_with_object({}) do |(key, count), normalized|
67
+ normalized_key = key.dup
68
+ normalized_key[0] = key[0].to_s
69
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
70
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
71
+ # Keys may collide after normalization (anonymous classes sharing a
72
+ # method name, or singleton + instance forms of a module_function method).
73
+ normalized[normalized_key] = normalized.fetch(normalized_key, 0) + count
74
+ end
75
+ end
76
+
77
+ def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
78
+ return unless cover_statistic.key?(:oneshot_lines)
79
+
80
+ oneshot_lines = cover_statistic.delete(:oneshot_lines)
81
+ line_stub = build_line_stub(file_name, oneshot_lines)
82
+ oneshot_lines.each { |covered_line| line_stub[covered_line - 1] = 1 }
83
+ cover_statistic[:lines] = line_stub
84
+ end
85
+
86
+ def build_line_stub(file_name, oneshot_lines)
87
+ Coverage.line_stub(file_name)
88
+ rescue Errno::ENOENT, SyntaxError
89
+ Array.new(oneshot_lines.max || 0, nil)
90
+ end
29
91
  end
30
92
  end