simplecov 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +165 -57
  3. data/exe/simplecov +0 -3
  4. data/lib/minitest/simplecov_plugin.rb +12 -9
  5. data/lib/simplecov/atomic_file.rb +16 -19
  6. data/lib/simplecov/autostart.rb +5 -7
  7. data/lib/simplecov/baseline/parser.rb +58 -0
  8. data/lib/simplecov/baseline.rb +170 -0
  9. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  10. data/lib/simplecov/cli/affected/selection.rb +117 -0
  11. data/lib/simplecov/cli/affected.rb +148 -0
  12. data/lib/simplecov/cli/badge/svg.rb +70 -0
  13. data/lib/simplecov/cli/badge.rb +62 -0
  14. data/lib/simplecov/cli/clean.rb +23 -24
  15. data/lib/simplecov/cli/command_helpers.rb +55 -19
  16. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  17. data/lib/simplecov/cli/completions.rb +83 -0
  18. data/lib/simplecov/cli/coverage.rb +25 -28
  19. data/lib/simplecov/cli/coverage_file.rb +58 -14
  20. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  21. data/lib/simplecov/cli/dead_code.rb +139 -0
  22. data/lib/simplecov/cli/diff/output.rb +45 -0
  23. data/lib/simplecov/cli/diff.rb +35 -67
  24. data/lib/simplecov/cli/dotfile.rb +83 -38
  25. data/lib/simplecov/cli/git.rb +41 -0
  26. data/lib/simplecov/cli/history/output.rb +135 -0
  27. data/lib/simplecov/cli/history.rb +67 -0
  28. data/lib/simplecov/cli/merge.rb +34 -39
  29. data/lib/simplecov/cli/open.rb +12 -15
  30. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  31. data/lib/simplecov/cli/patch/output.rb +135 -0
  32. data/lib/simplecov/cli/patch.rb +169 -0
  33. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  34. data/lib/simplecov/cli/ratchet.rb +83 -0
  35. data/lib/simplecov/cli/report.rb +18 -24
  36. data/lib/simplecov/cli/run.rb +8 -9
  37. data/lib/simplecov/cli/serve/report_preparer.rb +7 -6
  38. data/lib/simplecov/cli/serve/static_file_handler.rb +62 -56
  39. data/lib/simplecov/cli/serve.rb +27 -24
  40. data/lib/simplecov/cli/show/annotator.rb +87 -0
  41. data/lib/simplecov/cli/show/sweep.rb +36 -0
  42. data/lib/simplecov/cli/show.rb +133 -0
  43. data/lib/simplecov/cli/status/facts.rb +87 -0
  44. data/lib/simplecov/cli/status.rb +93 -0
  45. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  46. data/lib/simplecov/cli/tests.rb +148 -0
  47. data/lib/simplecov/cli/uncovered/misses.rb +37 -0
  48. data/lib/simplecov/cli/uncovered.rb +70 -32
  49. data/lib/simplecov/cli/usage.rb +152 -0
  50. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  51. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  52. data/lib/simplecov/cli/watch/poller.rb +45 -0
  53. data/lib/simplecov/cli/watch/session.rb +134 -0
  54. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  55. data/lib/simplecov/cli/watch.rb +85 -0
  56. data/lib/simplecov/cli.rb +43 -92
  57. data/lib/simplecov/color.rb +21 -35
  58. data/lib/simplecov/combine/branches_combiner.rb +7 -31
  59. data/lib/simplecov/combine/coverage_accumulator.rb +49 -142
  60. data/lib/simplecov/combine/identity_interner.rb +10 -13
  61. data/lib/simplecov/combine/interned_counts.rb +7 -9
  62. data/lib/simplecov/combine/lines_combiner.rb +20 -44
  63. data/lib/simplecov/combine/methods_combiner.rb +9 -35
  64. data/lib/simplecov/combine/results_combiner.rb +4 -21
  65. data/lib/simplecov/combine.rb +0 -6
  66. data/lib/simplecov/command_guesser.rb +20 -34
  67. data/lib/simplecov/configuration/baseline.rb +31 -0
  68. data/lib/simplecov/configuration/coverage.rb +91 -79
  69. data/lib/simplecov/configuration/coverage_criteria.rb +43 -54
  70. data/lib/simplecov/configuration/deprecations.rb +21 -0
  71. data/lib/simplecov/configuration/eval_coverage.rb +6 -10
  72. data/lib/simplecov/configuration/filters.rb +40 -73
  73. data/lib/simplecov/configuration/formatting.rb +74 -83
  74. data/lib/simplecov/configuration/groups.rb +19 -13
  75. data/lib/simplecov/configuration/history.rb +44 -0
  76. data/lib/simplecov/configuration/ignored_entries.rb +31 -22
  77. data/lib/simplecov/configuration/merging.rb +94 -62
  78. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  79. data/lib/simplecov/configuration/production.rb +23 -0
  80. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  81. data/lib/simplecov/configuration/thresholds.rb +33 -62
  82. data/lib/simplecov/configuration/view_coverage.rb +38 -0
  83. data/lib/simplecov/configuration.rb +70 -76
  84. data/lib/simplecov/configuration_error.rb +5 -0
  85. data/lib/simplecov/context_map/union.rb +81 -0
  86. data/lib/simplecov/context_map.rb +187 -0
  87. data/lib/simplecov/coverage_json.rb +2 -5
  88. data/lib/simplecov/coverage_statistics.rb +14 -24
  89. data/lib/simplecov/coverage_violations.rb +157 -64
  90. data/lib/simplecov/current_run.rb +61 -0
  91. data/lib/simplecov/defaults.rb +4 -23
  92. data/lib/simplecov/deprecation.rb +45 -27
  93. data/lib/simplecov/directive.rb +46 -62
  94. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  95. data/lib/simplecov/exit_codes/check.rb +17 -11
  96. data/lib/simplecov/exit_codes/exit_code_handling.rb +21 -8
  97. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +7 -10
  98. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  99. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  100. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +7 -12
  101. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +11 -10
  102. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +7 -9
  103. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +16 -17
  104. data/lib/simplecov/exit_codes.rb +17 -7
  105. data/lib/simplecov/exit_handling.rb +94 -59
  106. data/lib/simplecov/file_list.rb +20 -51
  107. data/lib/simplecov/filter.rb +43 -66
  108. data/lib/simplecov/formatter/base.rb +38 -51
  109. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  110. data/lib/simplecov/formatter/coverage_json_writer.rb +29 -31
  111. data/lib/simplecov/formatter/html_formatter/public/index.html +17 -10
  112. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +74 -17
  113. data/lib/simplecov/formatter/html_formatter.rb +24 -41
  114. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +50 -16
  115. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  116. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +43 -29
  117. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +14 -10
  118. data/lib/simplecov/formatter/json_formatter.rb +7 -13
  119. data/lib/simplecov/formatter/multi_formatter.rb +5 -11
  120. data/lib/simplecov/formatter/simple_formatter.rb +2 -8
  121. data/lib/simplecov/formatter.rb +14 -10
  122. data/lib/simplecov/group_names.rb +13 -12
  123. data/lib/simplecov/history.rb +115 -0
  124. data/lib/simplecov/last_run.rb +6 -9
  125. data/lib/simplecov/lines_classifier.rb +31 -16
  126. data/lib/simplecov/load_global_config.rb +7 -7
  127. data/lib/simplecov/parallel_adapters/base.rb +13 -41
  128. data/lib/simplecov/parallel_adapters/generic.rb +7 -18
  129. data/lib/simplecov/parallel_adapters/parallel_tests.rb +21 -42
  130. data/lib/simplecov/parallel_adapters.rb +24 -53
  131. data/lib/simplecov/parallel_coordination.rb +39 -49
  132. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  133. data/lib/simplecov/parallel_result_merger.rb +67 -126
  134. data/lib/simplecov/process.rb +14 -32
  135. data/lib/simplecov/production/error.rb +11 -0
  136. data/lib/simplecov/production/file_sink.rb +130 -0
  137. data/lib/simplecov/production.rb +249 -0
  138. data/lib/simplecov/profiles/rails.rb +1 -12
  139. data/lib/simplecov/profiles/root_filter.rb +0 -4
  140. data/lib/simplecov/profiles/strict.rb +9 -19
  141. data/lib/simplecov/profiles.rb +11 -33
  142. data/lib/simplecov/report_deferral.rb +11 -15
  143. data/lib/simplecov/report_stamp.rb +6 -9
  144. data/lib/simplecov/result/filter_config.rb +19 -0
  145. data/lib/simplecov/result/missing_source_files_reporter.rb +8 -9
  146. data/lib/simplecov/result/serialization.rb +43 -0
  147. data/lib/simplecov/result/source_file_builder.rb +7 -12
  148. data/lib/simplecov/result.rb +68 -117
  149. data/lib/simplecov/result_adapter.rb +58 -68
  150. data/lib/simplecov/result_merger/contexts.rb +22 -0
  151. data/lib/simplecov/result_merger/legacy_format_adapter.rb +4 -8
  152. data/lib/simplecov/result_merger/resultset_file.rb +20 -27
  153. data/lib/simplecov/result_merger/resultset_run_identity.rb +23 -25
  154. data/lib/simplecov/result_merger/resultset_store.rb +23 -14
  155. data/lib/simplecov/result_merger/unloaded_files.rb +24 -32
  156. data/lib/simplecov/result_merger.rb +59 -58
  157. data/lib/simplecov/result_processing.rb +74 -119
  158. data/lib/simplecov/run_identity.rb +16 -18
  159. data/lib/simplecov/simulate_coverage.rb +35 -50
  160. data/lib/simplecov/source_file/branch.rb +8 -33
  161. data/lib/simplecov/source_file/branch_builder.rb +28 -47
  162. data/lib/simplecov/source_file/builder_context.rb +6 -13
  163. data/lib/simplecov/source_file/line.rb +13 -33
  164. data/lib/simplecov/source_file/line_builder.rb +9 -15
  165. data/lib/simplecov/source_file/method.rb +3 -7
  166. data/lib/simplecov/source_file/method_builder.rb +6 -15
  167. data/lib/simplecov/source_file/ruby_data_parser.rb +59 -66
  168. data/lib/simplecov/source_file/skip_chunks.rb +19 -28
  169. data/lib/simplecov/source_file/source_loader.rb +45 -35
  170. data/lib/simplecov/source_file/statistics.rb +10 -9
  171. data/lib/simplecov/source_file.rb +21 -51
  172. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +63 -114
  173. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +76 -121
  174. data/lib/simplecov/static_coverage_extractor/method_collector.rb +11 -24
  175. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +15 -24
  176. data/lib/simplecov/static_coverage_extractor/value_position.rb +25 -38
  177. data/lib/simplecov/static_coverage_extractor/visitor.rb +47 -64
  178. data/lib/simplecov/static_coverage_extractor.rb +46 -62
  179. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  180. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  181. data/lib/simplecov/test_tracker/delta.rb +69 -0
  182. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  183. data/lib/simplecov/test_tracker.rb +162 -0
  184. data/lib/simplecov/unloaded_file_injector.rb +18 -31
  185. data/lib/simplecov/useless_results_remover.rb +7 -6
  186. data/lib/simplecov/version.rb +1 -1
  187. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  188. data/lib/simplecov/view_coverage.rb +55 -0
  189. data/lib/simplecov-html.rb +1 -0
  190. data/lib/simplecov.rb +59 -114
  191. data/lib/simplecov_json_formatter.rb +1 -0
  192. data/man/simplecov.1 +349 -0
  193. data/sig/simplecov.rbs +523 -595
  194. metadata +72 -7
  195. data/schemas/coverage-v1.0.schema.json +0 -306
  196. data/schemas/coverage.schema.json +0 -306
