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,30 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ class RuleRewriter
6
+ # Abstract base class for source code transformation rules.
7
+ # Extends the Parser::AST::Processor to provide common functionality
8
+ # for traversing and modifying Ruby abstract syntax trees during
9
+ # source rewriting operations.
10
+ class BaseRule < Parser::AST::Processor
11
+ extend T::Sig
12
+
13
+ include RuboCop::AST::Traversal
14
+
15
+ ANONYMIZED = '_'
16
+
17
+ sig { params(rewriter: Parser::Source::TreeRewriter).void }
18
+ def initialize(rewriter)
19
+ @rewriter = rewriter
20
+
21
+ super()
22
+ end
23
+
24
+ sig { params(begin_pos: Integer, end_pos: Integer).returns(Parser::Source::Range) }
25
+ def create_range(begin_pos, end_pos)
26
+ Parser::Source::Range.new(@rewriter.source_buffer, begin_pos, end_pos)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,56 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ class RuleRewriter
6
+ # Coordinates the application of multiple rewriting rules to source code.
7
+ # Processes Ruby code using RuboCop's source processing capabilities and
8
+ # applies each configured rule in sequence to transform source code for
9
+ # analysis purposes.
10
+ class RuleSetRewriter
11
+ extend T::Sig
12
+
13
+ sig { returns(RuboCop::ProcessedSource) }
14
+ attr_reader :source
15
+
16
+ sig { returns(RuboCop::AST::Node) }
17
+ attr_reader :ast
18
+
19
+ sig { returns(Parser::Source::TreeRewriter) }
20
+ attr_reader :rewriter
21
+
22
+ RULES = [
23
+ RuleRewriter::AnonymizeKeywordArgumentsRule,
24
+ RuleRewriter::AnonymizeArgumentsRule
25
+ ].freeze
26
+
27
+ def initialize(string, rules: RULES)
28
+ @source = processed_source(string)
29
+ @ast = @source.ast
30
+ @source_buffer = @source.buffer
31
+ @rewriter = Parser::Source::TreeRewriter.new(@source_buffer)
32
+ @rules = rules
33
+ end
34
+
35
+ def process
36
+ @rules.each do |rule_class|
37
+ rule = rule_class.new(@rewriter)
38
+ @ast.each_node { |node| rule.process(node) }
39
+ end
40
+
41
+ @rewriter
42
+ .process
43
+ .delete("\n").squeeze(' ') # ...and multiple spaces, probably indents from above
44
+ .gsub('( ', '(') # Remove paren spacing after previous
45
+ .gsub(' )', ')') # Remove paren spacing after previous
46
+ .gsub('. ', '.') # Remove suffix-dot spacing
47
+ end
48
+
49
+ private
50
+
51
+ def processed_source(string)
52
+ RuboCop::ProcessedSource.new(string, RUBY_VERSION.to_f)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # Orchestrates source code rewriting using defined transformation rules.
6
+ # Provides an entry point for applying rule-based code transformations,
7
+ # particularly for anonymizing method arguments and source patterns
8
+ # to facilitate pattern-based violation analysis.
9
+ class RuleRewriter
10
+ autoload :BaseRule, 'query_packwerk/rule_rewriter/base_rule'
11
+ autoload :RuleSetRewriter, 'query_packwerk/rule_rewriter/rule_set_rewriter'
12
+ autoload :AnonymizeArgumentsRule, 'query_packwerk/rule_rewriter/anonymize_arguments_rule'
13
+ autoload :AnonymizeKeywordArgumentsRule, 'query_packwerk/rule_rewriter/anonymize_keyword_arguments_rule'
14
+
15
+ extend T::Sig
16
+
17
+ sig { params(source_string: String).returns(String) }
18
+ def self.rewrite(source_string)
19
+ RuleSetRewriter.new(source_string).process
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,295 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module QueryPackwerk
5
+ # Represents a single Packwerk violation with extended inspection capabilities.
6
+ # Provides methods to analyze violation details including source location, contextual
7
+ # information, and code patterns. Facilitates both detailed and anonymized views of
8
+ # dependency violations between packages.
9
+ class Violation
10
+ extend T::Sig
11
+
12
+ # This does not play nicely with ERB files which may have violations
13
+ RUBY_FILE = T.let(/\.(rb|rake)\z/, Regexp)
14
+ ALL_CAPS = T.let(/\A[A-Z_]+\z/, Regexp)
15
+
16
+ sig { returns(QueryPackwerk::Package) }
17
+ attr_reader :producing_pack
18
+
19
+ sig { returns(QueryPackwerk::Package) }
20
+ attr_reader :consuming_pack
21
+
22
+ sig do
23
+ params(
24
+ original_violation: ParsePackwerk::Violation,
25
+ consuming_pack: ParsePackwerk::Package,
26
+ file_cache: QueryPackwerk::FileCache
27
+ ).void
28
+ end
29
+ def initialize(original_violation:, consuming_pack:, file_cache: QueryPackwerk::FileCache.new)
30
+ @original_violation = original_violation
31
+
32
+ @producing_pack = T.let(
33
+ QueryPackwerk::Package.new(original_package: T.must(ParsePackwerk.find(original_violation.to_package_name))),
34
+ QueryPackwerk::Package
35
+ )
36
+
37
+ @consuming_pack = T.let(
38
+ QueryPackwerk::Package.new(original_package: consuming_pack),
39
+ QueryPackwerk::Package
40
+ )
41
+
42
+ @file_cache = T.let(file_cache, QueryPackwerk::FileCache)
43
+ @cache_loaded = T.let(false, T::Boolean)
44
+ end
45
+
46
+ sig { params(headers: T::Boolean).void }
47
+ def load_cache!(headers: false)
48
+ return true if @cache_loaded
49
+
50
+ @file_cache.load!(*T.unsafe(files), headers: headers)
51
+ @cache_loaded = true
52
+ end
53
+
54
+ sig { params(cache: QueryPackwerk::FileCache).void }
55
+ def set_cache!(cache)
56
+ @cache_loaded = false
57
+ @file_cache = cache
58
+ end
59
+
60
+ sig { returns(Integer) }
61
+ def file_count
62
+ files.size
63
+ end
64
+
65
+ # Forwarding original properties explicitly.
66
+
67
+ sig { returns(String) }
68
+ def type
69
+ @original_violation.type
70
+ end
71
+
72
+ sig { returns(String) }
73
+ def to_package_name
74
+ @original_violation.to_package_name
75
+ end
76
+
77
+ sig { returns(String) }
78
+ def class_name
79
+ @original_violation.class_name
80
+ end
81
+
82
+ sig { returns(T::Array[String]) }
83
+ def files
84
+ @original_violation.files
85
+ end
86
+
87
+ # Addon methods
88
+
89
+ # Whether or not the files containing violations match any provided globs
90
+ #
91
+ # See also: https://ruby-doc.org/core-2.7.6/File.html#method-c-fnmatch
92
+ sig { params(globs: T.any(String, Regexp)).returns(T::Boolean) }
93
+ def includes_files?(*globs)
94
+ globs.any? do |glob|
95
+ files.any? do |file_name|
96
+ glob.is_a?(Regexp) ? glob.match?(file_name) : File.fnmatch?(glob, file_name)
97
+ end
98
+ end
99
+ end
100
+
101
+ # All sources and their receiver chains across all files this violation covers
102
+ sig { returns(T::Array[RuboCop::AST::Node]) }
103
+ def sources
104
+ load_cache!
105
+
106
+ files.flat_map do |file_name|
107
+ @file_cache.get_full_sources(file_name: file_name, class_name: class_name)
108
+ end
109
+ end
110
+
111
+ # Adds additional file and line number information to each source
112
+ sig { returns(T::Array[T.any(String, T::Array[RuboCop::AST::Node])]) }
113
+ def sources_with_locations
114
+ load_cache!
115
+
116
+ files.flat_map do |file_name|
117
+ @file_cache
118
+ .get_full_sources(file_name: file_name, class_name: class_name)
119
+ .map { |s| ["#{file_name}:#{s.loc.line}", s.source] }
120
+ end
121
+ end
122
+
123
+ # Frequency of which each source occurs
124
+ sig { returns(T::Hash[String, Integer]) }
125
+ def source_counts
126
+ load_cache!
127
+
128
+ sources = files.flat_map do |file_name|
129
+ @file_cache
130
+ .get_full_sources(file_name: file_name, class_name: class_name)
131
+ .map(&:source)
132
+ end
133
+
134
+ sources.tally
135
+ end
136
+
137
+ # Sources that have had their arguments anonymized
138
+ sig { returns(T::Array[String]) }
139
+ def anonymous_sources
140
+ load_cache!
141
+
142
+ files.flat_map do |file_name|
143
+ @file_cache
144
+ .get_full_anonymous_sources(file_name: file_name, class_name: class_name)
145
+ end
146
+ end
147
+
148
+ # sig { returns(T::Array[T.any(String, T::Array[String])]) }
149
+ sig { returns(T.untyped) }
150
+ def anonymous_sources_with_locations
151
+ load_cache!
152
+
153
+ file_sources = files.flat_map do |file_name|
154
+ @file_cache.get_full_sources(file_name: file_name, class_name: class_name).map do |s|
155
+ ["#{file_name}:#{s.loc.line}", @file_cache.anonymize_arguments(s.source)]
156
+ end
157
+ end
158
+
159
+ anonymous_source_groups = Hash.new { |h, source| h[source] = [] }
160
+
161
+ file_sources.each_with_object(anonymous_source_groups) do |(location, source), groups|
162
+ groups[source] << location
163
+ end
164
+ end
165
+
166
+ sig { params(start_offset: Integer, end_offset: Integer).returns(T.untyped) }
167
+ def sources_with_contexts(start_offset: 3, end_offset: 3)
168
+ load_cache!
169
+
170
+ file_sources = files.flat_map do |file_name|
171
+ @file_cache.get_full_sources(file_name: file_name, class_name: class_name).map do |s|
172
+ line_number = s.loc.line
173
+ start_pos = line_number - start_offset
174
+ end_pos = line_number + end_offset
175
+
176
+ location = "#{file_name}:#{s.loc.line} (L#{start_pos}..#{end_pos})"
177
+ context = @file_cache.get_file(file_name).lines.slice(start_pos..end_pos)
178
+ full_context = unindent((context || ['']).join)
179
+
180
+ [@file_cache.anonymize_arguments(s.source), "> #{location}\n\n#{full_context}"]
181
+ end
182
+ end
183
+
184
+ anonymous_source_groups = Hash.new { |h, source| h[source] = [] }
185
+
186
+ file_sources.each_with_object(anonymous_source_groups) do |(anonymous_source, full_source), groups|
187
+ groups[anonymous_source] << full_source
188
+ end
189
+ end
190
+
191
+ # Like above frequency of sources, except by method "shape" rather than
192
+ # exact arguments
193
+ sig { returns(T::Hash[String, Integer]) }
194
+ def anonymous_source_counts
195
+ anonymous_sources.tally
196
+ end
197
+
198
+ # True count of violations, as there can be multiple of the same violation
199
+ # in a file.
200
+ sig { returns(Integer) }
201
+ def count
202
+ files.sum do |file_name|
203
+ @file_cache.get_all_const_occurrences(
204
+ file_name: file_name,
205
+ class_name: class_name
206
+ ).size
207
+ end
208
+ end
209
+
210
+ sig do
211
+ params(keys: T.nilable(T::Array[Symbol])).returns(T::Hash[Symbol, T.untyped])
212
+ end
213
+ def runtime_keys(keys)
214
+ return {} unless defined?(Rails)
215
+
216
+ runtime_values = {}
217
+
218
+ return { is_active_record: false, is_constant: false } unless Kernel.const_defined?(class_name)
219
+
220
+ if keys.nil? || keys.include?(:is_active_record)
221
+ constant = Kernel.const_get(class_name) # rubocop:disable Sorbet/ConstantsFromStrings
222
+
223
+ value = @file_cache.set(
224
+ :is_active_record,
225
+ key: class_name,
226
+ value: constant.is_a?(Class) && constant < ApplicationRecord
227
+ )
228
+
229
+ runtime_values[:is_active_record] = value
230
+ end
231
+
232
+ if keys.nil? || keys.include?(:is_constant)
233
+ value = @file_cache.set(
234
+ :is_constant,
235
+ key: class_name,
236
+ value: class_name.split('::').last&.match?(ALL_CAPS)
237
+ )
238
+
239
+ runtime_values[:is_constant] = value
240
+ end
241
+
242
+ runtime_values
243
+ end
244
+
245
+ sig do
246
+ params(keys: T.nilable(T::Array[Symbol])).returns(T::Hash[Symbol, T.untyped])
247
+ end
248
+ def deconstruct_keys(keys)
249
+ all_values = {
250
+ constant_name: class_name,
251
+ pack_name: to_package_name,
252
+
253
+ # Type related properties, including convenience boolean handlers
254
+ type: type,
255
+ privacy: type == 'privacy',
256
+ dependency: type == 'dependency',
257
+
258
+ # Reaching into which pack produced the violated constant, and
259
+ # which consumes the violated constant.
260
+ consuming_pack: consuming_pack.name,
261
+ producing_pack: producing_pack.name,
262
+
263
+ # Same, except for owners
264
+ producing_owner: producing_pack.owner,
265
+ consuming_owner: consuming_pack.owner,
266
+
267
+ # So why is this "owner" implying producer? Because the
268
+ # owner field of the violation is producer-oriented.
269
+ owner: producing_pack.owner,
270
+ owned: producing_pack.owner.nil?,
271
+
272
+ **runtime_keys(keys)
273
+ }
274
+
275
+ # all_values[:is_active_record] = active_record? if !keys || keys.include?(:is_active_record)
276
+ # all_values[:is_constant] = active_record? if !keys || keys.include?(:is_constant)
277
+
278
+ keys.nil? ? all_values : all_values.slice(*T.unsafe(keys))
279
+ end
280
+
281
+ sig { returns(String) }
282
+ def inspect
283
+ "#<#{self.class.name} #{consuming_pack.name} -> #{class_name} (#{type})>"
284
+ end
285
+
286
+ private
287
+
288
+ sig { params(string: String).returns(String) }
289
+ def unindent(string)
290
+ # Multi-line match, this is intentional
291
+ min_space = string.scan(/^\s*/).min_by(&:length)
292
+ string.gsub(/^#{min_space}/, '')
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,270 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'coderay'
5
+
6
+ module QueryPackwerk
7
+ # A collection class for managing and querying sets of Packwerk violations.
8
+ # Provides aggregation, filtering, and analysis methods for violation data,
9
+ # including source extraction, contextual reporting, and consumer relationship mapping.
10
+ # Implements Enumerable and QueryInterface for flexible data manipulation.
11
+ class Violations
12
+ extend T::Sig
13
+ extend T::Generic
14
+
15
+ Elem = type_member { { fixed: QueryPackwerk::Violation } }
16
+
17
+ include Enumerable
18
+ include QueryInterface
19
+
20
+ sig { override.returns(T::Array[QueryPackwerk::Violation]) }
21
+ attr_reader :original_collection
22
+
23
+ @all = T.let(nil, T.nilable(QueryPackwerk::Violations))
24
+
25
+ class << self
26
+ extend T::Sig
27
+
28
+ # Get all violations from ParsePackwerk and wrap them in our own
29
+ # representations. Unlike ParsePackwerk we also capture the destination
30
+ # of the violation to give a bi-directional view of consumption.
31
+ sig { returns(QueryPackwerk::Violations) }
32
+ def all
33
+ return @all if @all
34
+
35
+ violations = ParsePackwerk.all.flat_map do |pack|
36
+ pack.violations.map do |violation|
37
+ QueryPackwerk::Violation.new(
38
+ original_violation: violation,
39
+ consuming_pack: pack
40
+ )
41
+ end
42
+ end
43
+
44
+ @all = QueryPackwerk::Violations.new(violations)
45
+ end
46
+
47
+ # Wrap the interface `where` with this type
48
+ sig do
49
+ params(
50
+ query_params: T.untyped, # Array, or anything responding to `===`, which can't be typed
51
+ query_fn: T.nilable(T.proc.params(arg0: T.untyped).returns(T::Boolean))
52
+ ).returns(QueryPackwerk::Violations)
53
+ end
54
+ def where(**query_params, &query_fn)
55
+ QueryPackwerk::Violations.new(super(**query_params, &query_fn))
56
+ end
57
+
58
+ sig { void }
59
+ def reload!
60
+ @all = nil
61
+ end
62
+ end
63
+
64
+ sig do
65
+ params(
66
+ original_collection: T::Array[QueryPackwerk::Violation],
67
+ file_cache: QueryPackwerk::FileCache
68
+ ).void
69
+ end
70
+ def initialize(original_collection, file_cache: QueryPackwerk::FileCache.new)
71
+ @original_collection = original_collection
72
+ @file_cache = T.let(file_cache, QueryPackwerk::FileCache)
73
+
74
+ @original_collection.each do |violation|
75
+ violation.set_cache!(file_cache)
76
+ end
77
+
78
+ @cache_loaded = T.let(false, T::Boolean)
79
+ @sources_loaded = T.let(false, T::Boolean)
80
+ end
81
+
82
+ sig { void }
83
+ def load_cache!
84
+ return true if @cache_loaded
85
+
86
+ warn "Prepopulating AST cache with #{file_count} files: "
87
+ start_time = Time.now
88
+
89
+ @original_collection.each(&:load_cache!)
90
+
91
+ finish_time = Time.now - start_time
92
+ warn '', "AST cache loaded in #{finish_time}"
93
+ @cache_loaded = true
94
+ end
95
+
96
+ sig { void }
97
+ def load_sources!
98
+ return true if @sources_loaded
99
+
100
+ unless @cache_loaded
101
+ load_cache!
102
+ warn
103
+ end
104
+
105
+ warn "Prepopulating sources cache with #{count} violations: "
106
+ start_time = Time.now
107
+
108
+ total_sources_loaded = @original_collection.sum do |violation|
109
+ $stderr.print '.'
110
+ violation.sources.size
111
+ end
112
+
113
+ finish_time = Time.now - start_time
114
+ warn "Loaded #{total_sources_loaded} full sources in #{finish_time}"
115
+
116
+ @sources_loaded = true
117
+ end
118
+
119
+ sig { returns(Integer) }
120
+ def file_count
121
+ @original_collection.sum(&:file_count)
122
+ end
123
+
124
+ # Gets all sources and their receiving chains grouped by the constant they've violated.
125
+ sig { returns(T.untyped) }
126
+ def raw_sources
127
+ load_sources!
128
+
129
+ deep_merge_groups(@original_collection) do |v|
130
+ [v.class_name, v.sources]
131
+ end
132
+ end
133
+
134
+ # Gets all sources and their receiving chains grouped by the constant they've violated.
135
+ sig { returns(T::Hash[String, T::Array[String]]) }
136
+ def sources
137
+ load_sources!
138
+
139
+ deep_merge_groups(@original_collection) { |v| [v.class_name, v.sources.map(&:source)] }.transform_values(&:uniq)
140
+ end
141
+
142
+ # In addition to the above also provide the file location and line number along with the
143
+ # source.
144
+ sig { returns(T::Hash[String, T::Array[String]]) }
145
+ def sources_with_locations
146
+ load_sources!
147
+
148
+ deep_merge_groups(@original_collection) { |v| [v.class_name, v.sources_with_locations] }
149
+ end
150
+
151
+ # Instead of getting all instances of the source, count how often each occurs, with the option to
152
+ # provide a threshold to remove lower-occuring items.
153
+ sig { params(threshold: Integer).returns(T::Hash[String, T::Hash[String, Integer]]) }
154
+ def source_counts(threshold: 0)
155
+ load_sources!
156
+
157
+ deep_merge_counts(@original_collection, threshold:) { |v| [v.class_name, v.source_counts] }
158
+ end
159
+
160
+ # "Anonymize" the arguments of sources by replacing all arguments with underscores to get a look
161
+ # at the "shape" of a function rather than its exact call (i.e. `test(1, 2, 3)` becomes `test(_, _, _)`).
162
+ #
163
+ # This also removes extra spacing, line-breaks, cbase constant sigils, and other extra information to
164
+ # give a clearer view of a call's "shape".
165
+ sig { returns(T::Hash[String, T::Array[String]]) }
166
+ def anonymous_sources
167
+ load_sources!
168
+
169
+ deep_merge_groups(@original_collection) { |v| [v.class_name, v.anonymous_sources] }.transform_values(&:uniq)
170
+ end
171
+
172
+ sig { returns(T::Hash[String, T::Hash[String, T::Array[String]]]) }
173
+ def anonymous_sources_with_locations
174
+ load_sources!
175
+
176
+ deep_merge_hash_groups(@original_collection) { |v| [v.class_name, v.anonymous_sources_with_locations] }
177
+ end
178
+
179
+ sig do
180
+ params(start_offset: Integer, end_offset: Integer).returns(T::Hash[String, T::Hash[String, T::Array[String]]])
181
+ end
182
+ def sources_with_contexts(start_offset: 3, end_offset: 3)
183
+ load_sources!
184
+
185
+ deep_merge_hash_groups(@original_collection) { |v| [v.class_name, v.sources_with_contexts] }
186
+ end
187
+
188
+ sig { params(start_offset: Integer, end_offset: Integer).returns(String) }
189
+ def sources_with_contexts_report(start_offset: 3, end_offset: 3)
190
+ contexts = sources_with_contexts(start_offset:, end_offset:)
191
+ output = +''
192
+
193
+ contexts.each do |violated_constant, anonymized_sources|
194
+ heavy_underline = '=' * violated_constant.size
195
+ output << "#{violated_constant}\n#{heavy_underline}\n\n"
196
+
197
+ anonymized_sources.each do |anonymized_source, full_contexts|
198
+ light_underline = '-' * anonymized_source.size
199
+ output << "#{anonymized_source}\n#{light_underline}\n\n"
200
+
201
+ full_contexts.each do |context|
202
+ output << highlight_ruby(context)
203
+ output << "\n\n"
204
+ end
205
+ end
206
+ end
207
+
208
+ output
209
+ end
210
+
211
+ # Like the above source counts, but uses anonymized sources to give a clearer look at how often each
212
+ # "shape" of a method is called across a set of violations.
213
+ sig { params(threshold: Integer).returns(T::Hash[String, T::Hash[String, Integer]]) }
214
+ def anonymous_source_counts(threshold: 0)
215
+ load_sources!
216
+
217
+ deep_merge_counts(@original_collection, threshold:) { |v| [v.class_name, v.anonymous_source_counts] }
218
+ end
219
+
220
+ # Find which packs consume these violations
221
+ sig { params(threshold: Integer).returns(T::Hash[String, Integer]) }
222
+ def consumers(threshold: 0)
223
+ tallies = @original_collection.map { |v| v.consuming_pack.name }.tally
224
+ threshold_filter_sort(tallies, threshold:)
225
+ end
226
+
227
+ # Find which packs produce these violations
228
+ sig { params(threshold: Integer).returns(T::Hash[String, Integer]) }
229
+ def producers(threshold: 0)
230
+ tallies = @original_collection.map { |v| v.producing_pack.name }.tally
231
+ threshold_filter_sort(tallies, threshold:)
232
+ end
233
+
234
+ # Filter for violations which include one of the provided file globs
235
+ sig { params(file_globs: T.any(String, Regexp)).returns(QueryPackwerk::Violations) }
236
+ def including_files(*file_globs)
237
+ filtered_violations = @original_collection.select do |violation|
238
+ T.unsafe(violation).includes_files?(*file_globs) # Sorbet hates splats
239
+ end
240
+
241
+ QueryPackwerk::Violations.new(filtered_violations)
242
+ end
243
+
244
+ # Filter for violations which do not include one of the provided file globs
245
+ sig { params(file_globs: T.any(String, Regexp)).returns(QueryPackwerk::Violations) }
246
+ def excluding_files(*file_globs)
247
+ filtered_violations = @original_collection.reject do |violation|
248
+ T.unsafe(violation).includes_files?(*file_globs) # Sorbet hates splats
249
+ end
250
+
251
+ QueryPackwerk::Violations.new(filtered_violations)
252
+ end
253
+
254
+ sig { returns(String) }
255
+ def inspect
256
+ [
257
+ "#<#{self.class.name} [",
258
+ to_a.map(&:inspect).join("\n"),
259
+ ']>'
260
+ ].join("\n")
261
+ end
262
+
263
+ private
264
+
265
+ sig { params(string: String).returns(String) }
266
+ def highlight_ruby(string)
267
+ CodeRay.encode(string, :ruby, :terminal)
268
+ end
269
+ end
270
+ end