simplecov 0.22.0 → 1.1.1

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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "color"
5
+ require_relative "cli/dotfile"
6
+ require_relative "cli/clean"
7
+ require_relative "cli/coverage_file"
8
+ require_relative "cli/coverage"
9
+ require_relative "cli/diff"
10
+ require_relative "cli/merge"
11
+ require_relative "cli/open"
12
+ require_relative "cli/report"
13
+ require_relative "cli/run"
14
+ require_relative "cli/serve"
15
+ require_relative "cli/uncovered"
16
+
17
+ module SimpleCov
18
+ # Lightweight command-line front-end. `run` dispatches a subcommand
19
+ # (`coverage`, `report`, `uncovered`, `merge`, `diff`, `open`, etc.) —
20
+ # see the `usage` text below for the full list, or run `simplecov help`.
21
+ #
22
+ # Read-only subcommands consume JSONFormatter output (`coverage.json`),
23
+ # which the bundled HTMLFormatter already drops alongside the HTML, so
24
+ # no runtime hooking is needed for those. Default paths follow the
25
+ # project's `.simplecov` `SimpleCov.coverage_dir` setting when one is
26
+ # present, so a project that writes its report somewhere other than
27
+ # `coverage/` doesn't have to pass `--input` / `--report` every
28
+ # invocation.
29
+ module CLI
30
+ COMMANDS = {
31
+ "coverage" => Coverage,
32
+ "run" => Run,
33
+ "open" => Open,
34
+ "report" => Report,
35
+ "uncovered" => Uncovered,
36
+ "merge" => Merge,
37
+ "diff" => Diff,
38
+ "serve" => Serve,
39
+ "clean" => Clean
40
+ }.freeze
41
+
42
+ module_function
43
+
44
+ # Resolved once per process. Walks up from cwd looking for a
45
+ # `.simplecov`; if present, the file is loaded with
46
+ # `SimpleCov.start` neutered so it can't trigger coverage tracking
47
+ # or an at_exit hook just because we asked it for a config value.
48
+ def coverage_dir
49
+ @coverage_dir ||= Dotfile.coverage_dir
50
+ end
51
+
52
+ def default_input
53
+ File.join(coverage_dir, "coverage.json")
54
+ end
55
+
56
+ # Resolve "should this subcommand colorize?" once per invocation.
57
+ # `--no-color` (opts[:no_color]) is the per-invocation kill-switch;
58
+ # otherwise we defer to `SimpleCov::Color.enabled?`, which honors
59
+ # `NO_COLOR` / `FORCE_COLOR` and falls back to `stream.tty?`.
60
+ def color_enabled?(opts, stream)
61
+ return false if opts[:no_color]
62
+
63
+ SimpleCov::Color.enabled?(stream)
64
+ end
65
+
66
+ def default_report
67
+ File.join(coverage_dir, "index.html")
68
+ end
69
+
70
+ def default_resultset
71
+ File.join(coverage_dir, ".resultset.json")
72
+ end
73
+
74
+ # Returns a process exit status (0 on success, non-zero on error).
75
+ def run(argv, stdout: $stdout, stderr: $stderr)
76
+ command, *rest = argv
77
+ handler = COMMANDS[command]
78
+ return dispatch(handler, command, rest, stdout: stdout, stderr: stderr) if handler
79
+ return stdout.puts(usage) || 0 if [nil, "help", "--help", "-h"].include?(command)
80
+
81
+ stderr.puts("simplecov: unknown command #{command.inspect}", usage)
82
+ 1
83
+ end
84
+
85
+ # One rescue covers every subcommand's OptionParser, so a typo'd
86
+ # flag or a malformed typed argument becomes a one-line error and
87
+ # exit status 1 instead of an unhandled-exception backtrace.
88
+ def dispatch(handler, command, rest, stdout:, stderr:)
89
+ handler.run(rest, stdout: stdout, stderr: stderr)
90
+ rescue OptionParser::ParseError => e
91
+ stderr.puts("simplecov #{command}: #{e.message} (run `simplecov help` for usage)")
92
+ 1
93
+ end
94
+
95
+ def usage
96
+ <<~USAGE
97
+ Usage: simplecov <command> [options]
98
+
99
+ Commands:
100
+ run <command...> Execute <command> with simplecov pre-loaded
101
+ (so a coverage report is generated even
102
+ when the project has no test_helper hook)
103
+ coverage <path> Print coverage stats for the given file
104
+ report Print the overall summary and group totals
105
+ uncovered List the lowest-coverage files
106
+ merge <files...> Merge multiple .resultset.json files
107
+ diff <baseline> Show per-file coverage delta vs baseline
108
+ open Open the HTML report in the default browser
109
+ serve Serve the coverage report over HTTP
110
+ clean Remove the coverage report directory
111
+ help Show this message
112
+
113
+ Default paths follow SimpleCov.coverage_dir from a project's
114
+ `.simplecov` when one is present (#{coverage_dir} for this run).
115
+
116
+ coverage / report / uncovered / diff options:
117
+ --input PATH Read from PATH instead of #{default_input}
118
+ --no-color Disable colorized percentages
119
+ (also honors NO_COLOR / FORCE_COLOR env)
120
+
121
+ coverage options:
122
+ --json Print the file's JSON entry verbatim
123
+
124
+ report options:
125
+ --json Emit totals and group sections as JSON
126
+
127
+ uncovered options:
128
+ --threshold N Only show files below N% coverage
129
+ --top N Show at most N files (default: 10)
130
+ --criterion C line, branch, or method (default: line)
131
+ --json Emit results as a JSON array (for CI)
132
+
133
+ merge options:
134
+ --output PATH Write merged resultset to PATH
135
+ (default: #{default_resultset})
136
+ --honor-timeout Drop entries older than merge_timeout
137
+ --dry-run Print what would be written without
138
+ actually writing
139
+ -q, --quiet Suppress the success status line
140
+
141
+ diff options:
142
+ --fail-on-drop Exit non-zero when any file's coverage
143
+ dropped vs the baseline (deleted
144
+ files don't count as drops)
145
+ --json Emit results as a JSON array (for CI)
146
+ --threshold N Only show files whose absolute delta
147
+ in any criterion is at least N%
148
+
149
+ open options:
150
+ --report PATH Open PATH instead of #{default_report}
151
+
152
+ serve options:
153
+ --port N Bind to port N (default: random open port)
154
+ --host HOST Bind to HOST (default: 127.0.0.1)
155
+
156
+ clean options:
157
+ --dry-run Print what would be removed without
158
+ deleting anything
159
+ -q, --quiet Suppress status lines
160
+ USAGE
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # ANSI colorization for stderr diagnostics. Thresholds mirror the
5
+ # HTML formatter (>= 90 green, >= 75 yellow, otherwise red) so a
6
+ # team's mental model of "what's the cutoff" is the same whether
7
+ # they're reading the terminal output or the HTML report.
8
+ #
9
+ # Color precedence, highest first:
10
+ #
11
+ # - `SimpleCov.color = true` / `false` (programmatic override, wins
12
+ # over everything; default is `:auto` which falls through)
13
+ # - `NO_COLOR` env var (any non-empty value) → off (see no-color.org)
14
+ # - `FORCE_COLOR` env var (any non-empty value) → on
15
+ # - `stream.tty?` fallback
16
+ #
17
+ # `NO_COLOR` wins over `FORCE_COLOR` if both env vars are set.
18
+ module Color
19
+ GREEN_THRESHOLD = 90
20
+ YELLOW_THRESHOLD = 75
21
+
22
+ ANSI = {
23
+ red: "\e[31m",
24
+ yellow: "\e[33m",
25
+ green: "\e[32m",
26
+ reset: "\e[0m"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # `stream` is the IO that the colorized text is destined for. The
32
+ # formatter writes to stderr, so that's the default. CLI subcommands
33
+ # that print to stdout should pass `$stdout` so a redirected pipe
34
+ # doesn't get ANSI sequences. See the module-level comment for
35
+ # precedence.
36
+ def enabled?(stream = $stderr)
37
+ # `SimpleCov.color` only exists once the full library is loaded.
38
+ # The standalone CLI (`exe/simplecov`) loads `simplecov/color`
39
+ # without `simplecov` itself to stay lightweight, so treat a
40
+ # missing config the same as its `:auto` default: fall through to
41
+ # the env vars and tty check below.
42
+ config = SimpleCov.color if SimpleCov.respond_to?(:color)
43
+ return config if [true, false].include?(config)
44
+ return false if env_set?("NO_COLOR")
45
+ return true if env_set?("FORCE_COLOR")
46
+
47
+ stream.tty?
48
+ rescue IOError
49
+ # A parallel runner can close a worker's stdio before its at_exit
50
+ # hooks run (rspec-conductor does). A closed stream is not a tty.
51
+ false
52
+ end
53
+
54
+ def for_percent(percent)
55
+ return :green if percent >= GREEN_THRESHOLD
56
+ return :yellow if percent >= YELLOW_THRESHOLD
57
+
58
+ :red
59
+ end
60
+
61
+ # Wrap `text` in the ANSI sequence for `color` (a key of ANSI).
62
+ # Returns the bare text if color is disabled. The `enabled:`
63
+ # keyword lets callers (e.g., CLI subcommands honoring `--no-color`)
64
+ # override the auto-detection without touching env vars.
65
+ def colorize(text, color, enabled: enabled?)
66
+ return text unless enabled
67
+
68
+ "#{ANSI.fetch(color)}#{text}#{ANSI.fetch(:reset)}"
69
+ end
70
+
71
+ # Render `percent` as a fixed "NN.NN%" string colored by which
72
+ # threshold band it falls into. Callers that want a different
73
+ # rendering of the number can pass the pre-rendered `text`.
74
+ def colorize_percent(percent, text = nil, enabled: enabled?)
75
+ colorize(text || format("%.2f%%", percent), for_percent(percent), enabled: enabled)
76
+ end
77
+
78
+ def env_set?(name)
79
+ value = ENV.fetch(name, nil)
80
+ value && !value.empty?
81
+ end
82
+ end
83
+ end
@@ -1,31 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../source_file/ruby_data_parser"
4
+ require_relative "identity_interner"
5
+ require_relative "interned_counts"
6
+
3
7
  module SimpleCov
4
8
  module Combine
5
9
  #
6
10
  # Combine different branch coverage results on single file.
7
11
  #
8
- # Should be called through `SimpleCov.combine`.
12
+ # Should be called through `CoverageAccumulator`.
9
13
  module BranchesCombiner
10
14
  module_function
11
15
 
12
16
  #
13
- # Return merged branches or the existed brach if other is missing.
17
+ # Return merged branches or the existed branch if other is missing.
14
18
  #
15
19
  # Branches inside files are always same if they exist, the difference only in coverage count.
16
20
  # Branch coverage report for any conditional case is built from hash, it's key is a condition and
17
21
  # it's body is a hash << keys from condition and value is coverage rate >>.
18
- # ex: branches =>{ [:if, 3, 8, 6, 8, 36] => {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36]=>2}, other conditions...}
22
+ # ex: branches => { [:if, 3, 8, 6, 8, 36] =>
23
+ # {[:then, 4, 8, 6, 8, 12] => 1, [:else, 5, 8, 6, 8, 36] => 2}, ... }
19
24
  # We create copy of result and update it values depending on the combined branches coverage values.
20
25
  #
21
26
  # @return [Hash]
22
27
  #
23
28
  def combine(coverage_a, coverage_b)
24
- coverage_a.merge(coverage_b) do |_condition, branches_inside_a, branches_inside_b|
25
- branches_inside_a.merge(branches_inside_b) do |_branch, a_count, b_count|
26
- a_count + b_count
27
- end
29
+ merged = absorb({}, coverage_a) #: Hash[untyped, [untyped, Hash[untyped, untyped]]]
30
+ materialize(absorb(merged, coverage_b))
31
+ end
32
+
33
+ # Folds `coverage` into `target`, an interned table keyed by condition
34
+ # identity. Kept interned (rather than turned back into tuple keys per
35
+ # merge) so a fold over N resultsets interns each condition once
36
+ # instead of re-interning the whole accumulated table N times.
37
+ #
38
+ # @return [Hash] `target`
39
+ def absorb(target, coverage)
40
+ return target unless coverage
41
+
42
+ coverage.each do |condition, branches_inside|
43
+ entry = target[identities[condition]] ||= new_condition(condition)
44
+ InternedCounts.absorb_counts(entry[1], branches_inside, identities)
28
45
  end
46
+
47
+ target
48
+ end
49
+
50
+ # Split out so the empty arm table has somewhere to carry its type
51
+ # annotation. Only allocated the first time a condition is seen.
52
+ def new_condition(condition)
53
+ arms = {} #: Hash[untyped, untyped]
54
+ [condition, arms]
55
+ end
56
+
57
+ # Turns an interned table back into the tuple-keyed hash the rest of
58
+ # SimpleCov reads. Done once, at the end of a fold.
59
+ #
60
+ # @return [Hash]
61
+ def materialize(target)
62
+ target.values.to_h { |condition, branches| [condition, branches.values.to_h] }
63
+ end
64
+
65
+ # Bounded by the project's branch count, like `RubyDataParser`'s parse
66
+ # cache.
67
+ def identities
68
+ @identities ||= IdentityInterner.build { |tuple| tuple_identity(tuple) }
69
+ end
70
+
71
+ # Branches match on source span, whatever ids the recording processes
72
+ # handed them (issue #1233).
73
+ def tuple_identity(tuple)
74
+ type, _id, start_line, start_column, end_line, end_column = SourceFile::RubyDataParser.call(tuple)
75
+ [type, start_line, start_column, end_line, end_column].freeze
29
76
  end
30
77
  end
31
78
  end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "branches_combiner"
4
+ require_relative "lines_combiner"
5
+ require_relative "methods_combiner"
6
+
7
+ module SimpleCov
8
+ module Combine
9
+ #
10
+ # Folds any number of resultsets' coverage into one, absorbing them one
11
+ # at a time.
12
+ #
13
+ # This replaces a pairwise `reduce` over the resultsets, which rebuilt
14
+ # the entire accumulated structure on every one of its N-1 steps: a
15
+ # fresh outer file hash, a fresh lines array for every file, and a fresh
16
+ # branch / method table for every file whose keys were re-interned from
17
+ # their tuples each time. On a 160-worker run over ~1,800 files that is
18
+ # ~290,000 whole-file rebuilds to produce ~1,800 files of output.
19
+ #
20
+ # The accumulated side is private to the fold, so it can be updated in
21
+ # place instead, and the interned tables only have to be turned back into
22
+ # tuple-keyed hashes once, at the end.
23
+ #
24
+ # Resultsets are absorbed one at a time rather than taken as a list, so
25
+ # `ResultMerger.merge_results` can keep reading and discarding them one
26
+ # file at a time — reading 100s of CI jobs' worth of coverage into memory
27
+ # at once is what that method is careful not to do.
28
+ #
29
+ class CoverageAccumulator
30
+ # A file some process actually loaded has at least one executed line;
31
+ # a simulated (never-loaded) file's lines are all `nil` or `0`. This is
32
+ # the signal the merge reconciles synthesized tuples on, and the one
33
+ # `ResultMerger` re-derives a merged result's not-loaded set from, so it
34
+ # lives here rather than being spelled out at each site.
35
+ #
36
+ # `Array()` plus the `Numeric` test rather than a bare
37
+ # `any?(&:positive?)` because this reads straight off a parsed
38
+ # resultset, which is external input: a file written by another
39
+ # SimpleCov version, or hand-edited, can carry anything under
40
+ # "lines" — a Hash coerces to pairs, a String to itself. Keeping a
41
+ # malformed entry to a wrong answer instead of a NoMethodError out
42
+ # of the middle of a merge is the point.
43
+ def self.executed?(lines)
44
+ counts = Array(lines) #: Array[untyped]
45
+ counts.any? { |count| count.is_a?(Numeric) && count.positive? }
46
+ end
47
+
48
+ #
49
+ # Folds `[command_names, coverage]` pairs into one merged coverage.
50
+ #
51
+ # `pairs` is only ever iterated, so a caller that reads resultsets off
52
+ # disk can hand in a lazy enumerable and never hold more than one in
53
+ # memory — which is what `ResultMerger.merge_results` is careful about.
54
+ #
55
+ # @return [Array] the concatenated command names and the merged coverage
56
+ #
57
+ def self.fold(pairs)
58
+ accumulator = new
59
+ command_names = [] #: Array[String]
60
+
61
+ pairs.each do |names, coverage|
62
+ command_names.concat(names)
63
+ accumulator.absorb(coverage)
64
+ end
65
+
66
+ [command_names, accumulator.result]
67
+ end
68
+
69
+ def initialize
70
+ @files = {} #: Hash[String, untyped]
71
+ @absorbed = false
72
+ # Whether a criterion is enabled can't change mid-fold, so read it once
73
+ # here rather than once per file. `branch_coverage?` reaches
74
+ # `Coverage.supported?`, and a large parallel run merges thousands of
75
+ # files.
76
+ @branch_coverage = SimpleCov.branch_coverage?
77
+ @method_coverage = SimpleCov.method_coverage?
78
+ end
79
+
80
+ #
81
+ # Folds one resultset's coverage (filename => per-file coverage) into
82
+ # the accumulator. A `nil` coverage is ignored — a resultset that
83
+ # carried nothing contributes nothing.
84
+ #
85
+ # @return [CoverageAccumulator] self, so absorbs can be chained
86
+ #
87
+ def absorb(coverage)
88
+ return self unless coverage
89
+
90
+ @absorbed = true
91
+ coverage.each do |filename, file_coverage|
92
+ @files[filename] = merge_file(@files[filename], file_coverage)
93
+ end
94
+
95
+ self
96
+ end
97
+
98
+ #
99
+ # The merged coverage, or `nil` when nothing was absorbed at all — the
100
+ # caller needs to tell "no results" apart from "results that cover
101
+ # nothing", and only the former means there is no report to build.
102
+ #
103
+ # A file only one resultset carried is returned exactly as it came in,
104
+ # untouched: with nothing to merge it into, copying it would only cost
105
+ # memory.
106
+ #
107
+ # @return [Hash, nil]
108
+ #
109
+ def result
110
+ return nil unless @absorbed
111
+
112
+ @files.transform_values do |entry|
113
+ entry.is_a?(MergedFile) ? entry.to_h : entry
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ # The first sighting of a file is kept as-is; the second promotes it to
120
+ # a `MergedFile` that owns its state, and every later one folds into
121
+ # that same object.
122
+ def merge_file(existing, file_coverage)
123
+ case existing
124
+ when nil then file_coverage
125
+ when MergedFile then existing.absorb(file_coverage)
126
+ else MergedFile.new(existing, branches: @branch_coverage, methods: @method_coverage).absorb(file_coverage)
127
+ end
128
+ end
129
+
130
+ #
131
+ # One file's accumulated coverage, against state this object owns
132
+ # outright rather than rebuilt for each merged pair.
133
+ #
134
+ class MergedFile
135
+ # A criterion is carried for this file when the data carries it, not
136
+ # when this process happens to measure it. A merge runs on behalf of
137
+ # the processes that produced the resultsets and does not necessarily
138
+ # share their configuration: `simplecov merge` never ran SimpleCov.start
139
+ # at all, and dropping a table it did not ask for would lose branch data
140
+ # the producers did measure. A nil table means nobody measured that
141
+ # criterion; an empty one means it was measured and the file has none.
142
+ def initialize(coverage, branches:, methods:)
143
+ @branch_coverage = branches
144
+ @method_coverage = methods
145
+ @lines = coverage["lines"]&.dup
146
+ # Branch coverage always reports a table, even an empty one, so
147
+ # `SourceFile` can tell "no branches in this file" from "branch
148
+ # coverage was off". Method coverage stays `nil` until some resultset
149
+ # actually carries methods.
150
+ @branches = BranchesCombiner.absorb({}, coverage["branches"]) if branches || coverage["branches"]
151
+ @methods = MethodsCombiner.absorb(nil, coverage["methods"]) if methods || coverage["methods"]
152
+ end
153
+
154
+ #
155
+ # Folds one more resultset's coverage for this file in.
156
+ #
157
+ # @return [MergedFile] self
158
+ #
159
+ def absorb(coverage)
160
+ reconcile_synthesized(coverage)
161
+ @lines = LinesCombiner.merge_into(@lines, coverage["lines"])
162
+ self
163
+ end
164
+
165
+ def to_h
166
+ merged = {"lines" => @lines} #: Hash[String, untyped]
167
+ merged["branches"] = BranchesCombiner.materialize(@branches) if @branches
168
+ merged["methods"] = MethodsCombiner.materialize(@methods) if @methods
169
+ merged
170
+ end
171
+
172
+ private
173
+
174
+ def new_table
175
+ {} #: Hash[untyped, untyped]
176
+ end
177
+
178
+ # When exactly one side of the merge was actually executed, its branch
179
+ # and method tuples are authoritative and the other side's are
180
+ # dropped. A simulated entry (SimulateCoverage backfills
181
+ # tracked-but-unloaded files) synthesizes those tuples statically, so
182
+ # a location that drifts from what Coverage emits would otherwise be
183
+ # unioned in by position and survive as a phantom, permanently-missed
184
+ # branch (see #1233). This contains any such drift to denominator
185
+ # inflation for files no process loaded, rather than a false miss on a
186
+ # covered file. Lines are never dropped: a simulated file's line shape
187
+ # is correct and carries the unloaded-file denominator (#1059).
188
+ #
189
+ # The accumulated side's executed-ness is re-read on every absorb
190
+ # because folding in an executed resultset can flip it, exactly as it
191
+ # would have flipped between two steps of the pairwise fold.
192
+ def reconcile_synthesized(coverage)
193
+ return absorb_tuples(coverage) unless judgeable?(@lines) && judgeable?(coverage["lines"])
194
+
195
+ accumulated_executed = executed?(@lines)
196
+ incoming_executed = executed?(coverage["lines"])
197
+
198
+ if accumulated_executed == incoming_executed
199
+ absorb_tuples(coverage)
200
+ elsif incoming_executed
201
+ replace_tuples(coverage)
202
+ else
203
+ drop_incoming_tuples(coverage)
204
+ end
205
+ end
206
+
207
+ # Both sides agree on whether they ran: union their tuples. A criterion
208
+ # only the incoming side carries comes into play here, because its
209
+ # tuples survive. It deliberately does not come into play on the path
210
+ # that drops them: promoting there would advertise an empty table
211
+ # sourced from data this merge just discarded, which claims the file
212
+ # has no branches when what is true is that nobody measured them.
213
+ def absorb_tuples(coverage)
214
+ @branches = BranchesCombiner.absorb(@branches || new_table, coverage["branches"]) if
215
+ @branches || coverage["branches"]
216
+ @methods = MethodsCombiner.absorb(@methods, coverage["methods"]) if @methods || coverage["methods"]
217
+ end
218
+
219
+ # Only the accumulated side ran, so the incoming tuples are synthesized
220
+ # and contribute nothing. When the discarded side carried a methods
221
+ # table, a measuring process still gets one — the empty table stands in
222
+ # for the blanked side, which is what a pair against a blank one used
223
+ # to produce for a file that has no methods yet. When no side has
224
+ # carried methods at all, the table stays nil (the rule `initialize`
225
+ # documents), the same answer the union path gives.
226
+ def drop_incoming_tuples(coverage)
227
+ @methods ||= {} if @method_coverage && coverage["methods"] #: Hash[untyped, untyped]
228
+ end
229
+
230
+ # Only the incoming side ran, so what's accumulated is synthesized:
231
+ # start its tuples over from the executed side's. The authoritative
232
+ # side decides which criteria the file carries, not just their tuples.
233
+ # Keeping a table in play that only the dropped side carried would make
234
+ # the answer depend on which side was seen first.
235
+ def replace_tuples(coverage)
236
+ @branches = authoritative_table(BranchesCombiner, coverage["branches"], @branch_coverage)
237
+ # Methods stay nil until some resultset carries them (see
238
+ # `initialize`), so a measuring process keeps an empty table only
239
+ # when one did.
240
+ @methods = authoritative_table(MethodsCombiner, coverage["methods"], @method_coverage && !@methods.nil?)
241
+ end
242
+
243
+ # The executed side's tuples when it has them. Otherwise `keep_empty`
244
+ # decides whether the criterion survives as an empty table, which is
245
+ # what keeps `SourceFile` able to tell "no branches here" from
246
+ # "branches were not measured".
247
+ def authoritative_table(combiner, table, keep_empty)
248
+ return combiner.absorb(new_table, table) if table
249
+
250
+ keep_empty ? new_table : nil
251
+ end
252
+
253
+ # Whether a side can be judged at all. Absent lines do not mean the side
254
+ # never ran: a branch-only or method-only run omits them even for the
255
+ # files it loaded, so treating that as synthesized would discard real
256
+ # tuples. Unknown means union, which is already what happens when
257
+ # neither side carries lines.
258
+ def judgeable?(lines)
259
+ !Array(lines).empty?
260
+ end
261
+
262
+ def executed?(lines)
263
+ CoverageAccumulator.executed?(lines)
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module Combine
5
+ #
6
+ # Builds the raw-key => interned-identity caches the tuple combiners keep.
7
+ #
8
+ # The cache maps a raw key to its interned identity on first sight and
9
+ # keeps it: the pairwise fold would otherwise re-derive the accumulator's
10
+ # identities on every one of its merges, always to the same answer.
11
+ #
12
+ # Identities exist only to be `combine`'s hash keys, and `Array#hash` is
13
+ # not memoized, so keying on the identity tuple itself would rehash its
14
+ # elements for every key of both sides of every merge. An Integer hashes
15
+ # as an immediate. Two keys share an id exactly when their identities are
16
+ # equal, so the string and array forms of one key still merge together.
17
+ #
18
+ module IdentityInterner
19
+ module_function
20
+
21
+ def build
22
+ ids = {} #: Hash[::Array[untyped], Integer]
23
+ Hash.new do |cache, key|
24
+ identity = yield(key)
25
+ cache[key] = ids[identity] ||= ids.size
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module Combine
5
+ # The inner merge loop the branches and methods combiners share:
6
+ # fold `source`'s tuple => count pairs into `target`, an interned
7
+ # table keyed by each tuple's merge identity and holding
8
+ # `[raw_key, count]` pairs. The first-seen raw key is kept for
9
+ # display; counts sum. `target` and its pairs are always built by
10
+ # this loop, so updating them in place can't reach data a caller
11
+ # still holds.
12
+ module InternedCounts
13
+ module_function
14
+
15
+ # @return [Hash] `target`
16
+ def absorb_counts(target, source, identities)
17
+ source.each do |key, count|
18
+ interned = identities[key]
19
+ entry = target[interned]
20
+ if entry
21
+ entry[1] += count
22
+ else
23
+ target[interned] = [key, count]
24
+ end
25
+ end
26
+ target
27
+ end
28
+ end
29
+ end
30
+ end