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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Detects the `if` / `unless` / ternary conditions CRuby folds away.
6
+ # When a condition is a statically-known-truthy/falsy literal the
7
+ # compiler eliminates the dead arm and Coverage emits NO branch, so the
8
+ # extractor must not synthesize one either — otherwise the arm is a
9
+ # phantom that no loaded run can ever hit, the same unmergeable-tuple
10
+ # failure mode as #1226 / #1233.
11
+ module ConditionFolding
12
+ # CRuby 3.4 rebuilt the fold on the Prism compiler, and the
13
+ # parse.y-based fold it replaced differed in three observable ways,
14
+ # each pinned by the runtime tuple equivalence battery on CI:
15
+ # `__FILE__` folded on 3.2/3.3 but no longer does; parentheses were
16
+ # transparent for every literal on 3.2 (opacity starts at 3.3); and
17
+ # on 3.2 the dead arm's branch table entries survive the fold —
18
+ # parse.y instrumented branches before eliminating dead code — while
19
+ # its `def`s still never register.
20
+ FOLDS_SOURCE_FILE = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
21
+ PARENS_ALWAYS_TRANSPARENT = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.3")
22
+ DEAD_ARM_BRANCHES_SURVIVE = PARENS_ALWAYS_TRANSPARENT
23
+ # Container literals in discarded position are eliminated from 3.3
24
+ # on, but the contents rule differs: 3.3's compile.c elides a
25
+ # container whose contents are merely effect-free (`[x]`, `[self]`),
26
+ # while the Prism compiler (3.4+) demands fully static literals
27
+ # (`[1]` goes, `[x]` stays). See `static_container_literal?`.
28
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS = !FOLDS_SOURCE_FILE
29
+
30
+ # Prism node types for the literals that fold. `while` / `until` do
31
+ # NOT fold (`while true` is a real branch), so only the if-like
32
+ # visitors consult this. Regexp and Range literals are excluded on
33
+ # purpose: as conditions they mean `=~ $_` / flip-flop, which
34
+ # Coverage does branch on. `[]`, `{}`, and interpolated strings do
35
+ # not fold either, and `->` folds while a `lambda` call does not —
36
+ # the compiler only folds what it can prove at compile time, and a
37
+ # method named `lambda` proves nothing.
38
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
39
+ STATIC_CONDITION_TYPES = [
40
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
41
+ ::Prism::ImaginaryNode, ::Prism::SymbolNode, ::Prism::StringNode,
42
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
43
+ ::Prism::SourceLineNode, ::Prism::SourceEncodingNode, ::Prism::LambdaNode,
44
+ *(::Prism::SourceFileNode if FOLDS_SOURCE_FILE)
45
+ ].freeze
46
+ # simplecov:enable branch
47
+
48
+ # The literals whose fold eliminates the *then* side: `if false` /
49
+ # `if nil` keep only the else arm. Every other folded literal is
50
+ # truthy and keeps only the then arm.
51
+ FALSY_CONDITION_TYPES = [::Prism::FalseNode, ::Prism::NilNode].freeze
52
+
53
+ # The literals whose fold does NOT see through parentheses: CRuby
54
+ # folds `if nil`, `if "x"`, and `if -> {}` but keeps a real branch
55
+ # for `if (nil)`, `if ("x")`, and `if (-> {})` — verified against
56
+ # Coverage, and pinned by the runtime tuple equivalence battery —
57
+ # while every other literal folds parenthesized or not. `__FILE__`
58
+ # is opaque like other strings on the Rubies that fold it at all.
59
+ # Consulted only when PARENS_ALWAYS_TRANSPARENT is false.
60
+ PAREN_OPAQUE_TYPES = [
61
+ ::Prism::NilNode, ::Prism::StringNode, ::Prism::LambdaNode, ::Prism::SourceFileNode
62
+ ].freeze
63
+
64
+ # The scalar literals the compiler treats as fully static: a
65
+ # multi-statement paren condition (`if (1; 2)`) folds by its last
66
+ # expression only when every leading statement is eliminated when
67
+ # discarded, and these — bare or composing an Array/Hash/Range —
68
+ # always are. Pinned against real Coverage on 3.2 through 4.0.
69
+ STATIC_LITERAL_LEAF_TYPES = [
70
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
71
+ ::Prism::ImaginaryNode, ::Prism::StringNode, ::Prism::SymbolNode,
72
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
73
+ ::Prism::SourceLineNode, ::Prism::SourceFileNode, ::Prism::SourceEncodingNode, ::Prism::RegularExpressionNode
74
+ ].freeze
75
+
76
+ # Non-literal reads that are also eliminated when discarded.
77
+ # `self` is eliminated by every supported compiler; local/ivar/
78
+ # defined? elimination arrived with the Prism-era compilers.
79
+ # Anything that can raise or run hooks (constants, globals, calls,
80
+ # writes) is never eliminated and keeps the branch real.
81
+ PRISM_ERA_ELIMINABLE_READS = [
82
+ ::Prism::LocalVariableReadNode, ::Prism::InstanceVariableReadNode, ::Prism::DefinedNode
83
+ ].freeze
84
+
85
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
86
+ ELIMINABLE_READ_TYPES = [
87
+ ::Prism::SelfNode,
88
+ *(PRISM_ERA_ELIMINABLE_READS unless PARENS_ALWAYS_TRANSPARENT)
89
+ ].freeze
90
+ # simplecov:enable branch
91
+
92
+ private
93
+
94
+ # A truthy verdict keeps the first arm live, a falsy one the
95
+ # second. `visit` is nil-safe, so a missing arm just visits
96
+ # nothing. On 3.2 the dead arm's branch table entries survive the
97
+ # fold (parse.y instrumented branches before eliminating dead
98
+ # code, even inside dead `def` bodies and lambdas) while its
99
+ # methods never register, so the dead arm is visited with method
100
+ # collection suppressed.
101
+ def visit_folded_arms(verdict, truthy_arm, falsy_arm)
102
+ live, dead = verdict == :truthy ? [truthy_arm, falsy_arm] : [falsy_arm, truthy_arm]
103
+ visit(live)
104
+ # simplecov:disable branch — the taken arm is fixed by the running Ruby's version
105
+ visit_dead_arm(dead) if DEAD_ARM_BRANCHES_SURVIVE
106
+ # simplecov:enable branch
107
+ end
108
+
109
+ # simplecov:disable — 3.2-only; unreachable on the modern dogfood Ruby
110
+ def visit_dead_arm(arm)
111
+ # Save/restore rather than set/clear: a fold nested inside this
112
+ # dead arm re-enters here (possibly with a nil arm) and must not
113
+ # clear suppression for the rest of the outer dead arm.
114
+ previous = @suppress_methods
115
+ begin
116
+ @suppress_methods = true
117
+ visit(arm)
118
+ ensure
119
+ @suppress_methods = previous
120
+ end
121
+ end
122
+ # simplecov:enable
123
+
124
+ # The compiler's verdict on a condition: `:truthy` or `:falsy` when
125
+ # it folds, nil when it doesn't. The verdict decides which arm
126
+ # survives — the compiler eliminates the other arm's entire subtree,
127
+ # so everything inside it (nested branches, methods) must go
128
+ # unvisited too, not just the folded condition's own tuple.
129
+ #
130
+ # Parentheses are transparent to the fold for most literals
131
+ # (`if (1)` folds like `if 1`) but not all — see
132
+ # PAREN_OPAQUE_TYPES. Compound forms (`!true`, `true || x`) are
133
+ # deliberately not folded: `!` never folds, and `||` / `&&`
134
+ # constant-propagation diverges across Ruby versions, so matching
135
+ # it would trade a rare, version-specific gain for real risk.
136
+ def folded_condition(node)
137
+ unwrapped = unwrap_parentheses(node)
138
+ return nil unless foldable?(node, unwrapped)
139
+
140
+ FALSY_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) } ? :falsy : :truthy
141
+ end
142
+
143
+ # A folding literal, minus the ones parentheses shield from the
144
+ # fold (`unwrapped` differing from `node` is what says parentheses
145
+ # were seen through).
146
+ def foldable?(node, unwrapped)
147
+ return false unless STATIC_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) }
148
+
149
+ # simplecov:disable — which of these lines runs is fixed by the
150
+ # running Ruby's version: on 3.2 the early return always fires and
151
+ # the opacity check below it is dead code, so it must be excluded
152
+ # from line coverage too, not only branch coverage.
153
+ return true if PARENS_ALWAYS_TRANSPARENT
154
+
155
+ unwrapped.equal?(node) || PAREN_OPAQUE_TYPES.none? { |type| unwrapped.is_a?(type) }
156
+ # simplecov:enable
157
+ end
158
+
159
+ # Parentheses are transparent to the fold, and a multi-statement
160
+ # body (`if (1; 2)`) folds by its LAST expression — but only when
161
+ # the compiler eliminates every leading statement. Stopping at
162
+ # multi-statement bodies synthesized a phantom then/else pair no
163
+ # real run can ever hit.
164
+ def unwrap_parentheses(node)
165
+ # @type var current: untyped
166
+ current = node
167
+ while current.is_a?(::Prism::ParenthesesNode)
168
+ body = current.body
169
+ break unless body.is_a?(::Prism::StatementsNode) && !body.body.empty?
170
+
171
+ statements = body.body
172
+ break unless statements.take(statements.size - 1).all? { |leading| eliminable_when_discarded?(leading) }
173
+
174
+ current = statements.last
175
+ end
176
+ current
177
+ end
178
+
179
+ # Whether the compiler compiles `node` to nothing when its value is
180
+ # discarded.
181
+ def eliminable_when_discarded?(node)
182
+ return true if static_container_literal?(node)
183
+ return true if ELIMINABLE_READ_TYPES.any? { |type| node.is_a?(type) }
184
+
185
+ node.is_a?(::Prism::ParenthesesNode) &&
186
+ node.body.is_a?(::Prism::StatementsNode) &&
187
+ node.body.body.all? { |statement| eliminable_when_discarded?(statement) }
188
+ end
189
+
190
+ # A scalar literal leaf, or an Array/Hash/Range whose contents pass
191
+ # the running compiler's contents rule (see
192
+ # CONTAINER_CONTENTS_NEED_STATIC_LITERALS). Container elimination
193
+ # only exists from 3.3 on.
194
+ def static_container_literal?(node)
195
+ return true if STATIC_LITERAL_LEAF_TYPES.any? { |type| node.is_a?(type) }
196
+ # simplecov:disable — which of these lines runs is fixed by the
197
+ # running Ruby's version: parse.y (3.2) never eliminates
198
+ # container literals, so there the early return always fires and
199
+ # the dispatch below is dead code.
200
+ return false if PARENS_ALWAYS_TRANSPARENT
201
+
202
+ static_container?(node)
203
+ # simplecov:enable
204
+ end
205
+
206
+ # The container dispatch, version-free: the predicate specs
207
+ # exercise it directly on every supported Ruby, 3.2 included.
208
+ def static_container?(node)
209
+ case node
210
+ when ::Prism::ArrayNode then static_array_literal?(node)
211
+ when ::Prism::HashNode then static_hash_literal?(node)
212
+ when ::Prism::RangeNode then static_range_literal?(node)
213
+ else false
214
+ end
215
+ end
216
+
217
+ def static_array_literal?(node)
218
+ node.elements.all? { |element| container_contents_eliminable?(element) }
219
+ end
220
+
221
+ def static_hash_literal?(node)
222
+ node.elements.all? do |element|
223
+ element.is_a?(::Prism::AssocNode) &&
224
+ container_contents_eliminable?(element.key) && container_contents_eliminable?(element.value)
225
+ end
226
+ end
227
+
228
+ def static_range_literal?(node)
229
+ [node.left, node.right].all? { |bound| bound.nil? || container_contents_eliminable?(bound) }
230
+ end
231
+
232
+ def container_contents_eliminable?(node)
233
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
234
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS ? static_container_literal?(node) : eliminable_when_discarded?(node)
235
+ # simplecov:enable branch
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_compat"
4
+
5
+ module SimpleCov
6
+ module StaticCoverageExtractor
7
+ # The source ranges Ruby's Coverage assigns to branch conditions and
8
+ # arms, resolved from Prism nodes. Simulated entries only ever merge
9
+ # with real entries produced by the running Ruby, and CRuby 3.4
10
+ # changed several of these conventions, so every resolver here emits
11
+ # whichever shape this Ruby's Coverage uses. See issues #1226 / #1233.
12
+ #
13
+ # rubocop:disable Metrics/ModuleLength -- one cohesive home for the
14
+ # per-construct, per-Ruby-version Coverage location conventions;
15
+ # splitting it would scatter closely-related resolvers.
16
+ module LocationConventions
17
+ LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
18
+
19
+ # A zero-width stand-in for Prism locations, for the arms Coverage
20
+ # anchors to a point rather than a range.
21
+ PointLocation = Data.define(:start_line, :start_column, :end_line, :end_column)
22
+
23
+ private
24
+
25
+ # simplecov:disable
26
+ # Which arm of each conditional below runs is fixed by the running
27
+ # Ruby's version, so no single process can cover both sides, and
28
+ # the legacy-only helpers are unreachable on modern Rubies (and
29
+ # vice versa). The "runtime tuple equivalence" spec exercises this
30
+ # module against real Coverage output on every CI Ruby.
31
+
32
+ # The range Coverage assigns to an if-like node itself. Modern
33
+ # CRuby uses the node's full source range for every form; 3.2/3.3
34
+ # end an `elsif` clause's range at its last content instead of the
35
+ # shared `end` keyword the clause doesn't own.
36
+ def if_like_location(node, type)
37
+ return node.location unless LEGACY_COVERAGE_LOCATIONS && type == :if && elsif_node?(node)
38
+
39
+ span(node.location, legacy_content_end(node))
40
+ end
41
+
42
+ def elsif_node?(node)
43
+ keyword = node.if_keyword_loc
44
+ !keyword.nil? && keyword.slice == "elsif"
45
+ end
46
+
47
+ # Where an if/elsif chain's content ends, for the legacy range
48
+ # convention: the deepest trailing clause's statements, or that
49
+ # clause's predicate / `else` keyword when its body is empty.
50
+ def legacy_content_end(node)
51
+ tail = node
52
+ while tail.is_a?(::Prism::IfNode)
53
+ sub = PrismCompat.subsequent(tail)
54
+ break unless sub
55
+
56
+ tail = sub
57
+ end
58
+ return (tail.statements || tail.predicate).location if tail.is_a?(::Prism::IfNode)
59
+
60
+ tail.statements ? tail.statements.location : tail.else_keyword_loc
61
+ end
62
+
63
+ # Location of the then arm. Coverage uses the body statements'
64
+ # range; with an empty then body the arm collapses to a zero-width
65
+ # point at the predicate's end — always on a modern `if`, and on
66
+ # legacy Rubies only when the construct is in void position (a
67
+ # trailing statement discards its value). In value (tail) position,
68
+ # legacy Rubies and `unless` fall back to the node's range.
69
+ def if_like_then_location(node, type)
70
+ return node.statements.location if node.statements
71
+ return point_at_end(node.predicate.location) if empty_arm_collapses?(node, type)
72
+
73
+ if_like_location(node, type)
74
+ end
75
+
76
+ # Resolve the source range Coverage attributes to a real-or-synthetic
77
+ # `:else` arm of an if-like construct (`PrismCompat` hides the
78
+ # per-Prism-version accessor split). When no else/elsif is present,
79
+ # the synthesized else inherits the condition's range (matches
80
+ # Coverage's convention).
81
+ def if_like_else_location(node, type)
82
+ sub = PrismCompat.subsequent(node)
83
+ return if_like_location(node, type) unless sub
84
+ # An `elsif` arrives as a nested IfNode. Coverage attributes the
85
+ # outer else arm to the clause's own range, not its then body
86
+ # (which is what created phantom unmergeable arms).
87
+ return if_like_location(sub, :if) if sub.is_a?(::Prism::IfNode)
88
+ return sub.statements.location if sub.statements
89
+
90
+ empty_else_location(node, sub, type)
91
+ end
92
+
93
+ # Location of an empty explicit `else`: a modern `if` uses the
94
+ # else..end span; a legacy Ruby in void position collapses to a point
95
+ # at the `else` keyword's end; otherwise (legacy value position, or
96
+ # `unless`) it uses the condition's range.
97
+ def empty_else_location(node, sub, type)
98
+ return sub.location if !LEGACY_COVERAGE_LOCATIONS && type == :if
99
+ return point_at_end(sub.else_keyword_loc) if LEGACY_COVERAGE_LOCATIONS && !value_position?(node)
100
+
101
+ if_like_location(node, type)
102
+ end
103
+
104
+ # Arm location for a when/in clause: its body statements, or — when
105
+ # the body is empty — the clause's own range on modern Rubies, a
106
+ # point at the pattern's end for a legacy `in`, and for a legacy
107
+ # `when` a point at the clause's end in void position or the tail
108
+ # convention (keyword through the case's remaining content) in value.
109
+ def case_arm_location(case_node, when_node, when_type)
110
+ return when_node.statements.location if when_node.statements
111
+ return when_node.location unless LEGACY_COVERAGE_LOCATIONS
112
+ return point_at_end(when_node.pattern.location) if when_type == :in
113
+ return point_at_end(when_node.location) unless value_position?(case_node)
114
+
115
+ legacy_when_value_location(case_node, when_node)
116
+ end
117
+
118
+ def legacy_when_value_location(case_node, when_node)
119
+ span(when_node.location, legacy_case_tail_end(case_node, when_node))
120
+ end
121
+
122
+ # The last body content in the case after `when_node`, falling
123
+ # back to the clause's final condition value.
124
+ def legacy_case_tail_end(case_node, when_node)
125
+ following_case_content(case_node, when_node).last ||
126
+ (when_node.conditions.last || when_node).location
127
+ end
128
+
129
+ def following_case_content(case_node, when_node)
130
+ clauses = case_node.conditions
131
+ index = clauses.index { |clause| clause.equal?(when_node) } || 0
132
+ # A when-clause's own location ends where its body ends (or at its
133
+ # condition when empty), so the whole clause extends the range
134
+ # through trailing EMPTY clauses that have no `statements`.
135
+ content = clauses.drop(index + 1).map(&:location)
136
+ else_statements = PrismCompat.else_clause(case_node)&.statements
137
+ content << else_statements.location if else_statements
138
+ content
139
+ end
140
+
141
+ # Resolve the source range Coverage attributes to a synthetic-or-real
142
+ # `:else` arm of a case construct: the body of an explicit else,
143
+ # the case's full range when no else is present, and — for an
144
+ # explicit else with an empty body — the else..end span on modern
145
+ # Rubies or the case's full range on legacy ones.
146
+ def else_arm_location(node)
147
+ else_clause = PrismCompat.else_clause(node)
148
+ return node.location unless else_clause
149
+ return else_clause.statements.location if else_clause.statements
150
+ return else_clause.location unless LEGACY_COVERAGE_LOCATIONS
151
+ # Empty explicit `else`: a point at the `else` keyword's end in void
152
+ # position, the whole case's range in value position.
153
+ return point_at_end(else_clause.else_keyword_loc) unless value_position?(node)
154
+
155
+ node.location
156
+ end
157
+
158
+ # An empty loop body falls back to the loop's range on modern
159
+ # Rubies and collapses to a point at the predicate's end on legacy
160
+ # ones.
161
+ def loop_body_location(node)
162
+ return legacy_do_while_body_location(node) if LEGACY_COVERAGE_LOCATIONS && begin_modifier_loop?(node)
163
+ return node.statements.location if node.statements
164
+ return point_at_end(node.predicate.location) if LEGACY_COVERAGE_LOCATIONS
165
+
166
+ node.location
167
+ end
168
+
169
+ # `begin ... end while/until cond` (the do-while form) parses as a
170
+ # while/until whose sole statement is the BeginNode. Modern Coverage
171
+ # attributes the body to that whole `begin ... end` span (which the
172
+ # generic `node.statements.location` already yields), but 3.3 uses
173
+ # the begin's inner statements instead — or a point at the end of
174
+ # the `begin` keyword when the body is empty.
175
+ def begin_modifier_loop?(node)
176
+ node.respond_to?(:begin_modifier?) && node.begin_modifier?
177
+ end
178
+
179
+ def legacy_do_while_body_location(node)
180
+ begin_node = node.statements.body.first
181
+ inner = begin_node.statements
182
+ inner ? inner.location : point_at_end(begin_node.begin_keyword_loc)
183
+ end
184
+
185
+ # Coverage's safe-navigation branch spans the receiver through the
186
+ # end of the call's arguments (or just the message when there are
187
+ # none), but never includes a trailing block: `x&.foo { ... }` and
188
+ # `x&.foo(1) { ... }` both end exactly where `x&.foo` / `x&.foo(1)`
189
+ # would without the block. `node.location` includes an attached
190
+ # block, so build the end position from `closing_loc` (closing
191
+ # paren) / `arguments` (paren-less args) / `message_loc` instead.
192
+ # This convention is the same on legacy and modern Rubies. See
193
+ # issue #1233.
194
+ def safe_navigation_location(node)
195
+ span(node.location, node.closing_loc || node.arguments&.location || node.message_loc)
196
+ end
197
+
198
+ # The range from `from`'s start through `to`'s end.
199
+ def span(from, to)
200
+ PointLocation.new(
201
+ start_line: from.start_line, start_column: from.start_column,
202
+ end_line: to.end_line, end_column: to.end_column
203
+ )
204
+ end
205
+
206
+ def point_at_end(location)
207
+ PointLocation.new(
208
+ start_line: location.end_line, start_column: location.end_column,
209
+ end_line: location.end_line, end_column: location.end_column
210
+ )
211
+ end
212
+
213
+ # Whether an empty then arm collapses to a point at the predicate's
214
+ # end. Modern Coverage does this for every `if` (but not `unless`);
215
+ # legacy Coverage does it only in void position, for both.
216
+ def empty_arm_collapses?(node, type)
217
+ return type == :if unless LEGACY_COVERAGE_LOCATIONS
218
+
219
+ !value_position?(node)
220
+ end
221
+
222
+ # Whether `node` sits in value (method-return) position, which on
223
+ # legacy Rubies keeps an empty arm's range instead of collapsing it
224
+ # to a point. `@value_positions` is computed once per parse by
225
+ # ValuePositions (only on legacy; nil elsewhere, which reads as
226
+ # "value" — the safe, pre-audit default).
227
+ def value_position?(node)
228
+ return true if @value_positions.nil?
229
+
230
+ @value_positions.key?(node)
231
+ end
232
+ # simplecov:enable
233
+ end
234
+ # rubocop:enable Metrics/ModuleLength
235
+ end
236
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Visitor mixin that collects method tuples and tracks the lexical
6
+ # class / module nesting that names them, in the shape Ruby's
7
+ # `Coverage` reports methods. Mixed into `Visitor`, it shares that
8
+ # visitor's `@methods` / `@class_stack` state and keeps the
9
+ # method-collection concern separate from branch extraction.
10
+ module MethodCollector
11
+ # Track class/module nesting so method tuples carry the lexical
12
+ # class name. Module + Class are both treated as namespaces here
13
+ # since `Coverage` reports both as the constant.
14
+ def visit_class_node(node)
15
+ with_class(constant_name(node.constant_path)) { super }
16
+ end
17
+
18
+ def visit_module_node(node)
19
+ with_class(constant_name(node.constant_path)) { super }
20
+ end
21
+
22
+ # `def name(...)` and `def self.name(...)` both produce DefNode.
23
+ # The class context is the surrounding lexical class/module (or
24
+ # `Object` at the top level, matching `Coverage`'s convention).
25
+ # Suppression covers 3.2's folded dead arms, where nested branches
26
+ # stay instrumented but a `def` never registers (see
27
+ # Visitor#visit_dead_arm).
28
+ def visit_def_node(node)
29
+ # simplecov:disable branch — suppression is reachable on 3.2 only
30
+ return super if @suppress_methods
31
+
32
+ # simplecov:enable branch
33
+ loc = node.location
34
+ class_name = @class_stack.last || "Object"
35
+ key = [class_name, node.name, loc.start_line, loc.start_column, loc.end_line, loc.end_column]
36
+ @methods[key] = 0
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ # Render a constant path (e.g., `Foo::Bar`) as its source-form
43
+ # string. Defensive nil / to_s fallbacks: ClassNode and ModuleNode
44
+ # always carry a constant_path in practice.
45
+ # simplecov:disable
46
+ def constant_name(node)
47
+ return "<anonymous>" if node.nil?
48
+ return node.slice if node.respond_to?(:slice)
49
+
50
+ node.to_s
51
+ end
52
+ # simplecov:enable
53
+
54
+ def with_class(name)
55
+ @class_stack.push(name)
56
+ yield
57
+ ensure
58
+ @class_stack.pop
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # The Prism 1.3 (Dec 2024) accessor renames for the trailing clause of
6
+ # conditional nodes, resolved ONCE at load so the per-node hot paths
7
+ # stay branch-free. Ruby 3.3's stdlib Prism (0.19) predates the
8
+ # renames; 3.4+ and any installed prism gem post-dates them. Reaching
9
+ # for the modern name on 0.19 raised NoMethodError inside the
10
+ # extractor — `call` swallowed it and the whole file silently fell
11
+ # back to no simulated data (see the 1.0.2 audit).
12
+ #
13
+ # This lives in its own file, required by every consumer, because the
14
+ # constants are referenced from several extractor files and defining
15
+ # them after those files were loaded worked only while the references
16
+ # happened at call time — a load-order trap for the next editor.
17
+ module PrismCompat
18
+ module_function
19
+
20
+ # `Prism::IfNode#subsequent` was renamed from `consequent`. The
21
+ # not-taken arm on whichever Prism version we're on can't be
22
+ # exercised by our own dogfood (we only run on one Prism at a time).
23
+ # simplecov:disable
24
+ IF_NODE_SUBSEQUENT_METHOD =
25
+ if ::Prism::IfNode.method_defined?(:subsequent)
26
+ :subsequent
27
+ else
28
+ :consequent
29
+ end
30
+
31
+ # The same rename hit the `else` accessor on `UnlessNode`,
32
+ # `CaseNode`, and `CaseMatchNode` (all three: `consequent` ->
33
+ # `else_clause`). All three renamed together, so one constant
34
+ # (probed off CaseNode) covers them.
35
+ ELSE_CLAUSE_METHOD =
36
+ if ::Prism::CaseNode.method_defined?(:else_clause)
37
+ :else_clause
38
+ else
39
+ :consequent
40
+ end
41
+ # simplecov:enable
42
+
43
+ # The `else`/`elsif` clause of an if-like node (an ElseNode, or a
44
+ # nested IfNode for `elsif`), or the `else` clause of anything else
45
+ # that has one, under whichever accessor this Prism exposes.
46
+ def subsequent(node)
47
+ node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : else_clause(node)
48
+ end
49
+
50
+ def else_clause(node)
51
+ node.public_send(ELSE_CLAUSE_METHOD)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_compat"
4
+
5
+ module SimpleCov
6
+ module StaticCoverageExtractor
7
+ # Ruby 3.3 value-position analysis for the extractor's legacy branch
8
+ # conventions (see LocationConventions and the #1233 audit).
9
+ #
10
+ # On Ruby 3.3, the source range Coverage assigns to an EMPTY branch arm
11
+ # depends on whether its construct is in value position — its result is
12
+ # the method's return value — or void position, where the result is
13
+ # discarded. Value position keeps the whole-construct range; void
14
+ # collapses the arm to a point at its header's end. Ruby 3.4 dropped the
15
+ # distinction, so this pass only runs on legacy Rubies.
16
+ #
17
+ # "Value position" here is narrower than general value-use: it is
18
+ # strictly method-return (tail) position. It reaches a node only through
19
+ # statement tails and `if`/`unless`/`when` arms. Assignments, blocks,
20
+ # lambdas, method arguments, `case/in` arms, and loop bodies all discard
21
+ # it (Coverage treats their empty arms as void). So `tail_children`
22
+ # names the constructs that forward tail position and everything else
23
+ # falls through to the void default.
24
+ module ValuePositions
25
+ module_function
26
+
27
+ # simplecov:disable
28
+ # This whole pass runs only on legacy Rubies (the modern dogfood
29
+ # never calls it), so its lines can't be covered on the CI Ruby that
30
+ # enforces 100%. Behavior is pinned instead by the differential
31
+ # tuple-equivalence spec, which runs against real Coverage on 3.3.
32
+
33
+ # An identity set (a `compare_by_identity` Hash used as a set) of the
34
+ # Prism nodes Coverage treats as being in value position.
35
+ def call(root)
36
+ positions = {} #: Hash[untyped, bool]
37
+ positions.compare_by_identity
38
+ mark(root, true, positions)
39
+ positions
40
+ end
41
+
42
+ def mark(node, in_value, positions)
43
+ return unless node.is_a?(::Prism::Node)
44
+
45
+ positions[node] = true if in_value
46
+ children = tail_children(node, in_value)
47
+ node.compact_child_nodes.each do |child|
48
+ mark(child, children.any? { |c| c.equal?(child) }, positions)
49
+ end
50
+ end
51
+
52
+ # The children of `node` that inherit its tail position; empty for the
53
+ # void default. A method body is a tail context even when the `def`
54
+ # itself is not (the method still returns its last expression), so it
55
+ # is included regardless of `in_value`.
56
+ def tail_children(node, in_value)
57
+ # A method body is a tail context even when the `def` is not.
58
+ return [node.body] if node.is_a?(::Prism::DefNode)
59
+ return [] unless in_value
60
+
61
+ # `case/in` (CaseMatchNode) is intentionally not a tail construct:
62
+ # its `in` arms and `else` both discard tail position.
63
+ case node
64
+ when ::Prism::StatementsNode then [node.body.last]
65
+ when ::Prism::IfNode, ::Prism::UnlessNode then [node.statements, PrismCompat.subsequent(node)]
66
+ when ::Prism::CaseNode then [*node.conditions, PrismCompat.else_clause(node)]
67
+ when ::Prism::ElseNode, ::Prism::WhenNode, ::Prism::BeginNode, ::Prism::ProgramNode then [node.statements]
68
+ else []
69
+ end
70
+ end
71
+ # simplecov:enable
72
+ end
73
+ end
74
+ end