rubocop-rspec_parity 1.4.6 → 1.5.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: 4c9e27c87b24c7df00daa4eb0a88c31644a7f9986b384d5c875d42bb8358d071
4
+ data.tar.gz: aaeab2c5bccca24e24c39250299235ec3a46950f48bee5181a575b2cf45ef683
5
5
  SHA512:
6
- metadata.gz: 56b3b3a5dd71d2360779382c0f4e8661c8c84097eacff2a76c9c4c8a53f3d1f1d0487f5e00f391923437c01ee72a7b7092ef8c6ccd74562aa0cf413138ea5498
7
- data.tar.gz: 262cf615d136461f78253e2412cfb16f4aa270793c36789d7f4494071d95a0908a58f745983d842cdcb19d9ce1b9f054e3471c76ec919e1f1d7f567f3f08a53f
6
+ metadata.gz: 858ae886ee9a32d83cd0a8695f0e5d0acc8758af31802ef5fdfe3d4686860dc40991adfa29cacb5a24a4191a9d94011e531b35edccbc40b8300b83c2c46b2996
7
+ data.tar.gz: 7d1184d3ea750869fcf9d655865e3a1d7b93a3d30f2292bc8e57fbd6cb4166e8da9a8ee274c54ccb9d2f825039911b230ede34731be80f9ade8c98dd3a59e8df
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2026-05-21
4
+
5
+ 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.
6
+
3
7
  ## [1.4.6] - 2026-04-10
4
8
 
5
9
  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
@@ -44,6 +45,8 @@ module RuboCop
44
45
  MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only %<contexts>d %<context_word>s " \
45
46
  "in spec. Add %<missing>d more %<missing_word>s to cover all branches."
46
47
 
48
+ TRACED_SUFFIX = " (including branches from: %<traced>s)"
49
+
47
50
  APP_DIR_PATTERN = %r{/app/}
48
51
 
49
52
  EXCLUDED_METHODS = %w[initialize].freeze
@@ -59,6 +62,8 @@ module RuboCop
59
62
  def initialize(config = nil, options = nil)
60
63
  super
61
64
  @ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
65
+ @trace_single_use_private = cop_config.fetch("TraceSingleUsePrivateMethods", true)
66
+ @call_graphs = {}.compare_by_identity
62
67
  end
63
68
 
64
69
  def on_def(node)
@@ -76,6 +81,12 @@ module RuboCop
76
81
  return if excluded_method?(method_name(node))
77
82
 
78
83
  branches = count_branches(node)
84
+ traced_methods = []
85
+ if @trace_single_use_private
86
+ extra = inlined_branches(node)
87
+ branches += extra.branches
88
+ traced_methods = extra.traced_methods
89
+ end
79
90
  return if branches < 2 # Only check methods with branches
80
91
 
81
92
  class_name = extract_class_name(node)
@@ -101,15 +112,28 @@ module RuboCop
101
112
  return if contexts >= branches
102
113
 
103
114
  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)))
115
+ add_offense(node, message: build_message(node, branches, contexts, missing, traced_methods))
116
+ end
117
+
118
+ def build_message(node, branches, contexts, missing, traced_methods)
119
+ message = format(MSG,
120
+ method_name: method_name(node),
121
+ branches: branches,
122
+ branch_word: pluralize("branch", branches),
123
+ contexts: contexts,
124
+ context_word: pluralize("context", contexts),
125
+ missing: missing,
126
+ missing_word: pluralize("context", missing))
127
+ message += format(TRACED_SUFFIX, traced: traced_methods.join(", ")) if traced_methods.any?
128
+ message
129
+ end
130
+
131
+ def inlined_branches(node)
132
+ container = find_class_or_module(node)
133
+ return PrivateMethodCallGraph::Result.new(0, []) unless container
134
+
135
+ graph = (@call_graphs[container] ||= PrivateMethodCallGraph.new(container))
136
+ graph.inlinable_from(node, method(:count_branches))
113
137
  end
114
138
 
115
139
  def method_name(node)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module RSpecParity
5
- VERSION = "1.4.6"
5
+ VERSION = "1.5.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.5.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: []