rubocop-rspec_parity 1.4.6 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce7a1961eafacef8055e79414d28cfd37157f3d56aa8317befaa6e03ec8e1351
4
- data.tar.gz: 3dc54f2de9deb5a2b5636d87aa2e0faabf625e62c4ab7540958f85ef92771868
3
+ metadata.gz: 2fc4286fcc69b46b490ae21db9ce9681ca7dd25577521e329d0bc3d89b33940a
4
+ data.tar.gz: da7872a16a143f8d44a178c7b1912996c8d5213fb1538a8f4231e9ceedf361dc
5
5
  SHA512:
6
- metadata.gz: 56b3b3a5dd71d2360779382c0f4e8661c8c84097eacff2a76c9c4c8a53f3d1f1d0487f5e00f391923437c01ee72a7b7092ef8c6ccd74562aa0cf413138ea5498
7
- data.tar.gz: 262cf615d136461f78253e2412cfb16f4aa270793c36789d7f4494071d95a0908a58f745983d842cdcb19d9ce1b9f054e3471c76ec919e1f1d7f567f3f08a53f
6
+ metadata.gz: 3d9f782158d6c053256401c6b687eb8b22dd1780f444b42783609b428ea0f82898a33e5c502818df86be8e56847a7be8012a99ead9c3aaffe91e5420d7be5a66
7
+ data.tar.gz: 44b7a03a46444bcacbec096e659fd435dc288278ba1f14d72d87311d1bca1b4feb32643c68c6fcb3ff2fbb5be1118adddddb8e4ec164b4d710cc00efbb287bc5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.6.0] - 2026-06-11
4
+
5
+ Fixed: `SufficientContexts` now counts each `it`/`example` within a single context as a separate scenario, so specs that cover branches with multiple examples (instead of separate contexts) no longer trigger false violations
6
+ Updated: `SufficientContexts` violation message now explains that each branch needs one `context` or `it`, and that compound conditions like `a && b` need a scenario per operand
7
+
8
+ ## [1.5.0] - 2026-05-21
9
+
10
+ Added: `SufficientContexts` now inlines branches from private/protected helpers that are called from exactly one site in the same class (controlled by the new `TraceSingleUsePrivateMethods` config key, default `true`). Violation messages list the helpers whose branches were counted.
11
+
3
12
  ## [1.4.6] - 2026-04-10
4
13
 
5
14
  Fixed: Method name matching no longer falsely matches substring occurrences in unrelated context descriptions
data/config/default.yml CHANGED
@@ -32,5 +32,6 @@ RSpecParity/SufficientContexts:
32
32
  Description: 'Ensures specs have at least as many contexts as the method has branches.'
33
33
  Enabled: true
34
34
  IgnoreMemoization: true
35
+ TraceSingleUsePrivateMethods: true
35
36
  SkipMethodDescribeFor: []
36
37
  DescribeAliases: {}
