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 +4 -4
- data/CHANGELOG.md +4 -0
- data/config/default.yml +1 -0
- data/docs/superpowers/specs/2026-05-21-trace-single-use-private-methods-design.md +204 -0
- data/lib/rubocop/cop/rspec_parity/private_method_call_graph.rb +278 -0
- data/lib/rubocop/cop/rspec_parity/sufficient_contexts.rb +33 -9
- data/lib/rubocop/rspec_parity/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c9e27c87b24c7df00daa4eb0a88c31644a7f9986b384d5c875d42bb8358d071
|
|
4
|
+
data.tar.gz: aaeab2c5bccca24e24c39250299235ec3a46950f48bee5181a575b2cf45ef683
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
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
|
+
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.
|
|
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: []
|