@@ -8,27 +8,22 @@ require_relative "value_position"
8
8
 
9
9
  module SimpleCov
10
10
  module StaticCoverageExtractor
11
- # Prism visitor that accumulates branch and method tuples in the
12
- # shape Ruby's `Coverage` reports. Tuple ids are sequential across
13
- # the file like `Coverage`'s, but the numbering order can differ
14
- # (e.g. `case`/`when` and chained `&.` are visited in a different
15
- # order than Coverage numbers them). That's fine: the combiners
16
- # intern on source span and the report output drops ids, so nothing
17
- # downstream compares them. Only defined when Prism is loadable;
18
- # `StaticCoverageExtractor.available?` is the runtime gate.
19
- class Visitor < ::Prism::Visitor
20
- # Method tuples and the class/module nesting that names them are
21
- # collected by this mixin; this class focuses on branch extraction.
11
+ # Prism visitor that accumulates branch and method tuples in the shape
12
+ # Ruby's `Coverage` reports. Tuple ids are sequential across the file like
13
+ # `Coverage`'s, but the numbering order can differ. That's fine: the
14
+ # combiners intern on source span and the report output drops ids, so
15
+ # nothing downstream compares them. Only defined when Prism is loadable.
16
+ class Visitor < Prism::Visitor
22
17
  include MethodCollector