@@ -0,0 +1,204 @@
1
+ # Trace branches through single-use private methods
2
+
3
+ ## Problem
4
+
5
+ `RSpecParity/SufficientContexts` currently counts branches only inside the body of the public method under inspection. When a public method is decomposed into private helpers — idiomatic Ruby style — the branches in those helpers don't count toward the required spec-context total. The cop effectively penalizes clean decomposition.
6
+
7
+ ## Goal
8
+
9
+ When a private (or protected) method is called from exactly one place in the same class/module, treat its branches as belonging to the caller. Apply transitively: if public `P` calls single-use private `A` which calls single-use private `B`, `B`'s branches roll into `P`'s total.
10
+
11
+ ## Configuration
12
+
13
+ Single key on `RSpecParity/SufficientContexts`:
14
+
15
+ ```yaml
16
+ RSpecParity/SufficientContexts:
17
+ TraceSingleUsePrivateMethods: true # default
18
+ ```
19
+
20
+ Set to `false` to restore the prior behavior.
21
+
22
+ No max-depth knob — depth is unlimited.
23
+
24
+ ## Behavior
25
+
26
+ ### What gets inlined
27
+
28
+ A private or protected method's branches roll into the caller's total when:
29
+
30
+ - the method is defined in the same class/module body as the caller
31
+ - it has exactly one static call site across the whole class/module body
32
+ - the enclosing class/module has no dynamic dispatch (see below)
33
+
34
+ Inlining is transitive: traversal follows the call graph from the public method, descending into any callee that satisfies the rules. A visited-set prevents double counting in diamond shapes and protects against mutual-recursion cycles.
35
+
36
+ Instance methods (`def foo`) and class methods (`def self.foo`) live in separate namespaces in the graph — a class method calling `bar` resolves only to a class-level `bar`.
37
+
38
+ ### Dynamic dispatch — class-wide opt out
39
+
40
+ If the class/module body contains any of the following, the call graph is considered untrustworthy and **no inlining occurs anywhere in that class**:
41
+
42
+ - `send` / `public_send` with a non-symbol-literal argument
43
+ - `define_method`
44
+ - `method_missing` / `respond_to_missing?` definitions
45
+ - `class_eval` / `instance_eval` / `module_eval` with a string argument
46
+ - `method(:foo)` / `&:foo` (symbol-to-proc) references — these resolve methods symbolically and may extend reach beyond statically visible call sites
47
+
48
+ This is conservative by design — a false-positive "missing context" warning is worse UX than a missed inlining opportunity.
49
+
50
+ ### Branchless helpers
51
+
52
+ A private method whose own body contributes zero branches:
53
+
54
+ - still has its callees traversed (it may call branchy helpers)
55
+ - is **not** named in the `traced_methods` list reported to the user
56
+ - doesn't count toward the user-visible "branches came from these methods" message
57
+
58
+ ### Visibility tracking
59
+
60
+ The class-body walker recognizes all visibility forms:
61
+
62
+ - modifier `send` nodes — `public`, `private`, `protected` on their own lines
63
+ - declaration form — `private :foo, :bar`, `private_class_method :foo`
64
+ - inline form — `private def foo …` and `private_class_method def self.foo …`
65
+ - `class << self` blocks for class-method visibility
66
+
67
+ ## Architecture
68
+
69
+ ### New file: `lib/rubocop/cop/rspec_parity/private_method_call_graph.rb`
70
+
71
+ Class `RuboCop::Cop::RSpecParity::PrivateMethodCallGraph`.
72
+
73
+ **Constructor**
74
+
75
+ ```ruby
76
+ PrivateMethodCallGraph.new(container_node)
77
+ ```
78
+
79
+ `container_node` is the enclosing `:class`, `:module`, or `:sclass` AST node, or `nil` when the def is at top level (in which case nothing inlines).
80
+
81
+ **Public API**
82
+
83
+ ```ruby
84
+ graph.dynamic_dispatch?
85
+ # => Boolean
86
+
87
+ graph.inlinable_from(method_node, branch_counter)
88
+ # branch_counter: a callable (->(node) { Integer })
89
+ # returns: Struct.new(:branches, :traced_methods)
90
+ # branches — total extra branches from reachable single-use helpers
91
+ # traced_methods — Array<String> of names of helpers that contributed > 0 branches,
92
+ # ordered by source position (deterministic)
93
+ ```
94
+
95
+ **Internal data**
96
+
97
+ ```
98
+ @methods = {
99
+ [:instance, "foo"] => { node:, visibility: :private, callees: Set[[:instance, "bar"]] },
100
+ [:class, "build"] => { node:, visibility: :public, callees: Set[…] },
101
+
102
+ }
103
+
104
+ @call_count = { method_key => Integer } # union-count across all methods' callees
105
+ ```
106
+
107
+ The graph is built lazily on first use and memoized on the instance. `SufficientContexts` keeps one graph per container node (memoized via `node.equal?` identity).
108
+
109
+ ### Changes to `SufficientContexts`
110
+
111
+ 1. New instance var `@trace_single_use_private = cop_config.fetch("TraceSingleUsePrivateMethods", true)` in `initialize`.
112
+ 2. New instance var `@call_graphs = {}.compare_by_identity` for per-container memoization.
113
+ 3. In `check_method`, after computing `branches = count_branches(node)`:
114
+ ```ruby
115
+ traced_methods = []
116
+ if @trace_single_use_private
117
+ container = find_class_or_module(node)
118
+ if container
119
+ graph = (@call_graphs[container] ||= PrivateMethodCallGraph.new(container))
120
+ extra = graph.inlinable_from(node, method(:count_branches))
121
+ branches += extra.branches
122
+ traced_methods = extra.traced_methods
123
+ end
124
+ end
125
+ ```
126
+ 4. Pass `traced_methods` into the message format. Update `MSG` to a base + optional suffix:
127
+ ```ruby
128
+ BASE_MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only " \
129
+ "%<contexts>d %<context_word>s in spec. Add %<missing>d more %<missing_word>s " \
130
+ "to cover all branches."
131
+ TRACED_SUFFIX = " (including branches from: %<traced>s)"
132
+ ```
133
+ Append `TRACED_SUFFIX` only when `traced_methods` is non-empty.
134
+
135
+ ### Config defaults
136
+
137
+ `config/default.yml` — add under `RSpecParity/SufficientContexts`:
138
+
139
+ ```yaml
140
+ TraceSingleUsePrivateMethods: true
141
+ ```
142
+
143
+ ## Edge cases
144
+
145
+ - **Top-level defs** — no container → no inlining (graph is never built).
146
+ - **`include`d / inherited methods** — invisible; not in `@methods`; never inflate counts. Calls *to* such methods don't appear in any helper's `callees`, so they don't push another method's call count up either. Correct.
147
+ - **`super`** — doesn't reference a name we track. No effect on counts. Correct.
148
+ - **Mutual recursion** — `priv_a` calls `priv_b`, `priv_b` calls `priv_a`, both used once from `pub`. Call count for each is 2 (one from `pub`, one from the other). Neither inlines. Correct.
149
+ - **Diamond** — `pub` → `priv_a`, `pub` → `priv_b`, both call `priv_c`. `priv_c` call count is 2, doesn't inline. `priv_a` and `priv_b` each have call count 1 → do inline.
150
+ - **`private def foo …`** — at AST level this is a `:send` whose argument is the `:def`. Walker handles both: detects the `:def` child, registers visibility, recurses into the def body for callees.
151
+ - **`class << self`** — the `:sclass` body adds class-method entries with their own visibility state.
152
+ - **Method named the same as a Kernel method** (e.g. `format`) — call-site detection uses receiver=`nil` send nodes whose method name matches a defined method in the same container. Kernel calls without a same-named local def are not in `@methods` and are ignored.
153
+
154
+ ## Testing
155
+
156
+ ### New file: `spec/rubocop/cop/rspec_parity/private_method_call_graph_spec.rb`
157
+
158
+ Pure unit tests on the graph class — no RuboCop cop infrastructure. Parse a source string via `RuboCop::ProcessedSource`, get the class node, exercise the API. Covers:
159
+
160
+ - Empty class → graph returns 0 branches, no traced methods
161
+ - Single-use private with one branch → 1, [name]
162
+ - Two-deep chain → both contribute, both named
163
+ - Branchless intermediate → traversal continues, intermediate excluded from traced list
164
+ - Two-caller helper → not inlined
165
+ - Diamond → only single-use intermediates inline
166
+ - Mutual recursion → neither inlines
167
+ - Class method calling private class method → traced
168
+ - Class method calling instance method of same name → not linked
169
+ - `private def foo …` form recognized
170
+ - `private :foo` declaration form recognized
171
+ - Dynamic dispatch markers each trigger `dynamic_dispatch?`:
172
+ - `send(var)` (non-literal)
173
+ - `send(:foo)` with literal — does NOT trigger (literal symbol is safe — but per design we still treat it as dynamic because we can't statically prove the symbol set is closed; cover this in tests as a deliberate conservative choice — see Open Questions)
174
+ - `define_method(:foo) { … }`
175
+ - `method_missing` def
176
+ - `class_eval("def x; end")`
177
+ - `method(:foo)`
178
+ - `&:foo` block argument
179
+
180
+ ### Extensions to `spec/rubocop/cop/rspec_parity/sufficient_contexts_spec.rb`
181
+
182
+ - Public method with single-use private helper → reports inflated branch count, message includes helper name
183
+ - Multi-context spec satisfies inflated count → no offense
184
+ - Two callers of same helper → no inflation
185
+ - `TraceSingleUsePrivateMethods: false` config → behavior identical to current cop
186
+ - Class with dynamic dispatch → no inflation, no traced_methods in message
187
+ - Branchless helper that calls branchy helper → only branchy helper named in message
188
+
189
+ ## Performance
190
+
191
+ Per file, RuboCop already parses and walks the AST. The new work is:
192
+
193
+ - One pass over each class/module body to populate `@methods` (linear in body size)
194
+ - One additional pass to count callees per method (linear in total send nodes)
195
+ - Per public-method check: DFS over reachable single-use helpers — bounded by total methods in the class
196
+
197
+ Class graphs are memoized by container identity, so a file with N public methods builds the graph once, not N times. Negligible compared to RuboCop's existing parsing and node-walking.
198
+
199
+ ## Open questions resolved during design
200
+
201
+ - **Default value** — `true`. Opt-out rather than opt-in. The user accepts that existing users may see new offenses on their next gem update; the new behavior is more accurate.
202
+ - **Protected methods** — treated the same as private (still internal API).
203
+ - **Class methods** — same treatment as instance methods, separate namespace in the graph.
204
+ - **Symbol-to-proc / `method(:foo)`** — treated as dynamic dispatch (conservative). The set of method references through these forms is hard to bound statically, and the cost of being conservative is just "no inlining for this class" not "wrong behavior".
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module RSpecParity
6
+ # Builds a static call graph for the methods defined directly inside a
7
+ # class/module node. Used by SufficientContexts to inline branches from
8
+ # private/protected helpers that are called from exactly one place in
9
+ # the same container.
10
+ class PrivateMethodCallGraph # rubocop:disable Metrics/ClassLength
11
+ Result = Struct.new(:branches, :traced_methods)
12
+
13
+ DYNAMIC_DISPATCH_SENDS = %i[send public_send __send__].freeze
14
+ EVAL_METHODS = %i[class_eval instance_eval module_eval].freeze
15
+
16
+ def initialize(container_node)
17
+ @container = container_node
18
+ @methods = {}
19
+ @order = []
20
+ @built = false
21
+ end
22
+
23
+ def dynamic_dispatch?
24
+ return false unless @container
25
+
26
+ @dynamic_dispatch ||= detect_dynamic_dispatch
27
+ @dynamic_dispatch == :yes
28
+ end
29
+
30
+ def inlinable_from(method_node, branch_counter)
31
+ return Result.new(0, []) unless @container
32
+ return Result.new(0, []) if dynamic_dispatch?
33
+
34
+ build! unless @built
35
+
36
+ key = key_for(method_node)
37
+ return Result.new(0, []) unless @methods.key?(key)
38
+
39
+ traverse(key, branch_counter)
40
+ end
41
+
42
+ private
43
+
44
+ def traverse(start_key, branch_counter)
45
+ state = { visited: Set.new([start_key]), total: 0, traced: [] }
46
+ stack = callees_of(start_key)
47
+ until stack.empty?
48
+ key = stack.shift
49
+ next unless inlinable?(key, state[:visited])
50
+
51
+ visit_callee(key, branch_counter, state)
52
+ stack.concat(callees_of(key))
53
+ end
54
+ Result.new(state[:total], sorted_names(state[:traced]))
55
+ end
56
+
57
+ def callees_of(key)
58
+ @methods[key][:callees].to_a
59
+ end
60
+
61
+ def visit_callee(key, branch_counter, state)
62
+ state[:visited] << key
63
+ count = branch_counter.call(@methods[key][:node])
64
+ return unless count.positive?
65
+
66
+ state[:total] += count
67
+ state[:traced] << key
68
+ end
69
+
70
+ def inlinable?(key, visited)
71
+ return false if visited.include?(key)
72
+ return false unless @methods.key?(key)
73
+ return false if @methods[key][:visibility] == :public
74
+
75
+ call_count(key) == 1
76
+ end
77
+
78
+ def call_count(key)
79
+ @call_counts ||= compute_call_counts
80
+ @call_counts[key] || 0
81
+ end
82
+
83
+ def compute_call_counts
84
+ counts = Hash.new(0)
85
+ @methods.each_value do |entry|
86
+ entry[:callees].each { |callee_key| counts[callee_key] += 1 }
87
+ end
88
+ counts
89
+ end
90
+
91
+ def sorted_names(keys)
92
+ keys.sort_by { |k| @order.index(k) || @order.size }
93
+ .map { |k| @methods[k][:name] }
94
+ end
95
+
96
+ def key_for(node)
97
+ [node.defs_type? ? :class : :instance, node.method_name.to_s]
98
+ end
99
+
100
+ # ---------- Build phase ----------
101
+
102
+ def build!
103
+ @built = true
104
+ walk_body(body_children(@container), :public, :instance)
105
+ @methods.each_value { |entry| entry[:callees] = collect_callees(entry[:node], entry[:namespace]) }
106
+ end
107
+
108
+ def walk_body(children, visibility, namespace) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
109
+ children.each do |child|
110
+ next unless child
111
+
112
+ case child.type
113
+ when :def
114
+ register_method(child, :instance, visibility) if namespace == :instance
115
+ register_method(child, :class, visibility) if namespace == :class
116
+ when :defs
117
+ register_method(child, :class, :public)
118
+ when :send
119
+ visibility = handle_visibility_send(child, visibility, namespace) || visibility
120
+ when :sclass
121
+ walk_body(body_children(child), :public, :class)
122
+ end
123
+ end
124
+ end
125
+
126
+ def register_method(def_node, kind, visibility)
127
+ name = def_node.method_name.to_s
128
+ key = [kind, name]
129
+ @methods[key] = { name: name, node: def_node, visibility: visibility, namespace: kind, callees: Set.new }
130
+ @order << key
131
+ end
132
+
133
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
134
+ def handle_visibility_send(send_node, current_visibility, namespace)
135
+ name = send_node.method_name
136
+ args = send_node.arguments
137
+
138
+ if %i[public private protected].include?(name)
139
+ kind = namespace == :class ? :class : :instance
140
+ if args.empty?
141
+ return name
142
+ elsif args.first.def_type?
143
+ register_method(args.first, kind, name)
144
+ return current_visibility
145
+ else
146
+ apply_named_visibility(args, name, kind)
147
+ return current_visibility
148
+ end
149
+ end
150
+
151
+ if %i[private_class_method public_class_method].include?(name)
152
+ target_vis = name == :private_class_method ? :private : :public
153
+ if args.first&.defs_type? || args.first&.def_type?
154
+ register_method(args.first, :class, target_vis)
155
+ else
156
+ apply_named_visibility(args, target_vis, :class)
157
+ end
158
+ end
159
+
160
+ current_visibility
161
+ end
162
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
163
+
164
+ def apply_named_visibility(args, visibility, kind)
165
+ args.each do |arg|
166
+ next unless arg.sym_type? || arg.str_type?
167
+
168
+ key = [kind, arg.value.to_s]
169
+ @methods[key][:visibility] = visibility if @methods.key?(key)
170
+ end
171
+ end
172
+
173
+ # ---------- Callee collection ----------
174
+
175
+ def collect_callees(def_node, namespace)
176
+ callees = Set.new
177
+ body = def_node.body
178
+ return callees unless body
179
+
180
+ each_send(body) do |send_node|
181
+ add_callee(callees, send_node, namespace)
182
+ end
183
+ callees
184
+ end
185
+
186
+ def each_send(node, &block)
187
+ return unless node.is_a?(RuboCop::AST::Node)
188
+ return if node.def_type? || node.defs_type?
189
+
190
+ yield node if node.send_type?
191
+ node.children.each { |child| each_send(child, &block) if child.is_a?(RuboCop::AST::Node) }
192
+ end
193
+
194
+ def add_callee(callees, send_node, namespace)
195
+ receiver = send_node.receiver
196
+ return unless receiver.nil? || receiver.self_type?
197
+
198
+ name = literal_send_target(send_node) || send_node.method_name.to_s
199
+ key = [namespace, name]
200
+ callees << key if @methods.key?(key)
201
+ end
202
+
203
+ # `send(:foo)` / `public_send(:foo)` with a literal symbol → treat as a static call to `foo`.
204
+ def literal_send_target(send_node)
205
+ return nil unless DYNAMIC_DISPATCH_SENDS.include?(send_node.method_name)
206
+
207
+ first_arg = send_node.arguments.first
208
+ return nil unless first_arg&.sym_type? || first_arg&.str_type?
209
+
210
+ first_arg.value.to_s
211
+ end
212
+
213
+ # ---------- Dynamic dispatch detection ----------
214
+
215
+ def detect_dynamic_dispatch
216
+ return :no unless @container&.body
217
+
218
+ found = false
219
+ walk_for_dynamic(@container.body) { found = true }
220
+ found ? :yes : :no
221
+ end
222
+
223
+ def walk_for_dynamic(node, &block)
224
+ return unless node.is_a?(RuboCop::AST::Node)
225
+ return yield if dynamic_dispatch_node?(node)
226
+
227
+ node.children.each { |child| walk_for_dynamic(child, &block) if child.is_a?(RuboCop::AST::Node) }
228
+ end
229
+
230
+ def dynamic_dispatch_node?(node)
231
+ return method_missing_def?(node) if node.def_type?
232
+ return symbol_to_proc_block_pass?(node) if node.block_pass_type?
233
+ return false unless node.send_type?
234
+
235
+ dynamic_send?(node) || define_method?(node) || string_eval?(node) || method_reference?(node)
236
+ end
237
+
238
+ def method_missing_def?(node)
239
+ %i[method_missing respond_to_missing?].include?(node.method_name)
240
+ end
241
+
242
+ def symbol_to_proc_block_pass?(node)
243
+ node.children.first&.sym_type?
244
+ end
245
+
246
+ def dynamic_send?(node)
247
+ return false unless DYNAMIC_DISPATCH_SENDS.include?(node.method_name)
248
+
249
+ first = node.arguments.first
250
+ return false if first.nil?
251
+ return false if first.sym_type? || first.str_type?
252
+
253
+ true
254
+ end
255
+
256
+ def define_method?(node)
257
+ node.method_name == :define_method
258
+ end
259
+
260
+ def string_eval?(node)
261
+ return false unless EVAL_METHODS.include?(node.method_name)
262
+
263
+ node.arguments.any? { |arg| arg.str_type? || arg.dstr_type? }
264
+ end
265
+
266
+ def method_reference?(node)
267
+ node.method_name == :method && node.arguments.size == 1 && node.arguments.first.sym_type?
268
+ end
269
+
270
+ def body_children(node)
271
+ return [] unless node&.body
272
+
273
+ node.body.begin_type? ? node.body.children : [node.body]
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "spec_file_finder"
4
+ require_relative "private_method_call_graph"
4
5
 
5
6
  module RuboCop
6
7
  module Cop
@@ -41,8 +42,11 @@ module RuboCop
41
42
  include DepartmentConfig
42
43
  include SpecFileFinder
43
44
 
44
- MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only %<contexts>d %<context_word>s " \
45
- "in spec. Add %<missing>d more %<missing_word>s to cover all branches."
45
+ MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but the spec covers only " \
46
+ "%<contexts>d %<scenario_word>s. Add %<missing>d more %<missing_word>s " \
47
+ "(one `context` or `it` per branch; compound conditions like `a && b` need a scenario per operand)."
48
+
49
+ TRACED_SUFFIX = " (including branches from: %<traced>s)"
46
50
 
47
51
  APP_DIR_PATTERN = %r{/app/}
48
52
 
@@ -56,9 +60,14 @@ module RuboCop
56
60
  /^autosave_/
57
61
  ].freeze
