bullematic 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/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +180 -0
- data/Rakefile +26 -0
- data/Steepfile +15 -0
- data/assets/logo-header.svg +28 -0
- data/lib/bullematic/ast/finder.rb +175 -0
- data/lib/bullematic/ast/parser.rb +58 -0
- data/lib/bullematic/ast/rewriter.rb +153 -0
- data/lib/bullematic/configuration.rb +66 -0
- data/lib/bullematic/detection.rb +109 -0
- data/lib/bullematic/fixer.rb +110 -0
- data/lib/bullematic/integrations/minitest.rb +37 -0
- data/lib/bullematic/integrations/rails.rb +40 -0
- data/lib/bullematic/integrations/rspec.rb +33 -0
- data/lib/bullematic/logger.rb +103 -0
- data/lib/bullematic/notifier.rb +76 -0
- data/lib/bullematic/version.rb +6 -0
- data/lib/bullematic.rb +54 -0
- data/rbs_collection.lock.yaml +24 -0
- data/rbs_collection.yaml +14 -0
- data/sig/generated/bullematic/ast/finder.rbs +91 -0
- data/sig/generated/bullematic/ast/parser.rbs +39 -0
- data/sig/generated/bullematic/ast/rewriter.rbs +77 -0
- data/sig/generated/bullematic/configuration.rbs +135 -0
- data/sig/generated/bullematic/detection.rbs +117 -0
- data/sig/generated/bullematic/fixer.rbs +33 -0
- data/sig/generated/bullematic/integrations/minitest.rbs +18 -0
- data/sig/generated/bullematic/integrations/rails.rbs +20 -0
- data/sig/generated/bullematic/integrations/rspec.rbs +10 -0
- data/sig/generated/bullematic/logger.rbs +61 -0
- data/sig/generated/bullematic/notifier.rbs +30 -0
- data/sig/generated/bullematic/version.rbs +5 -0
- data/sig/generated/bullematic.rbs +27 -0
- data/sig/stubs/rails.rbs +87 -0
- metadata +108 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Bullematic
|
|
7
|
+
module AST
|
|
8
|
+
class Finder
|
|
9
|
+
QUERY_METHODS = %i[all where find find_by first last order limit offset].freeze #: Array[Symbol]
|
|
10
|
+
|
|
11
|
+
# @rbs!
|
|
12
|
+
# type query_location = { node: untyped, location: untyped, receiver: untyped, method_name: Symbol }
|
|
13
|
+
|
|
14
|
+
QueryLocation = Struct.new(:node, :location, :receiver, :method_name, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# @rbs @parse_result: untyped
|
|
17
|
+
|
|
18
|
+
# @rbs!
|
|
19
|
+
# attr_reader parse_result: untyped
|
|
20
|
+
attr_reader :parse_result
|
|
21
|
+
|
|
22
|
+
# @rbs parse_result: untyped
|
|
23
|
+
# @rbs return: void
|
|
24
|
+
def initialize(parse_result)
|
|
25
|
+
@parse_result = parse_result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs model_class_name: String
|
|
29
|
+
# @rbs line_number: Integer?
|
|
30
|
+
# @rbs return: Array[QueryLocation]
|
|
31
|
+
def find_model_queries(model_class_name, line_number: nil)
|
|
32
|
+
queries = [] #: Array[QueryLocation]
|
|
33
|
+
visit_node(parse_result.value, queries, model_class_name, line_number)
|
|
34
|
+
queries
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs line_number: Integer
|
|
38
|
+
# @rbs return: QueryLocation?
|
|
39
|
+
def find_query_at_line(line_number)
|
|
40
|
+
queries = [] #: Array[QueryLocation]
|
|
41
|
+
visit_all_queries(parse_result.value, queries, line_number)
|
|
42
|
+
queries.first
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @rbs node: untyped
|
|
48
|
+
# @rbs queries: Array[QueryLocation]
|
|
49
|
+
# @rbs model_class_name: String
|
|
50
|
+
# @rbs target_line: Integer?
|
|
51
|
+
# @rbs skip_nodes: Set[Integer]
|
|
52
|
+
# @rbs return: void
|
|
53
|
+
def visit_node(node, queries, model_class_name, target_line, skip_nodes = Set.new)
|
|
54
|
+
return unless node.respond_to?(:child_nodes)
|
|
55
|
+
return if skip_nodes.include?(node.object_id)
|
|
56
|
+
|
|
57
|
+
case node
|
|
58
|
+
when Prism::InstanceVariableWriteNode
|
|
59
|
+
if check_assignment_node(node, queries, model_class_name, target_line)
|
|
60
|
+
mark_descendant_calls(node.value, skip_nodes)
|
|
61
|
+
end
|
|
62
|
+
when Prism::CallNode
|
|
63
|
+
check_call_node(node, queries, model_class_name, target_line)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
node.child_nodes.compact.each do |child|
|
|
67
|
+
visit_node(child, queries, model_class_name, target_line, skip_nodes)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs node: untyped
|
|
72
|
+
# @rbs skip_nodes: Set[Integer]
|
|
73
|
+
# @rbs return: void
|
|
74
|
+
def mark_descendant_calls(node, skip_nodes)
|
|
75
|
+
return unless node.respond_to?(:child_nodes)
|
|
76
|
+
|
|
77
|
+
skip_nodes.add(node.object_id) if node.is_a?(Prism::CallNode)
|
|
78
|
+
|
|
79
|
+
node.child_nodes.compact.each do |child|
|
|
80
|
+
mark_descendant_calls(child, skip_nodes)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @rbs node: untyped
|
|
85
|
+
# @rbs queries: Array[QueryLocation]
|
|
86
|
+
# @rbs target_line: Integer?
|
|
87
|
+
# @rbs return: void
|
|
88
|
+
def visit_all_queries(node, queries, target_line)
|
|
89
|
+
return unless node.respond_to?(:child_nodes)
|
|
90
|
+
|
|
91
|
+
if node.is_a?(Prism::CallNode) && query_method?(node.name)
|
|
92
|
+
node_line = node.location.start_line
|
|
93
|
+
if target_line.nil? || node_line == target_line
|
|
94
|
+
queries << QueryLocation.new(
|
|
95
|
+
node: node,
|
|
96
|
+
location: node.location,
|
|
97
|
+
receiver: find_root_receiver(node),
|
|
98
|
+
method_name: node.name
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
node.child_nodes.compact.each do |child|
|
|
104
|
+
visit_all_queries(child, queries, target_line)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @rbs node: untyped
|
|
109
|
+
# @rbs queries: Array[QueryLocation]
|
|
110
|
+
# @rbs model_class_name: String
|
|
111
|
+
# @rbs target_line: Integer?
|
|
112
|
+
# @rbs return: void
|
|
113
|
+
def check_call_node(node, queries, model_class_name, target_line)
|
|
114
|
+
return unless query_method?(node.name)
|
|
115
|
+
return if target_line && node.location.start_line != target_line
|
|
116
|
+
|
|
117
|
+
root_receiver = find_root_receiver(node)
|
|
118
|
+
return unless model_constant?(root_receiver, model_class_name)
|
|
119
|
+
|
|
120
|
+
queries << QueryLocation.new(
|
|
121
|
+
node: node,
|
|
122
|
+
location: node.location,
|
|
123
|
+
receiver: root_receiver,
|
|
124
|
+
method_name: node.name
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @rbs node: untyped
|
|
129
|
+
# @rbs queries: Array[QueryLocation]
|
|
130
|
+
# @rbs model_class_name: String
|
|
131
|
+
# @rbs target_line: Integer?
|
|
132
|
+
# @rbs return: bool
|
|
133
|
+
def check_assignment_node(node, queries, model_class_name, target_line)
|
|
134
|
+
return false if target_line && node.location.start_line != target_line
|
|
135
|
+
|
|
136
|
+
value = node.value
|
|
137
|
+
return false unless value.is_a?(Prism::CallNode)
|
|
138
|
+
|
|
139
|
+
root_receiver = find_root_receiver(value)
|
|
140
|
+
return false unless model_constant?(root_receiver, model_class_name)
|
|
141
|
+
|
|
142
|
+
queries << QueryLocation.new(
|
|
143
|
+
node: value,
|
|
144
|
+
location: value.location,
|
|
145
|
+
receiver: root_receiver,
|
|
146
|
+
method_name: value.name
|
|
147
|
+
)
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @rbs node: untyped
|
|
152
|
+
# @rbs return: untyped
|
|
153
|
+
def find_root_receiver(node)
|
|
154
|
+
current = node
|
|
155
|
+
current = current.receiver while current.is_a?(Prism::CallNode) && current.receiver
|
|
156
|
+
current
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @rbs node: untyped
|
|
160
|
+
# @rbs model_class_name: String
|
|
161
|
+
# @rbs return: bool
|
|
162
|
+
def model_constant?(node, model_class_name)
|
|
163
|
+
return false unless node.is_a?(Prism::ConstantReadNode)
|
|
164
|
+
|
|
165
|
+
node.name.to_s == model_class_name
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @rbs method_name: Symbol
|
|
169
|
+
# @rbs return: bool
|
|
170
|
+
def query_method?(method_name)
|
|
171
|
+
QUERY_METHODS.include?(method_name)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Bullematic
|
|
7
|
+
module AST
|
|
8
|
+
class Parser
|
|
9
|
+
# @rbs @source_code: String
|
|
10
|
+
# @rbs @filepath: String?
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
#: () -> Hash[String, untyped]
|
|
14
|
+
def cache
|
|
15
|
+
@cache ||= {} #: Hash[String, untyped]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> void
|
|
19
|
+
def clear_cache
|
|
20
|
+
@cache = {} #: Hash[String, untyped]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs filepath: String
|
|
24
|
+
# @rbs return: untyped
|
|
25
|
+
def parse_file(filepath)
|
|
26
|
+
return cache[filepath] if cache.key?(filepath)
|
|
27
|
+
|
|
28
|
+
result = Prism.parse_file(filepath)
|
|
29
|
+
raise ParseError, result.errors.map(&:message).join("\n") if result.failure?
|
|
30
|
+
|
|
31
|
+
cache[filepath] = result
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs!
|
|
37
|
+
# attr_reader source_code: String
|
|
38
|
+
# attr_reader filepath: String?
|
|
39
|
+
attr_reader :source_code, :filepath
|
|
40
|
+
|
|
41
|
+
# @rbs source_code: String
|
|
42
|
+
# @rbs filepath: String?
|
|
43
|
+
# @rbs return: void
|
|
44
|
+
def initialize(source_code, filepath: nil)
|
|
45
|
+
@source_code = source_code
|
|
46
|
+
@filepath = filepath
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#: () -> untyped
|
|
50
|
+
def parse
|
|
51
|
+
result = Prism.parse(@source_code, filepath: @filepath)
|
|
52
|
+
raise ParseError, result.errors.map(&:message).join("\n") if result.failure?
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Bullematic
|
|
5
|
+
module AST
|
|
6
|
+
class Rewriter
|
|
7
|
+
# @rbs!
|
|
8
|
+
# type modification = { type: Symbol, offset: Integer, byte_length: Integer, new_text: String, associations: Array[Symbol] }
|
|
9
|
+
|
|
10
|
+
Modification = Struct.new(:type, :offset, :byte_length, :new_text, :associations, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# @rbs @source: String
|
|
13
|
+
# @rbs @modifications: Array[Modification]
|
|
14
|
+
|
|
15
|
+
# @rbs!
|
|
16
|
+
# attr_reader source: String
|
|
17
|
+
# attr_reader modifications: Array[Modification]
|
|
18
|
+
attr_reader :source, :modifications
|
|
19
|
+
|
|
20
|
+
# @rbs source: String
|
|
21
|
+
# @rbs return: void
|
|
22
|
+
def initialize(source)
|
|
23
|
+
@source = source.dup
|
|
24
|
+
@modifications = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @rbs query_location: Finder::QueryLocation
|
|
28
|
+
# @rbs associations: Array[Symbol]
|
|
29
|
+
# @rbs return: void
|
|
30
|
+
def add_includes(query_location, associations)
|
|
31
|
+
return if already_has_includes?(query_location, associations)
|
|
32
|
+
|
|
33
|
+
strategy = Bullematic.configuration&.fix_strategy || :includes
|
|
34
|
+
insert_point = find_insert_point(query_location)
|
|
35
|
+
|
|
36
|
+
assoc_string = format_associations(associations)
|
|
37
|
+
new_text = ".#{strategy}(#{assoc_string})"
|
|
38
|
+
|
|
39
|
+
@modifications << Modification.new(
|
|
40
|
+
type: :insert,
|
|
41
|
+
offset: insert_point,
|
|
42
|
+
byte_length: 0,
|
|
43
|
+
new_text: new_text,
|
|
44
|
+
associations: associations
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: () -> String
|
|
49
|
+
def rewrite
|
|
50
|
+
return @source if @modifications.empty?
|
|
51
|
+
|
|
52
|
+
sorted = @modifications.sort_by { |m| -m.offset }
|
|
53
|
+
|
|
54
|
+
result = @source.dup
|
|
55
|
+
sorted.each do |mod|
|
|
56
|
+
case mod.type
|
|
57
|
+
when :insert
|
|
58
|
+
result.insert(mod.offset, mod.new_text)
|
|
59
|
+
when :replace
|
|
60
|
+
result[mod.offset, mod.byte_length] = mod.new_text
|
|
61
|
+
when :delete
|
|
62
|
+
result[mod.offset, mod.byte_length] = ""
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @rbs query_location: Finder::QueryLocation
|
|
72
|
+
# @rbs return: Integer
|
|
73
|
+
def find_insert_point(query_location)
|
|
74
|
+
node = query_location.node
|
|
75
|
+
receiver = query_location.receiver
|
|
76
|
+
|
|
77
|
+
if receiver.is_a?(Prism::ConstantReadNode)
|
|
78
|
+
receiver.location.end_offset
|
|
79
|
+
else
|
|
80
|
+
find_chain_insert_point(node)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @rbs node: untyped
|
|
85
|
+
# @rbs return: Integer
|
|
86
|
+
def find_chain_insert_point(node)
|
|
87
|
+
current = node
|
|
88
|
+
current = current.receiver while current.is_a?(Prism::CallNode) && current.receiver.is_a?(Prism::CallNode)
|
|
89
|
+
|
|
90
|
+
if current.receiver
|
|
91
|
+
current.receiver.location.end_offset
|
|
92
|
+
else
|
|
93
|
+
current.location.start_offset
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @rbs query_location: Finder::QueryLocation
|
|
98
|
+
# @rbs associations: Array[Symbol]
|
|
99
|
+
# @rbs return: bool
|
|
100
|
+
def already_has_includes?(query_location, associations)
|
|
101
|
+
existing = find_existing_includes(query_location.node)
|
|
102
|
+
return false if existing.empty?
|
|
103
|
+
|
|
104
|
+
associations.all? { |assoc| existing.include?(assoc) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @rbs node: untyped
|
|
108
|
+
# @rbs return: Array[Symbol]
|
|
109
|
+
def find_existing_includes(node)
|
|
110
|
+
includes = [] #: Array[Symbol]
|
|
111
|
+
current = node
|
|
112
|
+
|
|
113
|
+
while current.is_a?(Prism::CallNode)
|
|
114
|
+
if %i[includes preload eager_load].include?(current.name)
|
|
115
|
+
includes.concat(extract_associations_from_call(current))
|
|
116
|
+
end
|
|
117
|
+
current = current.receiver
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
includes
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @rbs call_node: untyped
|
|
124
|
+
# @rbs return: Array[Symbol]
|
|
125
|
+
def extract_associations_from_call(call_node)
|
|
126
|
+
return [] unless call_node.arguments
|
|
127
|
+
|
|
128
|
+
associations = [] #: Array[Symbol]
|
|
129
|
+
call_node.arguments.arguments.each do |arg|
|
|
130
|
+
case arg
|
|
131
|
+
when Prism::SymbolNode
|
|
132
|
+
associations << arg.value.to_sym
|
|
133
|
+
when Prism::ArrayNode
|
|
134
|
+
arg.elements.each do |elem|
|
|
135
|
+
associations << elem.value.to_sym if elem.is_a?(Prism::SymbolNode)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
associations
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @rbs associations: Array[Symbol]
|
|
143
|
+
# @rbs return: String
|
|
144
|
+
def format_associations(associations)
|
|
145
|
+
if associations.size == 1
|
|
146
|
+
":#{associations.first}"
|
|
147
|
+
else
|
|
148
|
+
associations.map { |a| ":#{a}" }.join(", ")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module Bullematic
|
|
7
|
+
class Configuration
|
|
8
|
+
# @rbs skip: to avoid empty array warning
|
|
9
|
+
DEFAULTS = { #: Hash[Symbol, untyped]
|
|
10
|
+
enabled: true,
|
|
11
|
+
auto_fix: true,
|
|
12
|
+
target_paths: %w[app/controllers app/models app/services],
|
|
13
|
+
skip_paths: [],
|
|
14
|
+
dry_run: false,
|
|
15
|
+
backup: false,
|
|
16
|
+
fix_strategy: :includes,
|
|
17
|
+
logger: nil,
|
|
18
|
+
debug: false
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# @rbs @enabled: bool
|
|
22
|
+
# @rbs @auto_fix: bool
|
|
23
|
+
# @rbs @target_paths: Array[String]
|
|
24
|
+
# @rbs @skip_paths: Array[String]
|
|
25
|
+
# @rbs @dry_run: bool
|
|
26
|
+
# @rbs @backup: bool
|
|
27
|
+
# @rbs @fix_strategy: Symbol
|
|
28
|
+
# @rbs @debug: bool
|
|
29
|
+
# @rbs @logger: Logger?
|
|
30
|
+
|
|
31
|
+
# @rbs!
|
|
32
|
+
# attr_accessor enabled: bool
|
|
33
|
+
# attr_accessor auto_fix: bool
|
|
34
|
+
# attr_accessor target_paths: Array[String]
|
|
35
|
+
# attr_accessor skip_paths: Array[String]
|
|
36
|
+
# attr_accessor dry_run: bool
|
|
37
|
+
# attr_accessor backup: bool
|
|
38
|
+
# attr_accessor fix_strategy: Symbol
|
|
39
|
+
# attr_accessor debug: bool
|
|
40
|
+
# attr_writer logger: Logger?
|
|
41
|
+
attr_accessor :enabled, :auto_fix, :target_paths, :skip_paths,
|
|
42
|
+
:dry_run, :backup, :fix_strategy, :debug
|
|
43
|
+
attr_writer :logger
|
|
44
|
+
|
|
45
|
+
#: () -> void
|
|
46
|
+
def initialize
|
|
47
|
+
DEFAULTS.each { |key, value| public_send(:"#{key}=", value.dup) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> Logger
|
|
51
|
+
def logger
|
|
52
|
+
@logger ||= default_logger
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
#: () -> Logger
|
|
58
|
+
def default_logger
|
|
59
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
60
|
+
::Logger.new(Rails.root.join("log", "bullematic.log"))
|
|
61
|
+
else
|
|
62
|
+
::Logger.new($stdout)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Bullematic
|
|
5
|
+
class Detection
|
|
6
|
+
# @rbs @type: Symbol
|
|
7
|
+
# @rbs @base_class: untyped
|
|
8
|
+
# @rbs @associations: Array[Symbol]
|
|
9
|
+
# @rbs @call_stack: Array[String]
|
|
10
|
+
# @rbs @source_file: String?
|
|
11
|
+
# @rbs @line_number: Integer?
|
|
12
|
+
# @rbs @method_name: String?
|
|
13
|
+
|
|
14
|
+
# @rbs!
|
|
15
|
+
# attr_reader type: Symbol
|
|
16
|
+
# attr_reader base_class: untyped
|
|
17
|
+
# attr_reader associations: Array[Symbol]
|
|
18
|
+
# attr_reader call_stack: Array[String]
|
|
19
|
+
# attr_reader source_file: String?
|
|
20
|
+
# attr_reader line_number: Integer?
|
|
21
|
+
# attr_reader method_name: String?
|
|
22
|
+
attr_reader :type, :base_class, :associations, :call_stack,
|
|
23
|
+
:source_file, :line_number, :method_name
|
|
24
|
+
|
|
25
|
+
# @rbs type: Symbol
|
|
26
|
+
# @rbs base_class: untyped
|
|
27
|
+
# @rbs associations: untyped
|
|
28
|
+
# @rbs call_stack: Array[String]
|
|
29
|
+
# @rbs return: void
|
|
30
|
+
def initialize(type:, base_class:, associations:, call_stack:)
|
|
31
|
+
@type = type
|
|
32
|
+
@base_class = base_class
|
|
33
|
+
@associations = normalize_associations(associations)
|
|
34
|
+
@call_stack = call_stack
|
|
35
|
+
parse_source_location
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: () -> String
|
|
39
|
+
def model_class_name
|
|
40
|
+
base_class.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: () -> bool
|
|
44
|
+
def fixable?
|
|
45
|
+
!source_file.nil? &&
|
|
46
|
+
!source_file.empty? &&
|
|
47
|
+
target_path? &&
|
|
48
|
+
!skip_path?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# @rbs assocs: untyped
|
|
54
|
+
# @rbs return: Array[Symbol]
|
|
55
|
+
def normalize_associations(assocs)
|
|
56
|
+
Array(assocs).map(&:to_sym)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: () -> void
|
|
60
|
+
def parse_source_location
|
|
61
|
+
config = Bullematic.configuration
|
|
62
|
+
return unless config
|
|
63
|
+
|
|
64
|
+
target_paths = config.target_paths
|
|
65
|
+
|
|
66
|
+
@call_stack.each do |frame|
|
|
67
|
+
next unless frame.is_a?(String)
|
|
68
|
+
|
|
69
|
+
match = frame.match(/\A(.+):(\d+)(?::in `(.+)')?/)
|
|
70
|
+
next unless match
|
|
71
|
+
|
|
72
|
+
filepath = match[1]
|
|
73
|
+
line = match[2].to_i
|
|
74
|
+
method = match[3]
|
|
75
|
+
|
|
76
|
+
next unless filepath && target_paths.any? { |path| filepath.include?(path) }
|
|
77
|
+
|
|
78
|
+
@source_file = filepath
|
|
79
|
+
@line_number = line
|
|
80
|
+
@method_name = method
|
|
81
|
+
break
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: () -> bool
|
|
86
|
+
def target_path?
|
|
87
|
+
return false if source_file.nil?
|
|
88
|
+
|
|
89
|
+
config = Bullematic.configuration
|
|
90
|
+
return false unless config
|
|
91
|
+
|
|
92
|
+
config.target_paths.any? do |path|
|
|
93
|
+
source_file&.include?(path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
#: () -> bool
|
|
98
|
+
def skip_path?
|
|
99
|
+
return false if source_file.nil?
|
|
100
|
+
|
|
101
|
+
config = Bullematic.configuration
|
|
102
|
+
return false unless config
|
|
103
|
+
|
|
104
|
+
config.skip_paths.any? do |path|
|
|
105
|
+
source_file&.include?(path)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Bullematic
|
|
7
|
+
class Fixer
|
|
8
|
+
class << self
|
|
9
|
+
#: () -> Array[Detection]
|
|
10
|
+
def detection_queue
|
|
11
|
+
@detection_queue ||= [] #: Array[Detection]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @rbs detection: Detection
|
|
15
|
+
# @rbs return: void
|
|
16
|
+
def queue(detection)
|
|
17
|
+
detection_queue << detection
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#: () -> void
|
|
21
|
+
def clear
|
|
22
|
+
@detection_queue = [] #: Array[Detection]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: () -> void
|
|
26
|
+
def apply_fixes
|
|
27
|
+
return if detection_queue.empty?
|
|
28
|
+
|
|
29
|
+
logger = BullematicLogger.new
|
|
30
|
+
|
|
31
|
+
grouped = detection_queue.group_by(&:source_file)
|
|
32
|
+
|
|
33
|
+
grouped.each do |filepath, detections|
|
|
34
|
+
next unless filepath
|
|
35
|
+
|
|
36
|
+
process_file(filepath, detections, logger)
|
|
37
|
+
rescue ParseError, FixError => e
|
|
38
|
+
logger.log_error(filepath, e)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
logger.log_error(filepath, e)
|
|
41
|
+
raise if Bullematic.configuration&.debug
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
logger.log_summary
|
|
45
|
+
clear
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @rbs filepath: String
|
|
51
|
+
# @rbs detections: Array[Detection]
|
|
52
|
+
# @rbs logger: BullematicLogger
|
|
53
|
+
# @rbs return: void
|
|
54
|
+
def process_file(filepath, detections, logger)
|
|
55
|
+
return unless File.exist?(filepath)
|
|
56
|
+
|
|
57
|
+
original_source = File.read(filepath)
|
|
58
|
+
parse_result = AST::Parser.parse_file(filepath)
|
|
59
|
+
finder = AST::Finder.new(parse_result)
|
|
60
|
+
rewriter = AST::Rewriter.new(original_source)
|
|
61
|
+
|
|
62
|
+
detections.each do |detection|
|
|
63
|
+
location = find_query_location(finder, detection)
|
|
64
|
+
|
|
65
|
+
if location.nil?
|
|
66
|
+
logger.log_skip(filepath, detection, "could not locate query")
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
rewriter.add_includes(location, detection.associations)
|
|
71
|
+
logger.log_fix(filepath, detection)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
new_source = rewriter.rewrite
|
|
75
|
+
|
|
76
|
+
return if new_source == original_source
|
|
77
|
+
|
|
78
|
+
if Bullematic.configuration&.dry_run
|
|
79
|
+
logger.log_dry_run(filepath, original_source, new_source)
|
|
80
|
+
else
|
|
81
|
+
backup_file(filepath) if Bullematic.configuration&.backup
|
|
82
|
+
File.write(filepath, new_source)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @rbs finder: AST::Finder
|
|
87
|
+
# @rbs detection: Detection
|
|
88
|
+
# @rbs return: AST::Finder::QueryLocation?
|
|
89
|
+
def find_query_location(finder, detection)
|
|
90
|
+
queries = finder.find_model_queries(
|
|
91
|
+
detection.model_class_name,
|
|
92
|
+
line_number: detection.line_number
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return queries.first unless queries.empty?
|
|
96
|
+
|
|
97
|
+
return nil unless detection.line_number
|
|
98
|
+
|
|
99
|
+
finder.find_query_at_line(detection.line_number)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @rbs filepath: String
|
|
103
|
+
# @rbs return: void
|
|
104
|
+
def backup_file(filepath)
|
|
105
|
+
backup_path = "#{filepath}.bullematic.bak"
|
|
106
|
+
FileUtils.cp(filepath, backup_path)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Bullematic
|
|
5
|
+
module Integrations
|
|
6
|
+
module Minitest
|
|
7
|
+
class << self
|
|
8
|
+
#: () -> void
|
|
9
|
+
def setup
|
|
10
|
+
::Minitest.after_run do
|
|
11
|
+
Bullematic::Fixer.apply_fixes if Bullematic.configuration&.enabled && Bullematic.configuration&.auto_fix
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module TestHelper
|
|
17
|
+
#: () -> void
|
|
18
|
+
def setup
|
|
19
|
+
super
|
|
20
|
+
return unless Bullematic.enabled?
|
|
21
|
+
|
|
22
|
+
Bullet.start_request if defined?(Bullet) && Bullet.enable?
|
|
23
|
+
Bullematic::Fixer.clear
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: () -> void
|
|
27
|
+
def teardown
|
|
28
|
+
if Bullematic.enabled? && defined?(Bullet) && Bullet.enable?
|
|
29
|
+
Bullematic::Notifier.process_notifications if Bullet.notification?
|
|
30
|
+
Bullet.end_request
|
|
31
|
+
end
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|