23
- # Source-range resolution, including the per-Ruby-version Coverage
24
- # conventions. See issue #1226.
25
18
  include LocationConventions
26
- # Which literal `if`/`unless`/ternary conditions the compiler folds
27
- # away (so we emit no branch for them).
28
19
  include ConditionFolding
29
20
 
30
21
  attr_reader :branches, :methods
31
22
 
23
+ # Prism's Visitor is a stateless dispatch table whose initializer is
24
+ # Object's, so no mutation of the `super` call can be told apart. It
25
+ # stays for the day that stops being true.
26
+ # mutant:disable
32
27
  def initialize
33
28
  super
34
29
  @branches = {}
@@ -39,32 +34,25 @@ module SimpleCov
39
34
  @suppress_methods = false
40
35
  end
41
36
 
42
- # Entry point for a parsed file. On legacy Rubies the location of an
43
- # empty branch arm depends on whether its construct is in value
44
- # (tail) position, so precompute that once for the whole tree before
45
- # emitting anything. Modern Rubies don't need it (see
46
- # LocationConventions), so the pass is skipped there.
37
+ # On legacy Rubies the location of an empty branch arm depends on whether
38
+ # its construct is in value (tail) position, so precompute that once for
39
+ # the whole tree before emitting anything.
47
40
  def visit_program_node(node)
48
- # simplecov:disable branch — legacy-only arm; unreachable on the modern dogfood Ruby
49
41
  @value_positions = ValuePositions.call(node) if LEGACY_COVERAGE_LOCATIONS
50
- # simplecov:enable branch
51
42
  super
52
43
  end
53
44
 
54
- # `if` / `unless` / postfix-if / postfix-unless / ternary all parse
55
- # as IfNode (or UnlessNode). Both carry a `then` arm (the
56
- # statements body) and an optional `subsequent` (an ElseNode for
57
- # `else`, another IfNode for `elsif`). When the subsequent is
58
- # missing, Coverage synthesizes a `:else` arm attributed to the
59
- # whole condition's range — we do the same.
45
+ # `if` / `unless` / postfix / ternary all parse as IfNode (or UnlessNode).
46
+ # Both carry a `then` arm and an optional `subsequent` (an ElseNode for
47
+ # `else`, another IfNode for `elsif`). When the subsequent is missing,
48
+ # Coverage synthesizes a `:else` arm attributed to the whole condition's
49
+ # range, and so do we.
60
50
  #