58
62
 
63
+ # Tallies extracted from a spec's text for a single method describe block.
64
+ ParsedSpec = Struct.new(:context_count, :example_count, :has_examples, :has_direct_examples)
65
+
59
66
  def initialize(config = nil, options = nil)
60
67
  super
61
68
  @ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
69
+ @trace_single_use_private = cop_config.fetch("TraceSingleUsePrivateMethods", true)
70
+ @call_graphs = {}.compare_by_identity
62
71
  end
63
72
 
64
73
  def on_def(node)
@@ -76,6 +85,12 @@ module RuboCop
76
85
  return if excluded_method?(method_name(node))
77
86
 
78
87
  branches = count_branches(node)
88
+ traced_methods = []
89
+ if @trace_single_use_private
90
+ extra = inlined_branches(node)
91
+ branches += extra.branches
92
+ traced_methods = extra.traced_methods
93
+ end
79
94
  return if branches < 2 # Only check methods with branches
80
95
 
81
96
  class_name = extract_class_name(node)
@@ -101,15 +116,28 @@ module RuboCop
101
116
  return if contexts >= branches
102
117
 
103
118
  missing = branches - contexts
104
- add_offense(node,
105
- message: format(MSG,
106
- method_name: method_name(node),
107
- branches: branches,
108
- branch_word: pluralize("branch", branches),
109
- contexts: contexts,
110
- context_word: pluralize("context", contexts),
111
- missing: missing,
112
- missing_word: pluralize("context", missing)))
119
+ add_offense(node, message: build_message(node, branches, contexts, missing, traced_methods))
120
+ end
121
+
122
+ def build_message(node, branches, contexts, missing, traced_methods)
123
+ message = format(MSG,
124
+ method_name: method_name(node),
125
+ branches: branches,
126
+ branch_word: pluralize("branch", branches),
127
+ contexts: contexts,
128
+ scenario_word: pluralize("scenario", contexts),
129
+ missing: missing,
130
+ missing_word: pluralize("scenario", missing))
131
+ message += format(TRACED_SUFFIX, traced: traced_methods.join(", ")) if traced_methods.any?
132
+ message
133
+ end
134
+
135
+ def inlined_branches(node)
136
+ container = find_class_or_module(node)
137
+ return PrivateMethodCallGraph::Result.new(0, []) unless container
138
+
139
+ graph = (@call_graphs[container] ||= PrivateMethodCallGraph.new(container))
140
+ graph.inlinable_from(node, method(:count_branches))
113
141
  end
