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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29639a4cd178bd1c230d8e2bd6497458e1d9b2e3c61c675924f88b2382c227ae
4
- data.tar.gz: 6cabbd3841f908571002517ceef11d25530cd339f2ccd3da86693879409954eb
3
+ metadata.gz: 0a648da7caa35c506546937a278548e14b4d912d0c6ca618da0390b576b22f29
4
+ data.tar.gz: c8f9f08054107b7c7b0b2e3e641b8fa470d414bc1d5f9218938e60dc00d84905
5
5
  SHA512:
6
- metadata.gz: 617933f66c7c60f04ffe0aced7a24e430434489d34f8257de8971d68b98478a4262124934542233ae934cca9f0fb1afda030fcc3716b8a0d0b90aab026519628
7
- data.tar.gz: e61cedf39718fba1122f19b10c1cb778b7a05e6638ace0941163765e1cb754cdcc17a5972b88b99f848960084f1f3bb20839a360c43e763e496817c8fcaefd5c
6
+ metadata.gz: 564910bb7f1e4d5c70238d089e03bbad0549b7ec6bd02e24e1c20c7c69ce8f8311a6ce54eafea67d69614df10ddee955f298e3c636e2c17d9aec65956adf6c67
7
+ data.tar.gz: 04b05af06f640486af07e4bfab652e9b605c63ae226cbc9e6a59f6ccbb44bc0410f1349de825707818306440c531f668569f99c820aa7324ef5ebec46a9c0cd6
data/.standard.yml CHANGED
@@ -1 +1,3 @@
1
1
  parallel: true
2
+ ignore:
3
+ - 'spec/fixtures/**/*'
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- vcr-unused_cassettes (1.1.0)
4
+ vcr-unused_cassettes (1.2.0)
5
5
  vcr
6
6
  zeitwerk
7
7
 
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
- def initialize
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 do |part_node|
60
- if part_node.type == :embedded_statements_node
61
- if part_node.statements.body.size != 1
62
- if string_interpolation_error == :raise
63
- raise ValueUnresolveable, "Could not resolve value for node: #{part_node.inspect}"
64
- elsif string_interpolation_error == :wildcard
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
- resolve_variable(node.name)
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
- found_name = find_cassette_name(node)
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.child_nodes.each do |child_node|
44
- find_cassette_usages_in(child_node)
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.line}"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module VCR
4
4
  module UnusedCassettes
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  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.1.0
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: 2025-03-03 00:00:00.000000000 Z
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