61
- # A folded condition emits no tuple, and on modern Rubies only its
62
- # live arm is descended into: the compiler eliminates the dead
63
- # arm's entire subtree, so a branch or method nested there would be
64
- # a phantom no loaded run can produce. A falsy `if`'s elsif chain
65
- # survives as a plain `if`, which is what visiting the subsequent
66
- # IfNode emits. On 3.2 the dead arm is visited too, branches only
67
- # (see visit_folded_arms).
51
+ # A folded condition emits no tuple, and on modern Rubies only its live
52
+ # arm is descended into: the compiler eliminates the dead arm's entire
53
+ # subtree, so a branch or method nested there would be a phantom no
54
+ # loaded run can produce. On 3.2 the dead arm is visited too, branches
55
+ # only.
68
56
  def visit_if_node(node)
69
57
  verdict = folded_condition(node.predicate)
70
58
  return visit_folded_arms(verdict, node.statements, PrismCompat.subsequent(node)) if verdict
@@ -86,9 +74,8 @@ module SimpleCov
86
74
  super
87
75
  end
88
76
 
89
- # `case`/`when` and `case`/`in` (pattern matching) parse as CaseNode
90
- # and CaseMatchNode respectively. When there's no explicit `else`,
91
- # Coverage synthesizes one at the case's range.
77
+ # When there's no explicit `else`, Coverage synthesizes one at the case's
78
+ # range.
92
79
  def visit_case_node(node)
93
80
  emit_case_like(node, :when)
94
81
  super
@@ -99,26 +86,22 @@ module SimpleCov
99
86
  super
100
87
  end
101
88
 
102
- # One-line pattern matching: `x => pattern` (MatchRequiredNode) and
103
- # `x in pattern` (MatchPredicateNode). Ruby 3.3's Coverage reports
104
- # these as a `:case` with an `:in` and an `:else` arm; 3.4 dropped
105
- # them entirely (no branch), so this is legacy-only. The two forms
106
- # differ only in where Coverage anchors the synthesized `:else`:
107
- # `=>` uses the whole expression, `in` uses just the pattern.
108
- # simplecov:disable branch — legacy-only arms; unreachable on the modern dogfood Ruby
89
+ # One-line pattern matching: `x => pattern` and `x in pattern`. Ruby 3.3's
90
+ # Coverage reports these as a `:case` with an `:in` and an `:else` arm;
91
+ # 3.4 dropped them entirely, so this is legacy-only. The two forms differ
92
+ # only in where Coverage anchors the synthesized `:else`: `=>` uses the
93
+ # whole expression, `in` uses just the pattern.
109
94
  def visit_match_required_node(node)
110
- emit_oneline_pattern(node, node.location) if LEGACY_COVERAGE_LOCATIONS
95
+ emit_oneline_pattern(node, node) if LEGACY_COVERAGE_LOCATIONS
111
96
  super
112
97
  end
113
98
 
114
99
  def visit_match_predicate_node(node)
115
- emit_oneline_pattern(node, node.pattern.location) if LEGACY_COVERAGE_LOCATIONS
100
+ emit_oneline_pattern(node, node.pattern) if LEGACY_COVERAGE_LOCATIONS
116
101
  super
117
102
  end
118
- # simplecov:enable branch
119
103
 
120
- # `while` / `until` loops get a single `:body` arm. No synthetic
121
- # else (the loop either runs the body or doesn't).
104
+ # A loop gets a single `:body` arm and no synthetic else.
122
105
  def visit_while_node(node)
123
106
  emit_loop(node, :while)
124
107
  super
@@ -129,11 +112,10 @@ module SimpleCov
129
112
  super
130
113
  end
131
114
 
132
- private
115
+ private
133
116
 
134
- # IfNode and UnlessNode share a shape (predicate + then body +
135
- # optional else/elsif) but expose the trailing arm under different
136
- # accessors. `if_like_else_location` hides that split.
117
+ # IfNode and UnlessNode share a shape but expose the trailing arm under
118
+ # different accessors, which `if_like_else_location` hides.
137
119
  def emit_if_like(node, type)
138
120
  then_loc = if_like_then_location(node, type)
139
121
  else_loc = if_like_else_location(node, type)
@@ -151,32 +133,33 @@ module SimpleCov
151
133
  }
152
134
  end
153
135
 