114
142
 
115
143
  def method_name(node)
@@ -207,21 +235,38 @@ module RuboCop
207
235
 
208
236
  def count_contexts_for_method(spec_content, method_name)
209
237
  method_pattern = Regexp.escape(method_name)
210
- context_count, has_examples, has_direct_examples = parse_spec_content(spec_content, method_pattern)
238
+ result = parse_spec_content(spec_content, method_pattern)
239
+
240
+ scenario_count(
241
+ result.context_count, result.example_count,
242
+ has_examples: result.has_examples, has_direct_examples: result.has_direct_examples
243
+ )
244
+ end
245
+
246
+ # A test scenario is the smaller unit between "a context block" and "an
247
+ # `it`/`example`". A single context whose examples each exercise a branch
248
+ # covers as many scenarios as it has examples, so the scenario count is
249
+ # the larger of the context-based count and the raw example count. Empty
250
+ # placeholder contexts (no examples) still count via the context-based
251
+ # path, so this never under-counts relative to the old behaviour.
252
+ def scenario_count(context_count, example_count, has_examples:, has_direct_examples:)
253
+ context_based =
254
+ if context_count.positive? && has_direct_examples
255
+ context_count + 1
256
+ elsif context_count.zero? && has_examples
257
+ 1
258
+ else
259
+ context_count
260
+ end
211
261
 
