query_packwerk 0.1.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.
@@ -0,0 +1,160 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # Manages caching of file contents, AST nodes, and code analysis results.
6
+ # Provides efficient access to parsed Ruby code, constant references, and
7
+ # source abstractions for performance optimization during violation analysis.
8
+ # Supports both standard and anonymized views of source code patterns.
9
+ class FileCache
10
+ extend T::Sig
11
+
12
+ RUBY_FILE = T.let(/\.(rb|rake)\z/, Regexp)
13
+
14
+ sig { void }
15
+ def initialize
16
+ # { file_name => AST }
17
+ @file_ast_cache = T.let({}, T::Hash[String, RuboCop::AST::Node])
18
+ @file_cache = T.let({}, T::Hash[String, String])
19
+
20
+ # { [file_name, const_name] => [AST const nodes] }
21
+ @file_const_cache = T.let({}, T::Hash[T::Array[String], T::Array[RuboCop::AST::Node]])
22
+
23
+ # { [file_name, const_name] => [AST const nodes with full receiver chains] }
24
+ @full_source_cache = T.let({}, T::Hash[T::Array[String], T::Array[RuboCop::AST::Node]])
25
+
26
+ # { [file_name, const_name] => [anonymized sources] }
27
+ @anonymized_source_cache = T.let({}, T::Hash[T::Array[String], T::Array[String]])
28
+
29
+ @anonymized_args_cache = T.let({}, T::Hash[String, String])
30
+
31
+ @is_active_record_cache = T.let({}, T::Hash[String, String])
32
+ @is_constant_cache = T.let({}, T::Hash[String, String])
33
+ end
34
+
35
+ sig { params(file_names: String, headers: T::Boolean).void }
36
+ def load!(*file_names, headers: true)
37
+ file_count = file_names.size
38
+
39
+ warn "Prepopulating AST cache with #{file_count} files: " if headers
40
+
41
+ file_names.each do |f|
42
+ get_file_ast(f)
43
+ $stderr.print '.'
44
+ end
45
+
46
+ warn '', 'AST cache loaded' if headers
47
+ end
48
+
49
+ sig { params(cache_name: Symbol, key: T.untyped, value: T.untyped).returns(T.untyped) }
50
+ def set(cache_name, key:, value:)
51
+ case cache_name
52
+ when :is_active_record
53
+ return @is_active_record_cache[key] if @is_active_record_cache.key?(key)
54
+
55
+ @is_active_record_cache[key] = value
56
+ when :is_constant
57
+ return @is_constant_cache[key] if @is_constant_cache.key?(key)
58
+
59
+ @is_constant_cache[key] = value
60
+ end
61
+ end
62
+
63
+ sig { params(file_name: String).returns(RuboCop::AST::Node) }
64
+ def get_file_ast(file_name)
65
+ @file_ast_cache[file_name] ||= ast_from(get_file(file_name))
66
+ end
67
+
68
+ sig { params(file_name: String).returns(String) }
69
+ def get_file(file_name)
70
+ @file_cache[file_name] ||= if RUBY_FILE.match?(file_name) && File.exist?(file_name)
71
+ File.read(file_name)
72
+ else
73
+ 'x'
74
+ end
75
+ end
76
+
77
+ # Get all occurrencs of the violation's constant in a file
78
+ sig { params(file_name: String, class_name: String).returns(T::Array[RuboCop::AST::Node]) }
79
+ def get_all_const_occurrences(file_name:, class_name:)
80
+ const_key = [file_name, class_name]
81
+
82
+ return T.must(@file_const_cache[const_key]) if @file_const_cache.key?(const_key)
83
+
84
+ absolute_const_node = ast_from(class_name)
85
+ relative_const_node = ast_from(class_name.delete_prefix('::'))
86
+
87
+ @file_const_cache[const_key] = get_file_ast(file_name).each_descendant.select do |node|
88
+ next false unless node.const_type?
89
+
90
+ node == absolute_const_node || node == relative_const_node
91
+ end
92
+ end
93
+
94
+ # Gets the full unanonymized source of how a constant is called
95
+ sig { params(file_name: String, class_name: String).returns(T::Array[RuboCop::AST::Node]) }
96
+ def get_full_sources(file_name:, class_name:)
97
+ const_key = [file_name, class_name]
98
+
99
+ return T.must(@full_source_cache[const_key]) if @full_source_cache.key?(const_key)
100
+
101
+ @full_source_cache[const_key] = get_all_const_occurrences(
102
+ file_name: file_name,
103
+ class_name: class_name
104
+ ).map do |node|
105
+ get_full_receiver_chain(node)
106
+ end
107
+ end
108
+
109
+ # Cleans up and anonymizes a source
110
+ sig { params(source: String).returns(String) }
111
+ def anonymize_arguments(source)
112
+ @anonymized_args_cache[source] ||= RuleRewriter
113
+ .rewrite(source)
114
+ .delete("\n").squeeze(' ').delete_prefix('::')
115
+ end
116
+
117
+ # Get the full receiver chains on a constant, and anonymize their arguments
118
+ sig { params(file_name: String, class_name: String).returns(T::Array[String]) }
119
+ def get_full_anonymous_sources(file_name:, class_name:)
120
+ const_key = [file_name, class_name]
121
+
122
+ return T.must(@anonymized_source_cache[const_key]) if @anonymized_source_cache.key?(const_key)
123
+
124
+ @anonymized_source_cache[const_key] = get_full_sources(
125
+ file_name: file_name,
126
+ class_name: class_name
127
+ ).map do |node|
128
+ get_full_receiver_chain(node)
129
+ .source
130
+ .then { |s| anonymize_arguments(s) }
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ # Turns a string into a Ruby AST
137
+ sig { params(string: String).returns(RuboCop::AST::Node) }
138
+ def ast_from(string)
139
+ RuboCop::ProcessedSource.new(string, RUBY_VERSION.to_f).ast
140
+ end
141
+
142
+ # We can find a constant, but by going up its parents we can find out the full call chain
143
+ # by checking if each parent is a receiver of the child, giving us method calls and
144
+ # arguments on a constant as well as where it occurred.
145
+ sig { params(node: RuboCop::AST::Node).returns(RuboCop::AST::Node) }
146
+ def get_full_receiver_chain(node)
147
+ return node unless node.const_type?
148
+
149
+ current_node = T.let(node, RuboCop::AST::Node)
150
+
151
+ while (parent_node = current_node.parent)
152
+ break unless parent_node.receiver == current_node
153
+
154
+ current_node = parent_node
155
+ end
156
+
157
+ current_node
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,129 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # Represents a Packwerk package with enhanced querying capabilities.
6
+ # Wraps around ParsePackwerk::Package to provide additional methods
7
+ # for accessing package properties, dependencies, violations, and consumer information.
8
+ class Package
9
+ extend T::Sig
10
+
11
+ sig { returns(ParsePackwerk::Package) }
12
+ attr_reader :original_package
13
+
14
+ sig { params(original_package: ParsePackwerk::Package).void }
15
+ def initialize(original_package:)
16
+ @original_package = original_package
17
+ end
18
+
19
+ sig { returns(String) }
20
+ def name
21
+ @original_package.name
22
+ end
23
+
24
+ sig { returns(T::Boolean) }
25
+ def enforce_dependencies
26
+ !!@original_package.enforce_dependencies
27
+ end
28
+
29
+ sig { returns(T::Boolean) }
30
+ def enforce_privacy
31
+ !!@original_package.enforce_privacy
32
+ end
33
+
34
+ sig { returns(ParsePackwerk::MetadataYmlType) }
35
+ def metadata
36
+ @original_package.metadata
37
+ end
38
+
39
+ sig { returns(T::Hash[String, T.untyped]) }
40
+ def config
41
+ @original_package.config
42
+ end
43
+
44
+ sig { returns(QueryPackwerk::Packages) }
45
+ def dependencies
46
+ Packages.where(name: @original_package.dependencies)
47
+ end
48
+
49
+ sig { returns(T::Array[String]) }
50
+ def dependency_names
51
+ @original_package.dependencies
52
+ end
53
+
54
+ sig { returns(String) }
55
+ def owner
56
+ config['owner'] || Packages::UNOWNED
57
+ end
58
+
59
+ sig { returns(Pathname) }
60
+ def directory
61
+ Pathname.new(name).cleanpath
62
+ end
63
+
64
+ # Returns violations where this package is the consumer (i.e., this package
65
+ # has dependencies on other packages). These are the "todos" in the package_todo.yml
66
+ # file for this package.
67
+ sig { returns(QueryPackwerk::Violations) }
68
+ def todos
69
+ QueryPackwerk::Violations.where(consuming_pack: name)
70
+ end
71
+ alias outgoing_violations todos
72
+
73
+ # Returns violations where this package is the producer (i.e., other packages
74
+ # depend on this package). These are violations where other packages are
75
+ # accessing code from this package.
76
+ sig { returns(QueryPackwerk::Violations) }
77
+ def violations
78
+ QueryPackwerk::Violations.where(producing_pack: name)
79
+ end
80
+ alias incoming_violations violations
81
+
82
+ # Returns all packages that consume (depend on) this package
83
+ sig { returns(QueryPackwerk::Packages) }
84
+ def consumers
85
+ Packages.where(name: consumer_names)
86
+ end
87
+
88
+ # Returns the names of all packages that consume (depend on) this package
89
+ sig { returns(T::Array[String]) }
90
+ def consumer_names
91
+ violations.consumers.keys
92
+ end
93
+
94
+ # Returns a count of how often each consumer package accesses this package
95
+ sig { returns(T::Hash[String, Integer]) }
96
+ def consumer_counts
97
+ violations.consumers
98
+ end
99
+
100
+ sig { returns(String) }
101
+ def parent_name
102
+ directory.dirname.to_s
103
+ end
104
+
105
+ sig do
106
+ params(
107
+ keys: T.nilable(T::Array[Symbol])
108
+ ).returns(T::Hash[Symbol, T.untyped])
109
+ end
110
+ def deconstruct_keys(keys)
111
+ all_values = {
112
+ name: name,
113
+ owner: owner,
114
+ owned: owner != Packages::UNOWNED,
115
+ dependencies: dependency_names,
116
+
117
+ # Used for future implementations of NestedPacks
118
+ parent_name: parent_name
119
+ }
120
+
121
+ keys.nil? ? all_values : all_values.slice(*T.unsafe(keys))
122
+ end
123
+
124
+ sig { returns(String) }
125
+ def inspect
126
+ "#<#{self.class.name} #{name}>"
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,78 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # A collection class for managing and querying sets of Packwerk packages.
6
+ # Provides methods for retrieving, filtering, and analyzing packages within
7
+ # the application. Implements Enumerable and QueryInterface for flexible
8
+ # data manipulation and consistent query patterns.
9
+ class Packages
10
+ extend T::Sig
11
+ extend T::Generic
12
+
13
+ Elem = type_member { { fixed: QueryPackwerk::Package } }
14
+
15
+ UNOWNED = T.let('Unowned', String)
16
+
17
+ include Enumerable
18
+ include QueryInterface
19
+
20
+ sig { override.returns(T::Array[QueryPackwerk::Package]) }
21
+ attr_reader :original_collection
22
+
23
+ class << self
24
+ extend T::Sig
25
+
26
+ # Get all packages wrapped in our interfaces
27
+ sig { returns(QueryPackwerk::Packages) }
28
+ def all
29
+ @all ||= T.let(
30
+ begin
31
+ packages = ParsePackwerk.all.map { |p| QueryPackwerk::Package.new(original_package: p) }
32
+ QueryPackwerk::Packages.new(packages)
33
+ end,
34
+ T.nilable(QueryPackwerk::Packages)
35
+ )
36
+ end
37
+
38
+ sig do
39
+ params(
40
+ query_params: T.untyped, # Array, or anything responding to `===`, which can't be typed
41
+ query_fn: T.nilable(T.proc.params(arg0: T.untyped).returns(T::Boolean))
42
+ ).returns(QueryPackwerk::Packages)
43
+ end
44
+ def where(**query_params, &query_fn)
45
+ QueryPackwerk::Packages.new(super)
46
+ end
47
+
48
+ sig { void }
49
+ def reload!
50
+ @all = nil
51
+ end
52
+ end
53
+
54
+ sig { params(original_collection: T::Array[QueryPackwerk::Package]).void }
55
+ def initialize(original_collection)
56
+ @original_collection = original_collection
57
+ end
58
+
59
+ # You can query for packages rather than violations to get a broader view, and
60
+ # the violations returned from this will be related to all packs in this class rather than just
61
+ # one.
62
+ sig { returns(QueryPackwerk::Violations) }
63
+ def violations
64
+ QueryPackwerk::Violations.new(
65
+ @original_collection.flat_map { |pack| pack.violations.original_collection }
66
+ )
67
+ end
68
+
69
+ sig { returns(String) }
70
+ def inspect
71
+ [
72
+ "#<#{self.class.name} [",
73
+ to_a.map(&:inspect).join("\n"),
74
+ ']>'
75
+ ].join("\n")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,268 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # A mixin module providing a flexible query interface for collections.
6
+ # Implements methods for filtering, comparing, and manipulating collection data
7
+ # with a consistent API pattern. Extends included classes with class methods
8
+ # for advanced querying capabilities and integrates with Enumerable for
9
+ # additional collection functionality.
10
+ module QueryInterface
11
+ include Kernel
12
+
13
+ extend T::Sig
14
+ extend T::Generic
15
+
16
+ Elem = type_member
17
+
18
+ include Enumerable
19
+
20
+ # Iterate over every member of the underlying original collection,
21
+ # also tie-in for Enumerable methods.
22
+ #
23
+ # Returns `T.untyped` because Sorbet complains on more accurate returns
24
+ # or void.
25
+ sig do
26
+ override.params(
27
+ block: T.nilable(T.proc.params(arg0: Elem).returns(T.untyped))
28
+ ).returns(
29
+ T.any(T::Enumerator[Elem], T::Array[Elem])
30
+ )
31
+ end
32
+ def each(&block)
33
+ return enum_for(:each) unless block_given?
34
+
35
+ original_collection.each(&block)
36
+ end
37
+
38
+ # Should be overridden, the base of most of the queries.
39
+ #
40
+ # TODO: Consider refactoring to `abstract` interface
41
+ sig { overridable.returns(T::Array[T.untyped]) }
42
+ def original_collection
43
+ []
44
+ end
45
+
46
+ # `Enumerable` does not expose these methods.
47
+ sig { returns(Integer) }
48
+ def size
49
+ original_collection.size
50
+ end
51
+
52
+ alias length size
53
+ alias count size
54
+
55
+ # Extend the class with class extensions whenever this module is
56
+ # included so we get both singleton and instance methods we need for
57
+ # querying.
58
+ sig { params(klass: T::Class[T.anything]).void }
59
+ def self.included(klass)
60
+ klass.extend(ClassMethods)
61
+ end
62
+
63
+ module ClassMethods
64
+ include Kernel
65
+ extend T::Sig
66
+
67
+ # *Notes on Sorbet:*
68
+ #
69
+ # Allows you to query against the underlying collection. As Sorbet does not
70
+ # allow for interface typing (i.e. `responds_to?`) we're using `T.untyped` instead.
71
+ #
72
+ # Since the implementing class will wrap the return value we also return `T.untyped` as
73
+ # Sorbet will imply this should be a `T::Array[inheriting_class]` rather than `InheritingClass`.
74
+ #
75
+ # *Notes on Interface:*
76
+ #
77
+ # This is based on a pattern-matching like interface using `===` and a few other assumptions
78
+ # about the underlying data structure. For singular values that means you can do this:
79
+ #
80
+ # InheritingClass.where(name: /part_of_name/)
81
+ #
82
+ # ...as `===` works as a pattern inclusion for classes like `Regexp`, `Range`, class types, and
83
+ # more.
84
+ #
85
+ # There are a few unique cases presumed here as well when dealing with more complex queries:
86
+ #
87
+ # * If both the underlying value and query value are arrays we check for intersection
88
+ # * If the query value is an array we check if the underlying value "matches" and item in it
89
+ # * If the underlying value is an array we check if the query value matches any part of it
90
+ # * Otherwise we use `===` as a query
91
+ #
92
+ # These interfaces are meant to mimic ActiveRecord, but do take some liberties as we're working
93
+ # with Ruby objects rather than underlying database structures.
94
+ sig do
95
+ params(
96
+ query_params: T.untyped, # Array, or anything responding to `===`, which can't be typed
97
+ _query_fn: T.nilable(T.proc.params(arg0: T.untyped).returns(T::Boolean))
98
+ ).returns(T.untyped)
99
+ end
100
+ def where(**query_params, &_query_fn)
101
+ query_keys = query_params.keys
102
+ all_values = all
103
+
104
+ accepted_keys = all_values.first.deconstruct_keys(nil).keys
105
+ invalid_keys = query_keys - accepted_keys
106
+
107
+ raise ArgumentError, "The following keys are invalid for querying: #{invalid_keys.join(', ')}" if invalid_keys.any?
108
+
109
+ all_values.select do |value|
110
+ next yield(value) if block_given?
111
+
112
+ object_params = value.deconstruct_keys(query_keys)
113
+
114
+ query_params.all? do |param, query_matcher|
115
+ object_value = object_params[param]
116
+
117
+ case [query_matcher, object_value]
118
+ in [Array, Array]
119
+ intersects?(query_matcher, object_value)
120
+ in [Array, _]
121
+ any_compare?(query_matcher, object_value)
122
+ in [_, Array]
123
+ includes?(query_matcher, object_value)
124
+ else
125
+ case_equal?(query_matcher, object_value)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ # Similar to the above `original_collection` we want to override this by defining
131
+ # where the InheritingClass can find all of its raw data.
132
+ #
133
+ # Sorbet does not recognize `included(klass)`/`klass.extend(ClassMethods)` when
134
+ # looking for `override` hooks. May be a bug in Sorbet.
135
+ sig do
136
+ returns(T.untyped)
137
+ end
138
+ def all
139
+ []
140
+ end
141
+
142
+ private
143
+
144
+ # Query Array to value Array is intersection, rather than strict equality or a literal pattern
145
+ # match. While we could do a `===` approximation of intersection that would make
146
+ # the code much more complicated.
147
+ sig { params(query_matcher: T::Array[T.untyped], object_value: T::Array[T.untyped]).returns(T::Boolean) }
148
+ def intersects?(query_matcher, object_value)
149
+ query_matcher.intersect?(object_value)
150
+ end
151
+
152
+ # Query Array to Any object value checks if any of the query matchers match that value
153
+ sig { params(query_matcher: T::Array[T.untyped], object_value: T.untyped).returns(T::Boolean) }
154
+ def any_compare?(query_matcher, object_value)
155
+ query_matcher.any? { |matcher| matcher === object_value } # rubocop:disable Style/CaseEquality
156
+ end
157
+
158
+ # Any other type of Query to Array is find if any value matches the condition
159
+ sig { params(query_matcher: T.untyped, object_value: T::Array[T.untyped]).returns(T::Boolean) }
160
+ def includes?(query_matcher, object_value)
161
+ object_value.any? { |v| query_matcher === v } # rubocop:disable Style/CaseEquality, Performance/RedundantEqualityComparisonBlock
162
+ end
163
+
164
+ # Otherwise we use `===` to decide if it's a match, and we use it explicitly as
165
+ # it's a very powerful query-like DSL and is in the language for a reason.
166
+ #
167
+ # We would use `===` for all cases, except that `Array` does not define it, nor
168
+ # should it as it could be defined in multiple different ways much the same as
169
+ # `String#each` could mean several things.
170
+ sig { params(query_matcher: T.untyped, object_value: T.untyped).returns(T::Boolean) }
171
+ def case_equal?(query_matcher, object_value)
172
+ query_matcher === object_value # rubocop:disable Style/CaseEquality
173
+ end
174
+ end
175
+
176
+ protected
177
+
178
+ sig do
179
+ params(
180
+ collection: T::Array[T.untyped],
181
+ threshold: Integer,
182
+ _blk: T.proc.params(arg0: T.untyped).returns(T::Array[T.untyped])
183
+ ).returns(T::Hash[String, T::Hash[String, Integer]])
184
+ end
185
+ def deep_merge_counts(collection, threshold: 0, &_blk)
186
+ nested_counts = collection.each_with_object(
187
+ Hash.new { |h, k| h[k] = Hash.new(0) }
188
+ ) do |violation, new_nested_counts|
189
+ key, values = yield(violation)
190
+
191
+ new_nested_counts[key].merge!(values) { |_k, a, b| a + b }
192
+ end
193
+
194
+ threshold_drop(nested_counts, threshold: threshold)
195
+ end
196
+
197
+ sig do
198
+ params(
199
+ collection: T::Array[T.untyped],
200
+ _blk: T.proc.params(arg0: T.untyped).returns(T::Array[T.untyped])
201
+ ).returns(T::Hash[String, T::Array[T.untyped]])
202
+ end
203
+ def deep_merge_groups(collection, &_blk)
204
+ groups = collection.each_with_object(
205
+ Hash.new { |h, k| h[k] = [] }
206
+ ) do |violation, new_groups|
207
+ key, values = yield(violation)
208
+ new_groups[key].concat(values)
209
+ end
210
+
211
+ if groups.values.first.is_a?(String)
212
+ groups.transform_values(&:sort)
213
+ else
214
+ groups
215
+ end
216
+ end
217
+
218
+ sig do
219
+ params(
220
+ collection: T::Array[T.untyped],
221
+ _blk: T.proc.params(arg0: T.untyped).returns(T::Array[T.untyped])
222
+ ).returns(T::Hash[String, T::Hash[String, T::Array[String]]])
223
+ end
224
+ def deep_merge_hash_groups(collection, &_blk)
225
+ initial_hash = Hash.new do |constants, const_name|
226
+ constants[const_name] = Hash.new do |sources, source|
227
+ sources[source] = []
228
+ end
229
+ end
230
+
231
+ merged_collection = collection.each_with_object(initial_hash) do |violation, new_groups|
232
+ constant_name, sources = yield(violation)
233
+ sources.each do |source, file_locations|
234
+ new_groups[constant_name][source].concat(file_locations)
235
+ end
236
+ end
237
+
238
+ merged_collection.transform_values do |sources_hash|
239
+ sources_hash
240
+ .transform_values(&:sort)
241
+ .sort_by { |_anonymous_source, file_list| -file_list.size }
242
+ .to_h
243
+ end
244
+ end
245
+
246
+ sig do
247
+ params(
248
+ hash: T::Hash[String, T::Hash[String, Integer]],
249
+ threshold: Integer
250
+ ).returns(T::Hash[String, T::Hash[String, Integer]])
251
+ end
252
+ def threshold_drop(hash, threshold: 0)
253
+ hash
254
+ .transform_values { |vs| threshold_filter_sort(vs, threshold: threshold) }
255
+ .reject { |_k, vs| vs.empty? }
256
+ end
257
+
258
+ sig { params(hash: T::Hash[String, Integer], threshold: Integer).returns(T::Hash[String, Integer]) }
259
+ def threshold_filter_sort(hash, threshold: 0)
260
+ hash
261
+ .select { |_k, v| v >= threshold }
262
+ .sort_by { |_k, v| -v }
263
+ .to_h
264
+ end
265
+
266
+ mixes_in_class_methods(ClassMethods)
267
+ end
268
+ end
@@ -0,0 +1,31 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ class RuleRewriter
6
+ class AnonymizeArgumentsRule < BaseRule
7
+ extend T::Sig
8
+
9
+ # Arguments prefixed with a sigil like `*arg` and `&fn`
10
+ SIGIL_ARGS = T.let(%i[splat block_pass].freeze, T::Array[Symbol])
11
+
12
+ sig { override.params(node: RuboCop::AST::Node).void }
13
+ def on_send(node)
14
+ return unless node.arguments?
15
+
16
+ node.arguments.reject(&:hash_type?).each do |arg|
17
+ arg_node = if SIGIL_ARGS.include?(arg.type)
18
+ arg.children.first
19
+ else
20
+ arg
21
+ end
22
+
23
+ # Just in case we get strangely shaped nodes
24
+ next unless arg_node.respond_to?(:loc)
25
+
26
+ @rewriter.replace(arg_node.loc.expression, ANONYMIZED)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ class RuleRewriter
6
+ class AnonymizeKeywordArgumentsRule < BaseRule
7
+ extend T::Sig
8
+
9
+ sig { override.params(node: RuboCop::AST::Node).void }
10
+ def on_send(node)
11
+ return unless node.arguments?
12
+
13
+ node.arguments.select(&:hash_type?).each do |hash_node|
14
+ hash_node.children.each do |pair|
15
+ _keyword_node, value_node = if pair.kwsplat_type?
16
+ [nil, pair.children.first]
17
+ else
18
+ pair.children
19
+ end
20
+
21
+ # Just in case we get strangely shaped nodes
22
+ next unless value_node.respond_to?(:loc)
23
+
24
+ @rewriter.replace(value_node.loc.expression, ANONYMIZED)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end