vcr-unused_cassettes 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.standard.yml +2 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile.lock +1 -1
- data/README.md +6 -1
- data/lib/vcr/unused_cassettes/call_context.rb +107 -23
- data/lib/vcr/unused_cassettes/cassette_usage_finder.rb +30 -12
- data/lib/vcr/unused_cassettes/method_index.rb +100 -0
- data/lib/vcr/unused_cassettes/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a648da7caa35c506546937a278548e14b4d912d0c6ca618da0390b576b22f29
|
|
4
|
+
data.tar.gz: c8f9f08054107b7c7b0b2e3e641b8fa470d414bc1d5f9218938e60dc00d84905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 564910bb7f1e4d5c70238d089e03bbad0549b7ec6bd02e24e1c20c7c69ce8f8311a6ce54eafea67d69614df10ddee955f298e3c636e2c17d9aec65956adf6c67
|
|
7
|
+
data.tar.gz: 04b05af06f640486af07e4bfab652e9b605c63ae226cbc9e6a59f6ccbb44bc0410f1349de825707818306440c531f668569f99c820aa7324ef5ebec46a9c0cd6
|
data/.standard.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
- Fix `NoMethodError` when emitting a warning for an unresolveable cassette name
|
|
6
|
+
- Resolve cassette names that come from method parameters by binding helper-method parameters to literal defaults and to the values passed at same-file call sites. Multi-value bindings fan out into one cassette entry per value; interpolations fan out via Cartesian product, so patterns like `"#{prefix}_#{suffix}"` resolve to concrete cassettes instead of being flagged as unused.
|
|
7
|
+
- Raise on unbound local/instance variable reads instead of silently returning `nil`, so unresolved interpolation parts collapse to `"*"` wildcards rather than producing garbage patterns.
|
|
8
|
+
- Chore: Add tests
|
|
9
|
+
|
|
3
10
|
## [1.1.0] - 2025-03-03
|
|
4
11
|
|
|
5
12
|
- Comply with zeitwerks eager loading
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -53,11 +53,16 @@ Open for contributions. Some ideas what could be done in the future:
|
|
|
53
53
|
- fancy spinner with progress indicator (configurable for ci environments)
|
|
54
54
|
- parallelize the analysis of the cassettes
|
|
55
55
|
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
59
|
+
|
|
60
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the changelog and the version number in `version.rb`, commit it, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
61
|
+
|
|
56
62
|
## Contributing
|
|
57
63
|
|
|
58
64
|
Contributions and ideas are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for more information.
|
|
59
65
|
|
|
60
|
-
|
|
61
66
|
## Code of Conduct
|
|
62
67
|
|
|
63
68
|
Everyone interacting in the Vcr::UnusedCassettes project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jbockler/vcr-unused_cassettes/blob/master/CODE_OF_CONDUCT.md).
|
|
@@ -4,11 +4,17 @@ module VCR::UnusedCassettes
|
|
|
4
4
|
class CallContext
|
|
5
5
|
ValueUnresolveable = Class.new(StandardError)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
# Reads under :raise mode raise ValueUnresolveable; only :wildcard callers
|
|
8
|
+
# can safely unpack the values.
|
|
9
|
+
MultiValue = Struct.new(:values)
|
|
10
|
+
|
|
11
|
+
def initialize(method_index: nil)
|
|
12
|
+
@method_index = method_index
|
|
8
13
|
@context = {
|
|
9
14
|
variables: {},
|
|
10
15
|
constants: {}
|
|
11
16
|
}
|
|
17
|
+
@parameter_scopes = []
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def track(node)
|
|
@@ -23,7 +29,18 @@ module VCR::UnusedCassettes
|
|
|
23
29
|
end
|
|
24
30
|
end
|
|
25
31
|
|
|
32
|
+
def enter_method(def_node)
|
|
33
|
+
@parameter_scopes.push(build_parameter_frame(def_node))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exit_method
|
|
37
|
+
@parameter_scopes.pop
|
|
38
|
+
end
|
|
39
|
+
|
|
26
40
|
def resolve_variable(variable_name)
|
|
41
|
+
@parameter_scopes.reverse_each do |frame|
|
|
42
|
+
return frame[variable_name] if frame.key?(variable_name)
|
|
43
|
+
end
|
|
27
44
|
@context.dig(:variables, variable_name)
|
|
28
45
|
end
|
|
29
46
|
|
|
@@ -56,29 +73,21 @@ module VCR::UnusedCassettes
|
|
|
56
73
|
extract_value(element, string_interpolation_error: string_interpolation_error)
|
|
57
74
|
end
|
|
58
75
|
when :interpolated_string_node
|
|
59
|
-
node.parts.map
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"*"
|
|
66
|
-
end
|
|
67
|
-
else
|
|
68
|
-
extract_value(part_node.statements.body.first, string_interpolation_error: string_interpolation_error)
|
|
69
|
-
end
|
|
70
|
-
else
|
|
71
|
-
extract_value(part_node, string_interpolation_error: string_interpolation_error)
|
|
72
|
-
end
|
|
73
|
-
rescue ValueUnresolveable
|
|
74
|
-
if string_interpolation_error == :raise
|
|
75
|
-
raise ValueUnresolveable, "Could not resolve value for node: #{part_node.inspect}"
|
|
76
|
-
elsif string_interpolation_error == :wildcard
|
|
77
|
-
"*"
|
|
78
|
-
end
|
|
79
|
-
end.join
|
|
76
|
+
part_options = node.parts.map { |part_node| interpolation_part_options(part_node, string_interpolation_error) }
|
|
77
|
+
combinations = part_options.reduce([[]]) do |acc, options|
|
|
78
|
+
acc.flat_map { |prefix| options.map { |opt| prefix + [opt] } }
|
|
79
|
+
end
|
|
80
|
+
results = combinations.map { |parts| parts.map(&:to_s).join }.uniq
|
|
81
|
+
(results.size == 1) ? results.first : MultiValue.new(results)
|
|
80
82
|
when :local_variable_read_node, :instance_variable_read_node
|
|
81
|
-
|
|
83
|
+
unless variable_bound?(node.name)
|
|
84
|
+
raise ValueUnresolveable, "Could not resolve value for node: #{node.inspect}"
|
|
85
|
+
end
|
|
86
|
+
value = resolve_variable(node.name)
|
|
87
|
+
if value.is_a?(MultiValue) && string_interpolation_error != :wildcard
|
|
88
|
+
raise ValueUnresolveable, "Multi-value binding cannot be embedded: #{node.inspect}"
|
|
89
|
+
end
|
|
90
|
+
value
|
|
82
91
|
when :constant_read_node
|
|
83
92
|
@context.dig(:constants, node.name)
|
|
84
93
|
when :assoc_splat_node
|
|
@@ -94,6 +103,11 @@ module VCR::UnusedCassettes
|
|
|
94
103
|
|
|
95
104
|
private
|
|
96
105
|
|
|
106
|
+
def variable_bound?(name)
|
|
107
|
+
return true if @parameter_scopes.any? { |frame| frame.key?(name) }
|
|
108
|
+
@context[:variables].key?(name)
|
|
109
|
+
end
|
|
110
|
+
|
|
97
111
|
def reset_local_variables!
|
|
98
112
|
@context[:variables].select! { |key, _value| key.start_with?("@") }
|
|
99
113
|
end
|
|
@@ -123,5 +137,75 @@ module VCR::UnusedCassettes
|
|
|
123
137
|
end
|
|
124
138
|
false
|
|
125
139
|
end
|
|
140
|
+
|
|
141
|
+
def build_parameter_frame(def_node)
|
|
142
|
+
frame = {}
|
|
143
|
+
return frame if def_node.parameters.nil?
|
|
144
|
+
params = def_node.parameters
|
|
145
|
+
|
|
146
|
+
call_bindings = @method_index ? @method_index.call_arguments_for(def_node.name) : []
|
|
147
|
+
|
|
148
|
+
parameter_specs(params).each do |name, default_node|
|
|
149
|
+
candidates = []
|
|
150
|
+
if default_node
|
|
151
|
+
begin
|
|
152
|
+
candidates << extract_value(default_node, string_interpolation_error: :raise)
|
|
153
|
+
rescue ValueUnresolveable
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
call_bindings.each do |call_binding|
|
|
157
|
+
candidates << call_binding[name] if call_binding.key?(name)
|
|
158
|
+
end
|
|
159
|
+
candidates = flatten_candidates(candidates)
|
|
160
|
+
next if candidates.empty?
|
|
161
|
+
frame[name] = (candidates.size == 1) ? candidates.first : MultiValue.new(candidates)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
frame
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parameter_specs(parameters_node)
|
|
168
|
+
specs = []
|
|
169
|
+
if parameters_node.respond_to?(:requireds)
|
|
170
|
+
parameters_node.requireds.each { |p| specs << [p.name, nil] if p.respond_to?(:name) }
|
|
171
|
+
end
|
|
172
|
+
if parameters_node.respond_to?(:optionals)
|
|
173
|
+
parameters_node.optionals.each { |p| specs << [p.name, p.value] }
|
|
174
|
+
end
|
|
175
|
+
if parameters_node.respond_to?(:keywords)
|
|
176
|
+
parameters_node.keywords.each do |p|
|
|
177
|
+
default = p.respond_to?(:value) ? p.value : nil
|
|
178
|
+
specs << [p.name, default]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
specs
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def interpolation_part_options(part_node, string_interpolation_error)
|
|
185
|
+
if part_node.type == :embedded_statements_node
|
|
186
|
+
if part_node.statements.body.size != 1
|
|
187
|
+
raise ValueUnresolveable, "Could not resolve value for node: #{part_node.inspect}"
|
|
188
|
+
end
|
|
189
|
+
value = extract_value(part_node.statements.body.first, string_interpolation_error: string_interpolation_error)
|
|
190
|
+
value.is_a?(MultiValue) ? value.values : [value]
|
|
191
|
+
else
|
|
192
|
+
[extract_value(part_node, string_interpolation_error: string_interpolation_error)]
|
|
193
|
+
end
|
|
194
|
+
rescue ValueUnresolveable
|
|
195
|
+
raise if string_interpolation_error == :raise
|
|
196
|
+
["*"]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def flatten_candidates(candidates)
|
|
200
|
+
flat = []
|
|
201
|
+
candidates.each do |c|
|
|
202
|
+
if c.is_a?(MultiValue)
|
|
203
|
+
flat.concat(c.values)
|
|
204
|
+
else
|
|
205
|
+
flat << c
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
flat.uniq
|
|
209
|
+
end
|
|
126
210
|
end
|
|
127
211
|
end
|
|
@@ -10,13 +10,15 @@ module VCR::UnusedCassettes
|
|
|
10
10
|
self.filename = filename
|
|
11
11
|
self.used_cassettes = []
|
|
12
12
|
self.warnings = []
|
|
13
|
-
self.call_context = CallContext.new
|
|
14
13
|
end
|
|
15
14
|
|
|
16
15
|
def find_cassette_usages
|
|
17
16
|
parse_result = Prism.parse_file(filename)
|
|
18
17
|
return [[], []] unless parse_result
|
|
19
18
|
|
|
19
|
+
method_index = MethodIndex.new(parse_result.value)
|
|
20
|
+
self.call_context = CallContext.new(method_index: method_index)
|
|
21
|
+
|
|
20
22
|
find_cassette_usages_in(parse_result.value)
|
|
21
23
|
|
|
22
24
|
[used_cassettes, warnings]
|
|
@@ -28,20 +30,36 @@ module VCR::UnusedCassettes
|
|
|
28
30
|
call_context.track(node)
|
|
29
31
|
|
|
30
32
|
if node_contains_call?(node)
|
|
31
|
-
|
|
32
|
-
if !found_name.nil? && /[a-zA-Z0-9]/.match?(found_name)
|
|
33
|
-
cassette_use = {pattern: found_name}
|
|
34
|
-
cassette_options = extract_options(node)
|
|
35
|
-
cassette_use[:persister] = cassette_options[:persist_with] if cassette_options&.has_key?(:persist_with)
|
|
36
|
-
cassette_use[:serializer] = cassette_options[:serialize_with] if cassette_options&.has_key?(:serialize_with)
|
|
37
|
-
used_cassettes << cassette_use
|
|
38
|
-
end
|
|
33
|
+
record_cassette_uses(node)
|
|
39
34
|
end
|
|
40
35
|
|
|
41
36
|
return if node.child_nodes.nil?
|
|
42
37
|
|
|
43
|
-
node.
|
|
44
|
-
|
|
38
|
+
if node.is_a?(Prism::DefNode)
|
|
39
|
+
call_context.enter_method(node)
|
|
40
|
+
begin
|
|
41
|
+
node.child_nodes.each { |child| find_cassette_usages_in(child) }
|
|
42
|
+
ensure
|
|
43
|
+
call_context.exit_method
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
node.child_nodes.each { |child| find_cassette_usages_in(child) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_cassette_uses(node)
|
|
51
|
+
found_name = find_cassette_name(node)
|
|
52
|
+
return if found_name.nil?
|
|
53
|
+
|
|
54
|
+
cassette_options = extract_options(node)
|
|
55
|
+
patterns = found_name.is_a?(CallContext::MultiValue) ? found_name.values : [found_name]
|
|
56
|
+
|
|
57
|
+
patterns.each do |pattern|
|
|
58
|
+
next unless pattern.is_a?(String) && /[a-zA-Z0-9*]/.match?(pattern)
|
|
59
|
+
cassette_use = {pattern: pattern}
|
|
60
|
+
cassette_use[:persister] = cassette_options[:persist_with] if cassette_options&.has_key?(:persist_with)
|
|
61
|
+
cassette_use[:serializer] = cassette_options[:serialize_with] if cassette_options&.has_key?(:serialize_with)
|
|
62
|
+
used_cassettes << cassette_use
|
|
45
63
|
end
|
|
46
64
|
end
|
|
47
65
|
|
|
@@ -76,7 +94,7 @@ module VCR::UnusedCassettes
|
|
|
76
94
|
|
|
77
95
|
def build_warning(node, error)
|
|
78
96
|
Warning.new.tap do |warning|
|
|
79
|
-
warning.message = "Could not determine cassette name for #{filename}:#{node.
|
|
97
|
+
warning.message = "Could not determine cassette name for #{filename}:#{node.location.start_line}"
|
|
80
98
|
warning.details = error.message
|
|
81
99
|
warning.backtrace = error.backtrace
|
|
82
100
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module VCR::UnusedCassettes
|
|
6
|
+
class MethodIndex
|
|
7
|
+
def initialize(root_node)
|
|
8
|
+
@definitions = {}
|
|
9
|
+
@call_arguments = Hash.new { |h, k| h[k] = [] }
|
|
10
|
+
collect_definitions(root_node)
|
|
11
|
+
collect_calls(root_node, CallContext.new) unless @definitions.empty?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call_arguments_for(method_name)
|
|
15
|
+
@call_arguments[method_name] || []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def collect_definitions(node)
|
|
21
|
+
return if node.nil?
|
|
22
|
+
@definitions[node.name] = node if node.is_a?(Prism::DefNode)
|
|
23
|
+
return if node.child_nodes.nil?
|
|
24
|
+
node.child_nodes.each { |child| collect_definitions(child) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def collect_calls(node, context)
|
|
28
|
+
return if node.nil?
|
|
29
|
+
context.track(node)
|
|
30
|
+
record_call(node, context) if known_helper_call?(node)
|
|
31
|
+
return if node.child_nodes.nil?
|
|
32
|
+
node.child_nodes.each { |child| collect_calls(child, context) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def known_helper_call?(node)
|
|
36
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
37
|
+
return false unless node.receiver.nil?
|
|
38
|
+
@definitions.key?(node.name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_call(call_node, context)
|
|
42
|
+
def_node = @definitions[call_node.name]
|
|
43
|
+
return if call_node.arguments.nil?
|
|
44
|
+
|
|
45
|
+
positional_names = positional_parameter_names(def_node)
|
|
46
|
+
keyword_names = keyword_parameter_names(def_node)
|
|
47
|
+
call_binding = {}
|
|
48
|
+
pos_index = 0
|
|
49
|
+
positional_unknown = false
|
|
50
|
+
|
|
51
|
+
call_node.arguments.arguments.each do |arg|
|
|
52
|
+
case arg
|
|
53
|
+
when Prism::KeywordHashNode
|
|
54
|
+
arg.elements.each do |element|
|
|
55
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
56
|
+
next unless element.key.is_a?(Prism::SymbolNode)
|
|
57
|
+
name = element.key.unescaped.to_sym
|
|
58
|
+
next unless keyword_names.include?(name)
|
|
59
|
+
begin
|
|
60
|
+
call_binding[name] = context.extract_value(element.value, string_interpolation_error: :raise)
|
|
61
|
+
rescue CallContext::ValueUnresolveable
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
when Prism::BlockArgumentNode
|
|
65
|
+
# block arg does not consume a positional slot
|
|
66
|
+
when Prism::SplatNode, Prism::ForwardingArgumentsNode
|
|
67
|
+
# alignment between source args and parameter list is lost from here on
|
|
68
|
+
positional_unknown = true
|
|
69
|
+
else
|
|
70
|
+
if !positional_unknown && pos_index < positional_names.size
|
|
71
|
+
name = positional_names[pos_index]
|
|
72
|
+
begin
|
|
73
|
+
call_binding[name] = context.extract_value(arg, string_interpolation_error: :raise)
|
|
74
|
+
rescue CallContext::ValueUnresolveable
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
pos_index += 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@call_arguments[call_node.name] << call_binding unless call_binding.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def positional_parameter_names(def_node)
|
|
85
|
+
return [] if def_node.parameters.nil?
|
|
86
|
+
params = def_node.parameters
|
|
87
|
+
names = []
|
|
88
|
+
names.concat(params.requireds.map(&:name)) if params.respond_to?(:requireds)
|
|
89
|
+
names.concat(params.optionals.map(&:name)) if params.respond_to?(:optionals)
|
|
90
|
+
names
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def keyword_parameter_names(def_node)
|
|
94
|
+
return [] if def_node.parameters.nil?
|
|
95
|
+
params = def_node.parameters
|
|
96
|
+
return [] unless params.respond_to?(:keywords)
|
|
97
|
+
params.keywords.map(&:name)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vcr-unused_cassettes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Josch Bockler
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-05-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: vcr
|
|
@@ -61,6 +61,7 @@ files:
|
|
|
61
61
|
- lib/vcr/unused_cassettes/Rakefile
|
|
62
62
|
- lib/vcr/unused_cassettes/call_context.rb
|
|
63
63
|
- lib/vcr/unused_cassettes/cassette_usage_finder.rb
|
|
64
|
+
- lib/vcr/unused_cassettes/method_index.rb
|
|
64
65
|
- lib/vcr/unused_cassettes/railtie.rb
|
|
65
66
|
- lib/vcr/unused_cassettes/runner.rb
|
|
66
67
|
- lib/vcr/unused_cassettes/tasks/vcr/unused_cassettes.rake
|