212
- if context_count.positive? && has_direct_examples
213
- context_count + 1
214
- elsif context_count.zero? && has_examples
215
- 1
216
- else
217
- context_count
218
- end
262
+ [context_based, example_count].max
219
263
  end
220
264
 
221
265
  # rubocop:disable Metrics/MethodLength
222
266
  def parse_spec_content(spec_content, method_pattern) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
223
267
  in_method_block = false
224
268
  context_count = 0
269
+ example_count = 0
225
270
  has_examples = false
226
271
  has_direct_examples = false
227
272
  base_indent = 0
@@ -245,6 +290,7 @@ module RuboCop
245
290
  context_count += 1
246
291
  elsif nested_example?(line)
247
292
  has_examples = true
293
+ example_count += 1
248
294
  child_indent ||= current_indent
249
295
  has_direct_examples = true if current_indent == child_indent
250
296
  end
@@ -253,7 +299,7 @@ module RuboCop
253
299
  end
254
300
  end
255
301
 
256
- [context_count, has_examples, has_direct_examples]
302
+ ParsedSpec.new(context_count, example_count, has_examples, has_direct_examples)
257
303
  end
258
304
  # rubocop:enable Metrics/MethodLength
259
305
 
@@ -283,7 +329,6 @@ module RuboCop
283
329
 