154
- # simplecov:disable — legacy-only (3.4 emits no branch for one-line patterns)
155
- def emit_oneline_pattern(node, else_location)
156
- @branches[build_tuple(:case, node.location)] = {
157
- build_tuple(:in, node.pattern.location) => 0,
158
- build_tuple(:else, else_location) => 0
136
+ def emit_oneline_pattern(node, else_span)
137
+ @branches[build_tuple(:case, node)] = {
138
+ build_tuple(:in, node.pattern) => 0,
139
+ build_tuple(:else, else_span) => 0
159
140
  }
160
141
  end
161
- # simplecov:enable
162
142
 
163
143
  def emit_case_like(node, when_type)
164
144
  arms = node.conditions.to_h do |when_node|
165
145
  [build_tuple(when_type, case_arm_location(node, when_node, when_type)), 0]
166
146
  end
167
147
  arms[build_tuple(:else, else_arm_location(node))] = 0
168
- @branches[build_tuple(:case, node.location)] = arms
148
+ @branches[build_tuple(:case, node)] = arms
169
149
  end
170
150
 
171
151
  def emit_loop(node, type)
172
- cond_tuple = build_tuple(type, node.location)
152
+ cond_tuple = build_tuple(type, node)
173
153
  @branches[cond_tuple] = {build_tuple(:body, loop_body_location(node)) => 0}
174
154
  end
175
155
 
176
- def build_tuple(type, location)
156
+ # `span` is anything that answers the four position accessors: a location
157
+ # from LocationConventions, or the node itself where the range wanted is
158
+ # the node's own.
159
+ def build_tuple(type, span)
177
160
  id = @next_id
178
161
  @next_id += 1
179
- [type, id, location.start_line, location.start_column, location.end_line, location.end_column]
162
+ [type, id, span.start_line, span.start_column, span.end_line, span.end_column]
180
163
  end
181
164
  end
182
165
  end
@@ -4,107 +4,91 @@ begin
4
4
  require "prism"
5
5
  rescue LoadError
6
6
  # Prism isn't available on this Ruby (older than 3.3 without the gem).
7
- # `StaticCoverageExtractor.available?` will return false and callers
8
- # fall back to the previous "empty hashes" behavior.
7
+ # `StaticCoverageExtractor.available?` will return false and callers fall
8
+ # back to the previous "empty hashes" behavior.
9
9
  end
10
10
 
11
11
  module SimpleCov
12
12
  # Static enumeration of the branches and methods Ruby's `Coverage` library
13
- # WOULD have reported if a file had been loaded with `branches: true` /
13
+ # would have reported if a file had been loaded with `branches: true` /
14
14
  # `methods: true`. Used by `SimulateCoverage` to backfill data for files
15
- # added via `cover` / `track_files` that were never `require`'d during the
16
- # run — so unloaded files contribute to the branch/method denominators
17
- # symmetrically with their line coverage, instead of vanishing from the
18
- # totals (see #1059).
15
+ # added via `cover` / `track_files` that were never required during the run,
16
+ # so unloaded files contribute to the branch/method denominators
17
+ # symmetrically with their line coverage (#1059).
19
18
  #
20
- # Implementation uses Prism (stdlib in Ruby 3.3+, gem on older Rubies).
21
- # When Prism isn't available, `available?` returns false and SimulateCoverage
22
- # falls back to the previous behavior older Rubies keep working, just
23
- # without the synthesized data.
24
- #
25
- # The emitted shape mirrors `Coverage.result[path]` for the same file:
26
- # branches are nested as `{condition_tuple => {arm_tuple => 0, ...}}` and
27
- # methods as `{["ClassName", :name, lines/cols] => 0}`. Position info
28
- # comes from Prism's reported source locations; it doesn't always match
29
- # `Coverage`'s byte-for-byte (the two parsers report slightly different
30
- # column conventions for some constructs), but lines are reliable and
31
- # downstream consumers that key off line numbers (the HTML formatter,
32
- # SonarQube, etc.) see the data they expect.
19
+ # The emitted shape mirrors `Coverage.result[path]` for the same file.
20
+ # Position info comes from Prism's reported source locations; it doesn't
21
+ # always match `Coverage`'s byte-for-byte, but lines are reliable and
22
+ # downstream consumers that key off line numbers see the data they expect.
33
23
  module StaticCoverageExtractor
34
- module_function
24
+ extend self
35
25
 
36
- # simplecov:disable branch
37
- # The Prism-unavailable arm of this ternary is unreachable when Prism
38
- # itself IS loadable — i.e., on every engine that exercises the dogfood
39
- # report. Asserted-on by callers; tested indirectly via the
40
- # `available?`-returns-false fallback path in SimulateCoverage's spec.
41
26
  def available?
42
- defined?(::Prism) ? true : false
27
+ defined?(Prism) ? true : false
43
28
  end
44
- # simplecov:enable branch
45
29
 
46
- # Parse `source` (a string of Ruby) and return a hash of the form
47
- # `{"branches" => {...}, "methods" => {...}}` matching the shape that
48
- # `Coverage.result[path]` produces. Returns nil on parse failure or
49
- # when Prism isn't available; callers should treat that as "couldn't
50
- # extract — fall back to empty hashes."
30
+ # Parse `source` and return `{"branches" => {...}, "methods" => {...}}`
31
+ # matching the shape `Coverage.result[path]` produces. Returns nil on parse
32
+ # failure or when Prism isn't available, which callers treat as "couldn't
33
+ # extract, fall back to empty hashes".
51
34
  def call(source)
52
- # simplecov:disable branch — `then` arm unreachable when Prism IS loadable
53
35
  return nil unless available?
54
36
 
55
- # simplecov:enable branch
56
-
57
- result = ::Prism.parse(source)
37
+ result = Prism.parse(source)
58
38
  return nil if result.failure?
59
39
 
60
40
  visitor = Visitor.new
61
41
  visitor.visit(result.value)
62
42
  {"branches" => visitor.branches, "methods" => visitor.methods}
63
- rescue StandardError
64
- # simplecov:disable line
65
- # Parser errors beyond the .failure? check, unsupported AST shapes,
66
- # or anything else: fall back to empty hashes rather than crashing
67
- # the whole report. Defensive; hard to trigger from a real source
68
- # input that Prism accepts at parse time.
43
+ rescue
44
+ # Parser errors beyond the .failure? check, unsupported AST shapes, or
45
+ # anything else: fall back to empty hashes rather than crashing the whole
46
+ # report.
69
47
  nil
70
- # simplecov:enable line
71
48
  end
72
49
 
73
- # Summarize a source file's REAL branch and method positions, for the
74
- # `:eval_generated` filter (SimpleCov.ignore_branches /
75
- # SimpleCov.ignore_methods, #1046). Returns a hash:
50
+ # Summarize a source file's real branch and method positions, for the
51
+ # `:eval_generated` filter (#1046). Answers:
76
52
  #
77
53
  # {
78
54
  # branches: Set[start_line, ...], # e.g., [3, 12, 20]
79
55
  # methods: Set[[name, start_line], ...] # e.g., [[:foo, 7], [:bar, 13]]
80
56
  # }
81
57
  #
82
- # Branch matching is start_line-only rather than by the full tuple.
83
- # Static extraction and Coverage can still disagree on a branch's exact
84
- # column positions (and, for some constructs, its type), so matching on
85
- # start_line alone is the conservative choice that tolerates those
86
- # differences. Coincidental line-sharing between a real branch and an
87
- # eval-generated one will keep both, which is an acceptable
88
- # false-negative for an opt-in filter. Method matching uses
89
- # (name, start_line) since a method name is unique at any line.
58
+ # Branch matching is start_line-only rather than by the full tuple. Static
59
+ # extraction and Coverage can still disagree on a branch's exact column
60
+ # positions, so matching on start_line alone tolerates those differences.
61
+ # Coincidental line-sharing between a real branch and an eval-generated one
62
+ # keeps both, an acceptable false-negative for an opt-in filter.
90
63
  #
91
- # Returns nil when Prism is unavailable or parsing fails, signaling
92
- # callers to keep every Coverage entry (no false drops).
64
+ # Returns nil when Prism is unavailable or parsing fails, signaling callers
65
+ # to keep every Coverage entry.
93
66
  def real_source_positions(source)
94
67
  extracted = call(source)
95
68
  return nil unless extracted
96
69
 
97
70
  {
98
- branches: extracted["branches"].keys.to_set { |tuple| tuple[2] },
99
- methods: extracted["methods"].keys.to_set { |tuple| [tuple[1], tuple[2]] }
71
+ branches: extracted.fetch("branches").keys.to_set { |tuple| branch_start_line(*tuple) },
72
+ methods: extracted.fetch("methods").keys.to_set { |tuple| method_identity(*tuple) }
100
73
  }
101
74
  end
75
+
76
+ # Both keys carry their start line third, after the parts that vary between
77
+ # recordings. Read through a parameter list rather than an index, because
78
+ # every spelling of an index answers the same for a tuple of this fixed
79
+ # shape. Binding an argument has only the one spelling.
80
+ def branch_start_line(_type, _id, start_line, *)
81
+ start_line
82
+ end
83
+
84
+ def method_identity(_class_name, name, start_line, *)
85
+ [name, start_line]
86
+ end
102
87
  end
103
88
  end
104
89
 
105
90
  # simplecov:disable branch
106
- # The `else` arm (Prism missing) is unreachable on engines where the
107
- # dogfood report runs; the Visitor class only matters when Prism is
108
- # loadable.
91
+ # The `else` arm (Prism missing) is unreachable on engines where the dogfood
92
+ # report runs; the Visitor class only matters when Prism is loadable.
109
93
  require_relative "static_coverage_extractor/visitor" if SimpleCov::StaticCoverageExtractor.available?
110
94
  # simplecov:enable branch
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class TestTracker
5
+ module Accessors
6
+ # Records the block's coverage delta as covered by `test_id`, returning the
7
+ # block's value. This is the seam test frameworks hang per-test tracking on. A
8
+ # no-op, with the block still running, unless `track_tests` is enabled and
9
+ # tracking has started, so callers never guard it.
10
+ def track_test(test_id, &)
11
+ tracker = test_tracker
12
+ return yield unless tracker
13
+
14
+ tracker.track(test_id, &)
15
+ end
16
+
17
+ def test_tracker
18
+ @test_tracker
19
+ end
20
+
21
+ # @api private. Reuses the tracker across repeated `start` calls for the same
22
+ # reason `Coverage` keeps counting across them: restarting must not lose what
23
+ # the process already recorded. The framework hooks are only wired up on the
24
+ # first.
25
+ def start_test_tracking
26
+ validate_test_tracking!
27
+ return unless track_tests? && test_tracker.nil?
28
+
29
+ @test_tracker = TestTracker.new(granularity: track_tests_granularity)
30
+ TestTracker.install_framework_hooks
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class TestTracker
5
+ # A one-shot watch that runs a callback the moment a constant of a given name
6
+ # is defined under a host module, via Ruby's `Module#const_added`.
7
+ # `TestTracker` uses it to install the Minitest wrapper in the ordering
8
+ # minitest 6 abandoned plugins for: SimpleCov starts first, as coverage must
9
+ # to see the app load, and minitest is required later, so nothing of
10
+ # minitest's exists yet to hook into.
11
+ #
12
+ # Each watch is its own Module so it can be prepended to the host's singleton
13
+ # class and compose with any `const_added` the host already has. A prepend
14
+ # cannot be removed, so after firing the watch stays in place as a name
15
+ # comparison per constant definition, which is as close to free as Ruby gets.
16
+ class ConstantWatch < Module
17
+ # No `super`: it would forward the implicit block even given explicit
18
+ # arguments, and `Module#initialize` module_evals any block it gets.
19
+ def initialize(name, &on_added)
20
+ @name = name
21
+ @on_added = on_added
22
+ watch = self #: ConstantWatch
23
+ define_method(:const_added) do |added|
24
+ super(added) # steep:ignore UnexpectedPositionalArgument
25
+ watch.notice(added)
26
+ end
27
+ end
28
+
29
+ # `host.const_added` runs with the host as self, so reaching this watch's
30
+ # state takes the closure. At runtime the `super` inside is the host's next
31
+ # `const_added`; Steep resolves a define_method block's super against the
32
+ # lexically enclosing method instead.
33
+ def attach(host)
34
+ host.singleton_class.prepend(self)
35
+ self
36
+ end
37
+
38
+ def notice(added)
39
+ return unless added.equal?(@name)
40
+
41
+ callback = @on_added
42
+ return unless callback
43
+
44
+ @on_added = nil
45
+ callback.call
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class TestTracker
5
+ # The peek-diff arithmetic behind the tracker: which in-root lines grew
6
+ # between two coverage snapshots. This is the part a native
7
+ # `Coverage.supported?(:contexts)` engine would replace outright.
8
+ class Delta
9
+ def initialize(root_regex:)
10
+ @root_regex = root_regex
11
+ @in_root = {} #: Hash[String, bool]
12
+ end
13
+
14
+ # Files outside the root are skipped; a file absent from `before` was loaded
15
+ # by the test itself, so everything it executed is the test's. The root match
16
+ # is memoized, so the regex runs once per file the process ever loads rather
17
+ # than once per file per test.
18
+ def call(before, after)
19
+ changed = {} #: Hash[String, Integer]
20
+ after.each do |path, file_coverage|
21
+ in_root = @in_root.fetch(path) { @in_root[path] = @root_regex.match?(path) }
22
+ next unless in_root
23
+
24
+ bitmap = line_delta(lines_in(before[path]), lines_in(file_coverage))
25
+ changed[path] = bitmap unless bitmap.zero?
26
+ end
27
+ changed
28
+ end
29
+
30
+ private
31
+
32
+ # A peek's per-file data is a criteria Hash when Coverage was started with a
33
+ # criteria hash, and a bare Array under the older lines-only form. No lines
34
+ # at all, from a branch-only or method-only start, means nothing to diff.
35
+ def lines_in(file_coverage)
36
+ case file_coverage
37
+ when Hash then file_coverage[:lines]
38
+ when Array then file_coverage
39
+ end
40
+ end
41
+
42
+ # The equality check is the overwhelmingly common case, the test never
43
+ # entering this file: an Array compare is fast C, the per-line loop is not.
44
+ #
45
+ # The bitmap is built as a binary string in one pass, so the
46
+ # arbitrary-precision math happens once per file rather than one bignum OR
47
+ # per executed line. The string reads lowest line first and the number wants
48
+ # it the other way round; the leading zero keeps the parse valid for an
49
+ # all-zero delta without changing any value.
50
+ def line_delta(before_lines, after_lines)
51
+ return 0 unless after_lines
52
+ return 0 if before_lines.eql?(after_lines)
53
+
54
+ bits = +""
55
+ after_lines.each_with_index do |count, index|
56
+ bits << (grew?(count, before_lines&.at(index)) ? "1" : "0")
57
+ end
58
+ Integer("0#{bits.reverse}", 2)
59
+ end
60
+
61
+ # `previous` is nil both for a non-executable line, whose count is nil too,
62
+ # and for a file the test itself loaded, where every executed line is the
63
+ # test's.
64
+ def grew?(count, previous)
65
+ !count.nil? && count > (previous || 0)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ # The test-framework side of per-test tracking: how the wrapper gets
5
+ # installed into RSpec and Minitest, and how each framework's tests are
6
+ # identified. The recording itself lives in test_tracker.rb.
7
+ class TestTracker
8
+ class << self
9
+ def install_framework_hooks
10
+ install_rspec_hook
11
+ install_minitest_hook_when_loaded
12
+ end
13
+
14
+ # Wraps every RSpec example in a `track_test` call. Called from
15
+ # `SimpleCov.start_tracking`, where RSpec is already loaded when the suite
16
+ # is an RSpec suite. Installs once per process no matter how often
17
+ # tracking is restarted.
18
+ def install_rspec_hook(rspec = rspec_module)
19
+ return unless rspec
20
+ return if @rspec_hook_installed
21
+
22
+ @rspec_hook_installed = true
23
+ rspec.configure do |config|
24
+ # The block runs instance-eval'd in the example group, so everything it
25
+ # touches must be fully qualified.
26
+ config.around do |example|
27
+ SimpleCov.track_test(TestTracker.rspec_example_id(example)) { example.run }
28
+ end
29
+ end
30
+ end
31
+
32
+ # The RSpec this process has, if it has one. A Minitest-only suite has
33
+ # none, and no example can make this process into one: hiding the RSpec
34
+ # constant takes down the very runner asking the question.
35
+ # simplecov:disable branch — the RSpec-less arm is unreachable from an RSpec-driven suite
36
+ # mutant:disable
37
+ def rspec_module
38
+ ::RSpec if defined?(::RSpec.configure)
39
+ end
40
+ # simplecov:enable branch
41
+
42
+ # @api private -- test seam, so specs can exercise installation repeatedly
43
+ # without touching the process-wide guard for real.
44
+ def reset_rspec_hook!
45
+ @rspec_hook_installed = false
46
+ end
47
+
48
+ # Called by the minitest plugin under minitest 5, and by the constant
49
+ # watch below under minitest 6, whose autorun no longer discovers
50
+ # plugins. Prepending a module already in the chain is a no-op, which is
51
+ # the whole of the idempotence.
52
+ def install_minitest_hook(test_case = (Minitest::Test if defined?(Minitest::Test)))
53
+ return if test_case.nil?
54
+
55
+ test_case.prepend(MinitestRun)
56
+ end
57
+
58
+ # Installs the Minitest wrapper now when Minitest is already loaded, and
59
+ # otherwise the moment `Minitest::Test` is defined. The deferred half is
60
+ # what covers minitest 6 under the canonical helper ordering (SimpleCov
61
+ # first, so coverage sees the app load; minitest after), where no plugin
62
+ # ever fires. `root` is Object outside of tests.
63
+ def install_minitest_hook_when_loaded(root = Object)
64
+ minitest = loaded_const(root, :Minitest)
65
+ test_case = minitest && loaded_const(minitest, :Test)
66
+ return install_minitest_hook(test_case) if test_case
67
+ return watch_for_minitest_test(minitest) if minitest
68
+
69
+ ConstantWatch.new(:Minitest) do
70
+ # `loaded_const` rather than the block capturing a module: the module
71
+ # does not exist yet when the watch is armed.
72
+ watch_for_minitest_test(loaded_const(root, :Minitest))
73
+ end.attach(root)
74
+ end
75
+
76
+ # Relative the way RSpec prints it (`./spec/a_spec.rb:12` becomes
77
+ # `spec/a_spec.rb:12`).
78
+ def rspec_example_id(example)
79
+ example.metadata.fetch(:location).delete_prefix("./")
80
+ end
81
+
82
+ # An autoload declaration fires `const_added` without the module existing
83
+ # yet; that arrangement is left to the plugin, so nil means nothing to
84
+ # watch.
85
+ def watch_for_minitest_test(minitest)
86
+ return unless minitest
87
+
88
+ ConstantWatch.new(:Test) { install_minitest_hook(loaded_const(minitest, :Test)) }.attach(minitest)
89
+ end
90
+
91
+ # The constant when it is genuinely loaded, nil when absent or merely
92
+ # declared for autoload: const_get would force such a load, and installing
93
+ # a coverage hook must never be what requires a test framework.
94
+ def loaded_const(mod, name)
95
+ return nil unless mod.const_defined?(name, false)
96
+ return nil if mod.autoload?(name, false)
97
+
98
+ mod.const_get(name)
99
+ end
100
+
101
+ # A Minitest test's identity: the `path:line` where the test method is
102
+ # defined, relative to `SimpleCov.root`. Falls back to
103
+ # `ClassName#method_name` when the method has no source location.
104
+ def minitest_test_id(test)
105
+ path, line = definition_site(test)
106
+ return "#{test.class}##{test.name}" unless path
107
+
108
+ "#{path.delete_prefix(File.join(SimpleCov.root, ""))}:#{line}"
109
+ end
110
+
111
+ # A method defined in C or through an eval without a filename has no site,
112
+ # and a runner exotic enough to name a method it never defined deserves
113
+ # an id over an exception out of the middle of its run.
114
+ def definition_site(test)
115
+ test.method(test.name).source_location
116
+ rescue NameError
117
+ nil
118
+ end
119
+ end
120
+
121
+ # Prepended to `Minitest::Test` by `install_minitest_hook`, wrapping each
122
+ # test method run, setup and teardown included: a line only a setup block
123
+ # reaches is still this test's line.
124
+ module MinitestRun
125
+ def run
126
+ SimpleCov.track_test(TestTracker.minitest_test_id(self)) { super }
127
+ end
128
+ end
129
+ end
130
+ end