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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +1 -0
- data/CODE_OF_CONDUCT.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/exe/query_packwerk +7 -0
- data/lib/query_packwerk/cli.rb +19 -0
- data/lib/query_packwerk/console.rb +65 -0
- data/lib/query_packwerk/console_helpers.rb +144 -0
- data/lib/query_packwerk/file_cache.rb +160 -0
- data/lib/query_packwerk/package.rb +129 -0
- data/lib/query_packwerk/packages.rb +78 -0
- data/lib/query_packwerk/query_interface.rb +268 -0
- data/lib/query_packwerk/rule_rewriter/anonymize_arguments_rule.rb +31 -0
- data/lib/query_packwerk/rule_rewriter/anonymize_keyword_arguments_rule.rb +30 -0
- data/lib/query_packwerk/rule_rewriter/base_rule.rb +30 -0
- data/lib/query_packwerk/rule_rewriter/rule_set_rewriter.rb +56 -0
- data/lib/query_packwerk/rule_rewriter.rb +22 -0
- data/lib/query_packwerk/version.rb +6 -0
- data/lib/query_packwerk/violation.rb +295 -0
- data/lib/query_packwerk/violations.rb +270 -0
- data/lib/query_packwerk.rb +92 -0
- data/sig/query_packwerk.rbs +4 -0
- metadata +153 -0
@@ -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,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
|