284
330
  case word
285
331
  when "branch" then "branches"
286
- when "context" then "contexts"
287
332
  else "#{word}s"
288
333
  end
289
334
  end
@@ -423,6 +468,7 @@ module RuboCop
423
468
  # Count contexts/describes and examples at the top level (under class describe)
424
469
  base_indent = lines[describe_line_index].match(/^(\s*)/)[1].length
425
470
  context_count = 0
471
+ example_count = 0
426
472
  has_examples = false
427
473
  has_direct_examples = false
428
474
  child_indent = nil
@@ -438,18 +484,13 @@ module RuboCop
438
484
  context_count += 1
439
485
  elsif line.match?(/^\s*(?:it|example|specify)\s+/)
440
486
  has_examples = true
487
+ example_count += 1
441
488
  child_indent ||= indent
442
489
  has_direct_examples = true if indent == child_indent
443
490
  end
444
491
  end
445
492
 
446
- if context_count.positive? && has_direct_examples
447
- context_count + 1
448
- elsif context_count.zero? && has_examples
449
- 1
450
- else
451
- context_count
452
- end
493
+ scenario_count(context_count, example_count, has_examples:, has_direct_examples:)
453
494
  end
454
495
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
455
496
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module RSpecParity
5
- VERSION = "1.4.6"
5
+ VERSION = "1.6.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-rspec_parity
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.6
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Povilas Jurcys
@@ -53,9 +53,11 @@ files:
53
53
  - README.md
54
54
  - Rakefile
55
55
  - config/default.yml
56
+ - docs/superpowers/specs/2026-05-21-trace-single-use-private-methods-design.md
56
57
  - lib/rubocop-rspec_parity.rb
57
58
  - lib/rubocop/cop/rspec_parity/department_config.rb
58
59
  - lib/rubocop/cop/rspec_parity/file_has_spec.rb
60
+ - lib/rubocop/cop/rspec_parity/private_method_call_graph.rb
59
61
  - lib/rubocop/cop/rspec_parity/public_method_has_spec.rb
60
62
  - lib/rubocop/cop/rspec_parity/spec_file_finder.rb
61
63
  - lib/rubocop/cop/rspec_parity/sufficient_contexts.rb
@@ -87,7 +89,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
89
  - !ruby/object:Gem::Version
88
90
  version: '0'
89
91
  requirements: []
90
- rubygems_version: 4.0.4
92
+ rubygems_version: 4.0.3
91
93
  specification_version: 4
92
94
  summary: RuboCop plugin for enforcing RSpec spec parity and best practices
93
95
  test_files: []