jitcatch 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/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module JitCatch
|
|
6
|
+
# Lists, for each value a change edits in a Rails app, the code at head
|
|
7
|
+
# that reads that value without calling it by name: a route helper that
|
|
8
|
+
# reaches `to_param`, a `send` of a literal name, a callback, a scope, a
|
|
9
|
+
# constant in SQL, a template, a validation, a setting. Each row says how
|
|
10
|
+
# it reads the value, why that matters, and what a test would check.
|
|
11
|
+
#
|
|
12
|
+
# It reads source only: Prism over `app/`, `config/`, `lib/`,
|
|
13
|
+
# `db/schema.rb`, and ERB templates turned into Ruby line for line. It
|
|
14
|
+
# names an object's model by its variable or method name, so a reader
|
|
15
|
+
# whose receiver has an unrelated name is not listed (BLIND).
|
|
16
|
+
class Readers
|
|
17
|
+
# A value the change edits. `kind` is one of the RULES keys, `owner` the
|
|
18
|
+
# class as its own file names it (the table's model, for a schema default),
|
|
19
|
+
# which is how code elsewhere names it too, and `scope` the full name
|
|
20
|
+
# with its enclosing modules. `line` and `last_line` are the lines it is
|
|
21
|
+
# declared on, `state` is added, modified, or removed, and `node` is its
|
|
22
|
+
# declaration (the default value, for a default).
|
|
23
|
+
Value = Data.define(:kind, :owner, :scope, :name, :file, :line, :last_line, :state, :node) do
|
|
24
|
+
def label
|
|
25
|
+
case kind
|
|
26
|
+
when :constant then "#{scope}::#{name}"
|
|
27
|
+
when :attribute_default then "#{owner}.#{name} default"
|
|
28
|
+
when :method then "#{scope}##{name}"
|
|
29
|
+
else "#{scope}.#{name}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
Row = Data.define(:value, :file, :line, :mechanism, :why, :score, :rank, :test, :group, :also, :edited)
|
|
34
|
+
Source = Data.define(:path, :tree, :template)
|
|
35
|
+
# How strongly a mechanism points at a test, the reason that ranks it,
|
|
36
|
+
# and the test sentence, with `%<where>s` and the other fields a rule
|
|
37
|
+
# fills in.
|
|
38
|
+
Mechanism = Data.define(:score, :rank, :test)
|
|
39
|
+
|
|
40
|
+
BLIND = [
|
|
41
|
+
"receivers named unlike their model (`x = Branch.first; link_to x`)",
|
|
42
|
+
"dispatch names built at run time from data outside the source (a DB column, a request param)",
|
|
43
|
+
"routes drawn by `draw`, loops, or concerns; `to_param` reached through a gem's helper",
|
|
44
|
+
"serializers, jbuilder, and other template languages than ERB",
|
|
45
|
+
"callbacks and validations added by concerns or gems",
|
|
46
|
+
"config read through ENV or a YAML file at boot"
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
DISPATCH = %i[send public_send __send__ try try! method public_method respond_to? instance_method].freeze
|
|
50
|
+
SQL_CALLS = %i[where order reorder select joins group having pluck find_by_sql exec_query execute from not count_by_sql].freeze
|
|
51
|
+
QUERY_CALLS = %i[all find find_by find_by! where first last take count exists? pluck order includes find_each].freeze
|
|
52
|
+
CALLBACKS = /\A(before|after|around)_(validation|save|create|update|destroy|commit|initialize|find|touch)\z|\Aafter_(create|update|destroy|save)_commit\z/
|
|
53
|
+
VALIDATIONS = %i[validates validate validates_presence_of validates_uniqueness_of validates_inclusion_of].freeze
|
|
54
|
+
SQL_TEXT = /\b(SELECT|UPDATE|DELETE|INSERT|WHERE|JOIN)\b/
|
|
55
|
+
SCHEMA = "db/schema.rb"
|
|
56
|
+
READ_ROOTS = %w[app/ config/ lib/].freeze
|
|
57
|
+
|
|
58
|
+
MECHANISMS = {
|
|
59
|
+
route_param_mismatch: Mechanism.new(100, "the URL value and the lookup now disagree",
|
|
60
|
+
"Render %<where>s with a %<model>s whose to_param differs from its id, follow the URL %<helper>s builds, and check %<action>s finds that same %<model>s."),
|
|
61
|
+
route_param: Mechanism.new(70, "a URL carries to_param into a lookup",
|
|
62
|
+
"Render %<where>s, follow the URL %<helper>s builds for a %<model>s, and check %<action>s finds that same %<model>s."),
|
|
63
|
+
param_lookup: Mechanism.new(65, "an action turns a param back into a record",
|
|
64
|
+
"Request %<action>s with the param a %<model>s's to_param gives and check it finds that %<model>s."),
|
|
65
|
+
polymorphic_route: Mechanism.new(75, "a URL or partial path comes from the model",
|
|
66
|
+
"Render or request %<where>s with a %<model>s and check the URL or partial %<call>s builds from its model_name."),
|
|
67
|
+
json_render: Mechanism.new(85, "a response body serializes it", "Request %<where>s and check the JSON body it renders for a %<model>s."),
|
|
68
|
+
to_s_output: Mechanism.new(60, "a page prints it", "Render %<where>s with a %<model>s and check the text it prints for it."),
|
|
69
|
+
dispatch_literal: Mechanism.new(90, "a literal name dispatches to it",
|
|
70
|
+
"Run %<where>s so it dispatches :%<name>s and check the value it gets back from %<owner>s#%<name>s."),
|
|
71
|
+
dispatch_pattern: Mechanism.new(75, "a built name can reach it", "Run %<where>s with the input that builds the name %<name>s and check the value it gets back."),
|
|
72
|
+
dispatch_dynamic: Mechanism.new(70, "a computed dispatch on the owner can reach it",
|
|
73
|
+
"Run %<where>s with :%<name>s as the name it computes and check the value it gets back from %<owner>s."),
|
|
74
|
+
dispatch_name_source: Mechanism.new(55, "the name is written here, the dispatch is elsewhere",
|
|
75
|
+
"Run the path from %<where>s, where :%<name>s is written, to a dispatch on a %<owner>s, and check the value it gets back."),
|
|
76
|
+
callback: Mechanism.new(85, "Rails runs it on save", "Save a %<owner>s through %<where>s and check the attribute or record this callback sets."),
|
|
77
|
+
validation_reads: Mechanism.new(80, "a validation reads it on save", "Save a %<owner>s at the edge %<where>s checks and check whether it is valid."),
|
|
78
|
+
default_taker: Mechanism.new(85, "a record is created with the default", "Create a %<owner>s through %<where>s and check the %<name>s it is saved with."),
|
|
79
|
+
attribute_read: Mechanism.new(60, "a method reads the default", "Build a %<owner>s with %<name>s left at its default and check what %<where>s returns."),
|
|
80
|
+
attribute_guard: Mechanism.new(80, "a callback fills it when unset", "Build a %<owner>s with %<name>s unset and check the value %<where>s leaves in it."),
|
|
81
|
+
sql_constant: Mechanism.new(90, "a query reads it, so returned rows change",
|
|
82
|
+
"Run the query at %<where>s and check which rows it returns now that %<name>s feeds it."),
|
|
83
|
+
scope_call: Mechanism.new(60, "a caller uses the scope", "Run %<where>s against records on both sides of the %<name>s scope and check which ones it returns."),
|
|
84
|
+
default_scope_query: Mechanism.new(50, "a query runs under the default scope", "Run the query at %<where>s and check which %<owner>s rows and order it returns."),
|
|
85
|
+
default_scope_first: Mechanism.new(80, "first/last picks by the default order", "Run %<where>s with two %<owner>s rows and check which one first/last picks."),
|
|
86
|
+
default_scope_order: Mechanism.new(80, "an order is added after the default order",
|
|
87
|
+
"Run %<where>s with two %<owner>s rows that tie on its own order and check the order it returns."),
|
|
88
|
+
default_scope_association: Mechanism.new(65, "an association loads under the default scope",
|
|
89
|
+
"Load %<where>s through the association and check which %<owner>s rows it holds."),
|
|
90
|
+
config_read: Mechanism.new(85, "code reads the setting", "Run %<where>s with the app's config and check the value it derives from %<name>s."),
|
|
91
|
+
constant_read: Mechanism.new(45, "code reads it, effect not classified", "Run %<where>s and check the value it derives from %<name>s."),
|
|
92
|
+
template_call: Mechanism.new(55, "a page prints it", "Render %<where>s and check the text it prints from %<name>s."),
|
|
93
|
+
save_site: Mechanism.new(40, "a save runs it", "Save a %<owner>s through %<where>s and check the callbacks and validations it runs.")
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
# The rules a method's name adds to the ones every method gets.
|
|
97
|
+
SPECIAL = {
|
|
98
|
+
"to_param" => %i[route_rows polymorphic_rows param_lookup_rows],
|
|
99
|
+
"model_name" => %i[polymorphic_rows],
|
|
100
|
+
"as_json" => %i[json_rows],
|
|
101
|
+
"serializable_hash" => %i[json_rows],
|
|
102
|
+
"to_json" => %i[json_rows],
|
|
103
|
+
"to_s" => %i[to_s_rows]
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
# The rules each kind of changed value has.
|
|
107
|
+
RULES = {
|
|
108
|
+
method: ->(r, v) { SPECIAL.fetch(v.name, []).flat_map { |rule| r.send(rule, v) } + r.dispatch_rows(v) + r.template_rows(v) },
|
|
109
|
+
scope: ->(r, v) { r.scope_rows(v) },
|
|
110
|
+
default_scope: ->(r, v) { r.default_scope_rows(v) },
|
|
111
|
+
constant: ->(r, v) { r.constant_rows(v) },
|
|
112
|
+
attribute_default: ->(r, v) { r.attribute_rows(v) },
|
|
113
|
+
config: ->(r, v) { r.config_rows(v) },
|
|
114
|
+
callback: ->(r, v) { r.save_rows(v) },
|
|
115
|
+
validation: ->(r, v) { r.save_rows(v) }
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
# `head_sources` and `base_sources` map a path to its text on each side;
|
|
119
|
+
# `diff` is every file's hunks, as `DiffParser.parse` gives them.
|
|
120
|
+
def self.between(base_sources:, head_sources:, diff:, routes:, inflections:)
|
|
121
|
+
new(base_sources, head_sources, diff, routes, inflections).found
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def initialize(base_sources, head_sources, diff, routes, inflections)
|
|
125
|
+
@base_sources = base_sources
|
|
126
|
+
@head_sources = head_sources
|
|
127
|
+
@lines = changed_lines(diff)
|
|
128
|
+
@routes = routes
|
|
129
|
+
@inflections = inflections
|
|
130
|
+
@sources = head_sources.filter_map do |path, text|
|
|
131
|
+
next unless path == SCHEMA || READ_ROOTS.any? { |root| path.start_with?(root) }
|
|
132
|
+
next unless path.end_with?(".rb") || Template.template?(path)
|
|
133
|
+
|
|
134
|
+
template = Template.template?(path)
|
|
135
|
+
result = Prism.parse(template ? Template.as_ruby(text) : text)
|
|
136
|
+
Source.new(path: path, tree: result.value, template: template) if result.success?
|
|
137
|
+
end.sort_by(&:path)
|
|
138
|
+
@models = @sources.select { |src| src.path.start_with?("app/models/") }.flat_map do |src|
|
|
139
|
+
found = []
|
|
140
|
+
each_node(src.tree) { |node, _| found << node.constant_path.slice if node.is_a?(Prism::ClassNode) }
|
|
141
|
+
found
|
|
142
|
+
end
|
|
143
|
+
@names = model_names
|
|
144
|
+
@suffixes = @names.keys.sort_by { |name| -name.size }
|
|
145
|
+
constant_table
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The values the change edits and the rows that read them, highest
|
|
149
|
+
# score first.
|
|
150
|
+
def found
|
|
151
|
+
values = changed_values
|
|
152
|
+
rows = values.flat_map { |value| RULES.fetch(value.kind).call(self, value) }
|
|
153
|
+
rows = rows.reject { |row| row.file == row.value.file && row.line == row.value.line }
|
|
154
|
+
rows = rows.uniq { |row| [row.file, row.line, row.mechanism, row.value.name] }
|
|
155
|
+
# The lookup a route row already names is that row's other end.
|
|
156
|
+
cited = rows.flat_map { |row| row.why.scan(%r{(app/\S+:\d+)\)}).flatten.map { |place| "#{row.value.name} #{place}" } }
|
|
157
|
+
rows = rows.reject { |row| row.mechanism == :param_lookup && cited.include?("#{row.value.name} #{row.file}:#{row.line}") }
|
|
158
|
+
rows = rows.sort_by { |row| [-row.score, row.file, row.line] }
|
|
159
|
+
rows = rows.group_by { |row| [row.value, row.mechanism, row.group] }.values.map do |same|
|
|
160
|
+
same.first.with(also: same.drop(1).map { |row| "#{row.file}:#{row.line}" })
|
|
161
|
+
end
|
|
162
|
+
rows = rows.map { |row| row.with(edited: @lines.dig(row.file, :new).to_a.include?(row.line)) }
|
|
163
|
+
[values, rows.sort_by { |row| [-row.score, row.file, row.line] }]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The lines each file's hunks cover, on the base side and the head side.
|
|
167
|
+
def changed_lines(diff)
|
|
168
|
+
lines = {}
|
|
169
|
+
diff.each do |file|
|
|
170
|
+
[["old_path", :old, "old"], ["new_path", :new, "new"]].each do |path_key, side, prefix|
|
|
171
|
+
path = file[path_key] or next
|
|
172
|
+
covered = (lines[path] ||= { old: [], new: [] })[side]
|
|
173
|
+
file["hunks"].each { |hunk| covered.concat((hunk["#{prefix}_start"]...(hunk["#{prefix}_start"] + hunk["#{prefix}_count"])).to_a) }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
lines
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def changed_values
|
|
180
|
+
@lines.keys.select { |path| path.end_with?(".rb") }.sort.flat_map do |path|
|
|
181
|
+
lines = @lines[path]
|
|
182
|
+
old_values = declarations(path, @base_sources[path])
|
|
183
|
+
new_values = declarations(path, @head_sources[path])
|
|
184
|
+
key = ->(value) { [value.kind, value.owner, value.name] }
|
|
185
|
+
old_keys = old_values.map(&key)
|
|
186
|
+
new_keys = new_values.map(&key)
|
|
187
|
+
touched = ->(value, set) { (value.line..value.last_line).any? { |line| set.include?(line) } }
|
|
188
|
+
found = new_values.select { |value| touched.(value, lines[:new]) }.map do |value|
|
|
189
|
+
value.with(state: old_keys.include?(key.(value)) ? :modified : :added)
|
|
190
|
+
end
|
|
191
|
+
found += old_values.select { |value| touched.(value, lines[:old]) && !new_keys.include?(key.(value)) }.map { |value| value.with(state: :removed) }
|
|
192
|
+
found.uniq { |value| [value.kind, value.owner, value.name, value.state] }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The values a file declares, with no state yet: methods, scopes,
|
|
197
|
+
# default scopes, constants, callbacks, validations, attributes, schema
|
|
198
|
+
# column defaults, and config assignments.
|
|
199
|
+
def declarations(path, source)
|
|
200
|
+
return [] unless source
|
|
201
|
+
|
|
202
|
+
result = Prism.parse(source)
|
|
203
|
+
return [] unless result.success?
|
|
204
|
+
|
|
205
|
+
values = []
|
|
206
|
+
table = nil
|
|
207
|
+
each_node(result.value) do |node, stack|
|
|
208
|
+
next unless node.is_a?(Prism::DefNode) || node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::CallNode)
|
|
209
|
+
|
|
210
|
+
owner = enclosing_class(stack)
|
|
211
|
+
scope = nesting(stack).last
|
|
212
|
+
declare = lambda do |kind, of, name, declared = node|
|
|
213
|
+
values << Value.new(kind: kind, owner: of, scope: of == owner ? scope : of, name: name, file: path,
|
|
214
|
+
line: node.location.start_line, last_line: node.location.end_line, state: nil, node: declared)
|
|
215
|
+
end
|
|
216
|
+
case node
|
|
217
|
+
when Prism::DefNode
|
|
218
|
+
declare.(:method, owner, node.name.to_s)
|
|
219
|
+
when Prism::ConstantWriteNode
|
|
220
|
+
declare.(:constant, scope, node.name.to_s)
|
|
221
|
+
when Prism::CallNode
|
|
222
|
+
args = arguments(node)
|
|
223
|
+
name = node.name
|
|
224
|
+
if path == SCHEMA && name == :create_table
|
|
225
|
+
table = literal_name(args.first)
|
|
226
|
+
elsif path == SCHEMA && node.receiver&.slice == "t" && table && (default = keywords(args)["default"])
|
|
227
|
+
declare.(:attribute_default, model_of_table(table), literal_name(args.first), default)
|
|
228
|
+
elsif node.receiver.nil? && owner && stack.none?(Prism::DefNode)
|
|
229
|
+
case name
|
|
230
|
+
when :scope then declare.(:scope, owner, literal_name(args.first))
|
|
231
|
+
when :default_scope then declare.(:default_scope, owner, "default_scope")
|
|
232
|
+
when :attribute then declare.(:attribute_default, owner, literal_name(args.first)) if keywords(args)["default"]
|
|
233
|
+
when CALLBACKS then declare.(:callback, owner, "#{name} #{args.filter_map { |arg| literal_name(arg) }.join(",")}")
|
|
234
|
+
when *VALIDATIONS then declare.(:validation, owner, args.filter_map { |arg| literal_name(arg) }.join(","))
|
|
235
|
+
end
|
|
236
|
+
elsif path.start_with?("config/") && name.to_s.end_with?("=") && node.receiver&.slice&.match?(/(\A|\.)config(\.|\z)/)
|
|
237
|
+
setting = [node.receiver.slice.sub(/\A(.*\.)?config\.?/, ""), name.to_s.delete_suffix("=")].reject(&:empty?).join(".")
|
|
238
|
+
declare.(:config, "config", setting)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
values
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private :changed_lines, :changed_values, :declarations
|
|
246
|
+
|
|
247
|
+
# Each rule below takes a Value and returns the Rows that read it.
|
|
248
|
+
|
|
249
|
+
# Actions that turn a param back into the model, whoever built the URL.
|
|
250
|
+
def param_lookup_rows(value)
|
|
251
|
+
one = snake(value.owner)
|
|
252
|
+
rows = []
|
|
253
|
+
@sources.select { |src| src.path.start_with?("app/controllers/") }.each do |src|
|
|
254
|
+
each_node(src.tree) do |node, stack|
|
|
255
|
+
next unless params_read?(node)
|
|
256
|
+
|
|
257
|
+
key = literal_name(arguments(node).first)
|
|
258
|
+
user = stack.reverse.find { |up| up.is_a?(Prism::CallNode) && %i[find find_by find_by! where].include?(up.name) }
|
|
259
|
+
next unless user && key
|
|
260
|
+
|
|
261
|
+
on_model = user.receiver&.slice == value.owner
|
|
262
|
+
next unless key == "#{one}_id" || (on_model && key == "id")
|
|
263
|
+
|
|
264
|
+
action = enclosing_def(stack)
|
|
265
|
+
controller = src.path.delete_prefix("app/controllers/").delete_suffix(".rb").split("/").map { |part| @inflections.camelize(part) }.join("::")
|
|
266
|
+
rows << row(value, src, node, :param_lookup, "#{user.slice[0, 60]} in #{action} turns params[:#{key}] back into a #{value.owner} by id",
|
|
267
|
+
action: "#{controller}##{action}")
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
rows
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def route_rows(value)
|
|
274
|
+
rows = []
|
|
275
|
+
returns = param_source(value)
|
|
276
|
+
each_call do |src, node, _|
|
|
277
|
+
route = @routes.route_for_helper(node.name.to_s) or next
|
|
278
|
+
|
|
279
|
+
args = arguments(node)
|
|
280
|
+
segments = route.path.scan(/:(\w+)/).flatten
|
|
281
|
+
pairs = args.reject { |arg| arg.is_a?(Prism::KeywordHashNode) }.each_with_index.map { |arg, i| [segments[i], arg] }
|
|
282
|
+
pairs += keywords(args).to_a
|
|
283
|
+
pairs.each do |key, arg|
|
|
284
|
+
model, = model_of(arg)
|
|
285
|
+
next unless key && model == value.owner
|
|
286
|
+
|
|
287
|
+
found = action_def(route.action)
|
|
288
|
+
lookup = found ? param_lookups(found[1], key) : []
|
|
289
|
+
by_id = lookup.select { |_, by, _| by == "id" || by.end_with?("_id") }
|
|
290
|
+
mismatch = !id_param?(returns) && by_id.any?
|
|
291
|
+
detail = lookup.map { |line, by, text| "#{route.action} reads params[:#{key}] by #{by} (#{text}, #{found[0].path}:#{line})" }.join("; ")
|
|
292
|
+
why = "#{arg.slice} goes through #{value.owner}#to_param (now `#{returns}`) into params[:#{key}]; " +
|
|
293
|
+
(detail.empty? ? "#{route.action} does not read it in its own body" : detail)
|
|
294
|
+
rows << row(value, src, node, mismatch ? :route_param_mismatch : :route_param, why, helper: node.slice[0, 60], action: route.action,
|
|
295
|
+
group: "#{route.action} #{key}")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
rows
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def polymorphic_rows(value)
|
|
302
|
+
rows = []
|
|
303
|
+
each_call do |src, node, _|
|
|
304
|
+
args = arguments(node)
|
|
305
|
+
targets = case node.name
|
|
306
|
+
when :link_to, :button_to then args[1..] || []
|
|
307
|
+
when :redirect_to, :url_for, :polymorphic_path, :polymorphic_url, :render, :form_for then args.first(1)
|
|
308
|
+
when :form_with then [keywords(args)["model"]].compact
|
|
309
|
+
else []
|
|
310
|
+
end
|
|
311
|
+
json = keywords(args)["json"] if node.name == :render
|
|
312
|
+
targets += [json] if json
|
|
313
|
+
targets.each do |arg|
|
|
314
|
+
next if arg.is_a?(Prism::KeywordHashNode)
|
|
315
|
+
|
|
316
|
+
model, = model_of(arg)
|
|
317
|
+
next unless model == value.owner
|
|
318
|
+
|
|
319
|
+
if json.equal?(arg)
|
|
320
|
+
rows << row(value, src, node, :json_render, "render json: #{arg.slice} serializes it through #{value.owner}#as_json")
|
|
321
|
+
elsif value.name == "model_name" || value.name == "to_param"
|
|
322
|
+
rows << row(value, src, node, :polymorphic_route, "#{node.name} #{arg.slice} builds a URL or partial path from #{value.owner}##{value.name}", call: node.name)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
rows
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def to_s_rows(value)
|
|
330
|
+
rows = []
|
|
331
|
+
@sources.each do |src|
|
|
332
|
+
each_node(src.tree) do |node, _|
|
|
333
|
+
arg = case node
|
|
334
|
+
when Prism::EmbeddedStatementsNode then node.statements&.body&.last
|
|
335
|
+
when Prism::CallNode
|
|
336
|
+
arguments(node).first if node.name == Template::OUTPUT || %i[link_to button_to].include?(node.name)
|
|
337
|
+
end
|
|
338
|
+
next unless arg
|
|
339
|
+
|
|
340
|
+
model, many = model_of(arg)
|
|
341
|
+
next unless model == value.owner && many == :one
|
|
342
|
+
|
|
343
|
+
via = if node.is_a?(Prism::EmbeddedStatementsNode) then "interpolates"
|
|
344
|
+
elsif node.name == Template::OUTPUT then "prints"
|
|
345
|
+
else "uses as link text"
|
|
346
|
+
end
|
|
347
|
+
rows << row(value, src, node, :to_s_output, "#{via} #{arg.slice}, which calls #{value.owner}#to_s")
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
rows
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def json_rows(value)
|
|
354
|
+
rows = polymorphic_rows(value).select { |found| found.mechanism == :json_render }
|
|
355
|
+
each_call do |src, node, _|
|
|
356
|
+
next unless %i[to_json as_json].include?(node.name) && node.receiver
|
|
357
|
+
|
|
358
|
+
model, = model_of(node.receiver)
|
|
359
|
+
rows << row(value, src, node, :json_render, "#{node.slice[0, 50]} serializes it through #{value.owner}#as_json") if model == value.owner
|
|
360
|
+
end
|
|
361
|
+
rows
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# A name a dispatch can reach: a literal, or an interpolation whose fixed
|
|
365
|
+
# parts match it.
|
|
366
|
+
def dispatch_rows(value)
|
|
367
|
+
name = value.name
|
|
368
|
+
rows = []
|
|
369
|
+
dynamic = []
|
|
370
|
+
each_call do |src, node, stack|
|
|
371
|
+
args = arguments(node)
|
|
372
|
+
first = args.first
|
|
373
|
+
if DISPATCH.include?(node.name) && first
|
|
374
|
+
if literal_name(first) == name
|
|
375
|
+
rows << row(value, src, node, :dispatch_literal, "#{node.slice[0, 60]} dispatches to #{name} by a literal name")
|
|
376
|
+
elsif first.is_a?(Prism::InterpolatedSymbolNode) || first.is_a?(Prism::InterpolatedStringNode)
|
|
377
|
+
pattern = first.parts.map { |part| part.is_a?(Prism::StringNode) ? Regexp.escape(part.unescaped) : ".+" }.join
|
|
378
|
+
rows << row(value, src, node, :dispatch_pattern, "#{node.slice[0, 60]} builds a name that matches #{name}") if name.match?(/\A#{pattern}\z/)
|
|
379
|
+
else
|
|
380
|
+
dynamic << [src, node] unless dispatch_on_other_model?(value, node, stack)
|
|
381
|
+
end
|
|
382
|
+
elsif (node.name.match?(CALLBACKS) || VALIDATIONS.include?(node.name) || %i[delegate alias_method].include?(node.name)) &&
|
|
383
|
+
node.receiver.nil? && (args.any? { |arg| literal_name(arg) == name } || keywords(args).values_at("if", "unless").any? { |arg| literal_name(arg) == name })
|
|
384
|
+
mechanism = VALIDATIONS.include?(node.name) ? :validation_reads : :callback
|
|
385
|
+
rows << row(value, src, node, mechanism, "#{node.slice[0, 60]} names :#{name}, so Rails calls it")
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
rows + computed_dispatch_rows(value, dynamic)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def template_rows(value)
|
|
392
|
+
rows = []
|
|
393
|
+
@sources.select(&:template).each do |src|
|
|
394
|
+
each_node(src.tree) do |node, _|
|
|
395
|
+
next unless node.is_a?(Prism::CallNode) && node.name.to_s == value.name && node.receiver
|
|
396
|
+
|
|
397
|
+
model, = model_of(node.receiver)
|
|
398
|
+
next if model && model != value.owner
|
|
399
|
+
|
|
400
|
+
rows << row(value, src, node, :template_call, "the template calls #{node.slice[0, 50]}" + (model ? "" : " (receiver's model not known by name)"))
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
rows
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def attribute_rows(value)
|
|
407
|
+
attr = value.name
|
|
408
|
+
owner = value.owner
|
|
409
|
+
model_src = @sources.find { |src| src.path == "app/models/#{underscore_path(owner)}.rb" }
|
|
410
|
+
return [] unless model_src
|
|
411
|
+
|
|
412
|
+
rows = []
|
|
413
|
+
defs = {}
|
|
414
|
+
each_node(model_src.tree) { |node, _| defs[node.name.to_s] = node if node.is_a?(Prism::DefNode) }
|
|
415
|
+
reads = ->(node) { node && node.slice.match?(/\b#{Regexp.escape(attr)}\b/) }
|
|
416
|
+
each_node(model_src.tree) do |node, stack|
|
|
417
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && stack.none?(Prism::DefNode)
|
|
418
|
+
|
|
419
|
+
args = arguments(node)
|
|
420
|
+
if node.name.match?(CALLBACKS)
|
|
421
|
+
targets = args.filter_map { |arg| literal_name(arg) }.filter_map { |target| defs[target] }
|
|
422
|
+
hit = reads.(node.block) || targets.find { |target| reads.(target) }
|
|
423
|
+
next unless hit
|
|
424
|
+
|
|
425
|
+
guard = (hit.is_a?(Prism::DefNode) ? hit : node).slice.match?(/#{Regexp.escape(attr)}\s*\|\|=/)
|
|
426
|
+
rows << row(value, model_src, node, guard ? :attribute_guard : :callback,
|
|
427
|
+
"#{node.name} #{guard ? "fills" : "reads"} #{attr}" + (hit.is_a?(Prism::DefNode) ? " in #{hit.name} (line #{hit.location.start_line})" : ""))
|
|
428
|
+
elsif VALIDATIONS.include?(node.name)
|
|
429
|
+
target = args.filter_map { |arg| literal_name(arg) }
|
|
430
|
+
hit = target.include?(attr) || target.filter_map { |name| defs[name] }.any? { |method| reads.(method) }
|
|
431
|
+
rows << row(value, model_src, node, :validation_reads, "#{node.slice[0, 60]} checks #{attr}") if hit
|
|
432
|
+
elsif node.name == :scope && reads.(node)
|
|
433
|
+
rows << row(value, model_src, node, :scope_call, "scope #{literal_name(args.first)} filters on #{attr}", name: literal_name(args.first))
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
defs.each_value do |method|
|
|
437
|
+
next unless method.body&.slice&.match?(/(?<![.\w])#{Regexp.escape(attr)}\b(?!\s*=[^=])/)
|
|
438
|
+
|
|
439
|
+
rows << row(value, model_src, method, :attribute_read, "#{owner}##{method.name} reads #{attr}", name: attr)
|
|
440
|
+
end
|
|
441
|
+
rows + default_taker_rows(value, owner, attr)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def constant_rows(value)
|
|
445
|
+
rows = []
|
|
446
|
+
name = value.name
|
|
447
|
+
full = [value.owner, name].compact.join("::")
|
|
448
|
+
@references ||= @sources.flat_map do |src|
|
|
449
|
+
found = []
|
|
450
|
+
each_node(src.tree) do |node, stack|
|
|
451
|
+
if node.is_a?(Prism::ConstantPathNode) || (node.is_a?(Prism::ConstantReadNode) && !stack.last.is_a?(Prism::ConstantPathNode))
|
|
452
|
+
found << [src, node, stack.dup]
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
found
|
|
456
|
+
end
|
|
457
|
+
@references.each do |src, node, stack|
|
|
458
|
+
next unless (node.is_a?(Prism::ConstantReadNode) ? node.name.to_s : path_name(node)) == name
|
|
459
|
+
next if src.path == value.file && stack.any?(Prism::ConstantWriteNode)
|
|
460
|
+
|
|
461
|
+
# A bare `STATUSES` in a class that defines its own is not this value.
|
|
462
|
+
target = resolve(node, nesting(stack), full)
|
|
463
|
+
next if target && target != full
|
|
464
|
+
|
|
465
|
+
rows << ranked_constant(constant_row(value, src, node, stack), node, target)
|
|
466
|
+
end
|
|
467
|
+
rows
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def config_rows(value)
|
|
471
|
+
path = value.name
|
|
472
|
+
rows = []
|
|
473
|
+
each_call do |src, node, _|
|
|
474
|
+
text = node.slice
|
|
475
|
+
next if src.path == value.file || node.receiver.nil?
|
|
476
|
+
next unless node.name.to_s == path.split(".").last && text.match?(/(Rails\.(application\.)?config(uration)?|config)\.#{Regexp.escape(path)}\z/)
|
|
477
|
+
|
|
478
|
+
rows << row(value, src, node, :config_read, "reads #{text}")
|
|
479
|
+
end
|
|
480
|
+
rows
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def scope_rows(value)
|
|
484
|
+
rows = []
|
|
485
|
+
each_call do |src, node, _|
|
|
486
|
+
next unless node.name.to_s == value.name && node.receiver
|
|
487
|
+
|
|
488
|
+
rows << row(value, src, node, :scope_call, "#{node.slice[0, 60]} calls the scope #{value.name}, which is not a def")
|
|
489
|
+
end
|
|
490
|
+
rows + dispatch_rows(value)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def default_scope_rows(value)
|
|
494
|
+
rows = []
|
|
495
|
+
owner = value.owner
|
|
496
|
+
# The receiver a chain starts from: `Loan` in `Loan.overdue.order(:x)`.
|
|
497
|
+
root = lambda do |node|
|
|
498
|
+
node = node.receiver while node.is_a?(Prism::CallNode) && node.receiver
|
|
499
|
+
node
|
|
500
|
+
end
|
|
501
|
+
on_owner = lambda do |node|
|
|
502
|
+
start = root.(node)
|
|
503
|
+
start.slice == owner || model_of(start)&.then { |model, many| model == owner && many == :many }
|
|
504
|
+
end
|
|
505
|
+
each_call do |src, node, stack|
|
|
506
|
+
parent = stack.last
|
|
507
|
+
next if parent.is_a?(Prism::CallNode) && parent.receiver.equal?(node) && (QUERY_CALLS + %i[includes order reorder limit]).include?(parent.name)
|
|
508
|
+
|
|
509
|
+
if %i[order first last take].include?(node.name) && node.receiver && on_owner.(node.receiver) && (chain = node.slice).match?(/\.(order|first|last|take)\b/)
|
|
510
|
+
orders = chain.scan(/\.(order|reorder|first|last|take)\b/).flatten
|
|
511
|
+
next if orders.include?("reorder")
|
|
512
|
+
|
|
513
|
+
mechanism = node.name == :order || orders.include?("order") ? :default_scope_order : :default_scope_first
|
|
514
|
+
why = mechanism == :default_scope_order ? "#{chain[0, 70]} adds its order after the one the default scope sets" : "#{chain[0, 70]} picks by the order the default scope sets"
|
|
515
|
+
rows << row(value, src, node, mechanism, why)
|
|
516
|
+
elsif node.receiver&.slice == owner && QUERY_CALLS.include?(node.name)
|
|
517
|
+
mechanism = %i[first last take].include?(node.name) ? :default_scope_first : :default_scope_query
|
|
518
|
+
rows << row(value, src, node, mechanism, "#{node.slice[0, 60]} queries #{owner} under its default scope")
|
|
519
|
+
elsif node.receiver&.slice == owner && node.name == :unscoped
|
|
520
|
+
rows << row(value, src, node, :default_scope_query, "#{node.slice[0, 60]} drops the default scope")
|
|
521
|
+
elsif node.receiver.nil? && %i[has_many has_one].include?(node.name)
|
|
522
|
+
args = arguments(node)
|
|
523
|
+
assoc = literal_name(args.first)
|
|
524
|
+
target = literal_name(keywords(args)["class_name"]) || (assoc && @names[assoc]&.first)
|
|
525
|
+
rows << row(value, src, node, :default_scope_association, "#{node.slice[0, 60]} loads #{owner} rows under its default scope") if target == owner && assoc
|
|
526
|
+
elsif %i[first last take].include?(node.name) && node.receiver.is_a?(Prism::CallNode) &&
|
|
527
|
+
@names[node.receiver.name.to_s]&.then { |model, many| model == owner && many == :many }
|
|
528
|
+
rows << row(value, src, node, :default_scope_first, "#{node.slice[0, 60]} picks by the order the default scope sets")
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
rows
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def save_rows(value)
|
|
535
|
+
rows = []
|
|
536
|
+
each_call do |src, node, _|
|
|
537
|
+
next unless %i[create create! save save! update update!].include?(node.name)
|
|
538
|
+
|
|
539
|
+
model = node.receiver&.slice == value.owner ? value.owner : model_of(node.receiver)&.first
|
|
540
|
+
next unless model == value.owner
|
|
541
|
+
|
|
542
|
+
rows << row(value, src, node, :save_site, "#{node.slice[0, 60]} saves a #{value.owner}, which runs the #{value.kind}")
|
|
543
|
+
end
|
|
544
|
+
rows
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
private
|
|
548
|
+
|
|
549
|
+
def row(value, src, node, mechanism, why, group: nil, **fields)
|
|
550
|
+
kind = MECHANISMS.fetch(mechanism)
|
|
551
|
+
where = "#{src.path}:#{node.location.start_line}"
|
|
552
|
+
sentence = src.template ? kind.test : kind.test.sub(/\ARender (or request )?/, "Run ")
|
|
553
|
+
test = format(sentence, { where: where, model: value.owner, owner: value.owner, name: value.name, helper: "", action: "", call: "" }.merge(fields))
|
|
554
|
+
Row.new(value: value, file: src.path, line: node.location.start_line, mechanism: mechanism, why: why, score: kind.score, rank: kind.rank,
|
|
555
|
+
test: test, group: group || where, also: [], edited: false)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# A dispatch on another known model cannot reach this owner.
|
|
559
|
+
def dispatch_on_other_model?(value, node, stack)
|
|
560
|
+
receiver = node.receiver
|
|
561
|
+
if receiver.nil?
|
|
562
|
+
(klass = enclosing_class(stack)) && klass != value.owner
|
|
563
|
+
elsif receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
564
|
+
receiver.slice != value.owner
|
|
565
|
+
else
|
|
566
|
+
(model = model_of(receiver)&.first) && model != value.owner
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# A symbol or string spelled like the method, in code that also
|
|
571
|
+
# dispatches by a name it computes. Only a name kept in a list
|
|
572
|
+
# (`%w[late lost]`, a constant's array or hash) counts as one a computed
|
|
573
|
+
# dispatch iterates; a lone "late" passed as a column value is data.
|
|
574
|
+
def computed_dispatch_rows(value, dynamic)
|
|
575
|
+
name = value.name
|
|
576
|
+
sources = []
|
|
577
|
+
unless dynamic.empty?
|
|
578
|
+
@sources.each do |src|
|
|
579
|
+
next if src.path == SCHEMA || src.path.start_with?("config/")
|
|
580
|
+
|
|
581
|
+
each_node(src.tree) do |node, stack|
|
|
582
|
+
next unless (node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)) && literal_name(node) == name
|
|
583
|
+
next unless stack.last.is_a?(Prism::ArrayNode) || (stack.any?(Prism::ConstantWriteNode) && stack.any?(Prism::HashNode))
|
|
584
|
+
|
|
585
|
+
sources << [src, node]
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
on_owner = dynamic.select do |_, node|
|
|
590
|
+
node.receiver && (node.receiver.slice == value.owner || model_of(node.receiver)&.first == value.owner)
|
|
591
|
+
end
|
|
592
|
+
rows = []
|
|
593
|
+
unless sources.empty?
|
|
594
|
+
listed = sources.map { |src, node| "#{src.path}:#{node.location.start_line}" }.join(", ")
|
|
595
|
+
on_owner.each do |src, node|
|
|
596
|
+
rows << row(value, src, node, :dispatch_dynamic, "#{node.slice[0, 60]} dispatches on a #{value.owner} by a computed name; :#{name} is listed at #{listed}")
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
near_sites = on_owner.empty? ? dynamic : on_owner
|
|
600
|
+
return rows if near_sites.empty?
|
|
601
|
+
|
|
602
|
+
dispatch_files = near_sites.map { |src, _| src.path }.uniq
|
|
603
|
+
sources.each do |src, node|
|
|
604
|
+
near = near_sites.find { |site, _| site.path == src.path } || near_sites.first
|
|
605
|
+
rows << row(value, src, node, :dispatch_name_source,
|
|
606
|
+
"#{node.slice} is written here; #{near[0].path}:#{near[1].location.start_line} dispatches by a computed name (#{near[1].slice[0, 50]})" +
|
|
607
|
+
(dispatch_files.include?(src.path) ? "" : " in another file"))
|
|
608
|
+
end
|
|
609
|
+
rows
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Where a record is made without naming the attribute, the default is
|
|
613
|
+
# the value it gets.
|
|
614
|
+
def default_taker_rows(value, owner, attr)
|
|
615
|
+
many = plural(snake(owner))
|
|
616
|
+
rows = []
|
|
617
|
+
each_call do |src, node, _|
|
|
618
|
+
next unless %i[create create! new find_or_initialize_by find_or_create_by find_or_create_by! build].include?(node.name)
|
|
619
|
+
|
|
620
|
+
receiver = node.receiver&.slice.to_s
|
|
621
|
+
last = receiver.split(".").last
|
|
622
|
+
next unless receiver == owner || receiver.end_with?(".#{many}") || receiver == many || (@names[last]&.first == owner && last == many)
|
|
623
|
+
next if arguments(node).any? { |arg| keywords([arg]).key?(attr) } || node.slice.match?(/\b#{Regexp.escape(attr)}\s*[:=]/)
|
|
624
|
+
|
|
625
|
+
rows << row(value, src, node, :default_taker, "#{node.slice[0, 60]} makes a #{owner} without setting #{attr}", name: attr)
|
|
626
|
+
end
|
|
627
|
+
rows
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def constant_row(value, src, node, stack)
|
|
631
|
+
name = value.name
|
|
632
|
+
sql = stack.reverse.find { |up| up.is_a?(Prism::CallNode) && SQL_CALLS.include?(up.name) && query?(up) }
|
|
633
|
+
string = stack.reverse.find { |up| up.is_a?(Prism::InterpolatedStringNode) || up.is_a?(Prism::InterpolatedXStringNode) }
|
|
634
|
+
sql_text = string && !sql && string.parts.any? { |part| part.is_a?(Prism::StringNode) && part.unescaped.match?(SQL_TEXT) }
|
|
635
|
+
validation = stack.find { |up| up.is_a?(Prism::CallNode) && VALIDATIONS.include?(up.name) }
|
|
636
|
+
if sql || sql_text
|
|
637
|
+
row(value, src, node, :sql_constant, sql_text ? "#{name} is interpolated into SQL text" : "#{name} is #{string ? "interpolated into" : "passed to"} the SQL of #{sql.name}")
|
|
638
|
+
elsif validation
|
|
639
|
+
row(value, src, validation, :validation_reads, "#{validation.slice[0, 60]} reads #{name}", owner: enclosing_class(stack) || value.owner)
|
|
640
|
+
elsif src.template
|
|
641
|
+
row(value, src, node, :template_call, "the template reads #{node.slice}")
|
|
642
|
+
elsif src.path.start_with?("config/")
|
|
643
|
+
row(value, src, node, :config_read, "reads #{node.slice} set in #{value.file}")
|
|
644
|
+
else
|
|
645
|
+
row(value, src, node, :constant_read, "reads #{node.slice}")
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# `Order.where`, `orders.where`, a bare `where` in a scope: a query.
|
|
650
|
+
# `f.select` on a form builder is not.
|
|
651
|
+
def query?(call)
|
|
652
|
+
root = call
|
|
653
|
+
root = root.receiver while root.is_a?(Prism::CallNode) && root.receiver
|
|
654
|
+
root.equal?(call) || root.is_a?(Prism::ConstantReadNode) || root.is_a?(Prism::ConstantPathNode) ||
|
|
655
|
+
root.is_a?(Prism::SelfNode) || model_of(root)&.last == :many
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# A reference that names its owner from a file the change does not touch
|
|
659
|
+
# is a reader nobody looked at; one that resolves to nothing visible may
|
|
660
|
+
# come through a gem or a concern the index cannot see. A read beside the
|
|
661
|
+
# edit, in a file the change touches, was in front of its author; a query
|
|
662
|
+
# there still changes which rows come back, so it keeps its score.
|
|
663
|
+
def ranked_constant(row, node, target)
|
|
664
|
+
in_diff = @lines.key?(row.file)
|
|
665
|
+
if target.nil?
|
|
666
|
+
row.with(score: [row.score, 30].min, rank: "bare #{node.slice} is defined nowhere visible; may reach it through a mixin")
|
|
667
|
+
elsif node.is_a?(Prism::ConstantPathNode) && !in_diff
|
|
668
|
+
row.score >= 80 ? row.with(rank: "#{row.rank}; qualified, outside the diff") : row.with(score: 80, rank: "qualified #{node.slice} from a file outside the diff")
|
|
669
|
+
elsif in_diff && row.mechanism != :sql_constant
|
|
670
|
+
row.with(score: row.score - 10, rank: "#{row.rank}; in a file the change edits")
|
|
671
|
+
else
|
|
672
|
+
row
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# What a `to_param` body returns, when it is one attribute: `code`,
|
|
677
|
+
# `"#{id}-#{name}"`, or `id`.
|
|
678
|
+
def param_source(value)
|
|
679
|
+
body = value.node.is_a?(Prism::DefNode) && value.node.body
|
|
680
|
+
return "the default id" if value.state == :removed || !body
|
|
681
|
+
|
|
682
|
+
(body.is_a?(Prism::StatementsNode) ? body.body.last : body).slice
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def id_param?(text) = text.match?(/\A(id|id\.to_s|super|the default id)\z/)
|
|
686
|
+
|
|
687
|
+
# How an action reads one param: `find(params[:id])`, `find_by(id: ...)`,
|
|
688
|
+
# `where(branch_id: ...)` compare it to an id; `find_by(code: ...)` does not.
|
|
689
|
+
def param_lookups(action_nodes, key)
|
|
690
|
+
found = []
|
|
691
|
+
action_nodes.each do |action_node|
|
|
692
|
+
each_node(action_node) do |node, stack|
|
|
693
|
+
next unless params_read?(node) && literal_name(arguments(node).first) == key
|
|
694
|
+
|
|
695
|
+
user = stack.reverse.find { |up| up.is_a?(Prism::CallNode) && %i[find find_by find_by! where find_or_create_by].include?(up.name) }
|
|
696
|
+
by = if user.nil? then "used as given"
|
|
697
|
+
elsif user.name == :find then "id"
|
|
698
|
+
else
|
|
699
|
+
assoc = stack.reverse.find { |up| up.is_a?(Prism::AssocNode) }
|
|
700
|
+
assoc ? literal_name(assoc.key).to_s : "id"
|
|
701
|
+
end
|
|
702
|
+
found << [node.location.start_line, by, user&.slice&.slice(0, 60) || node.slice]
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
found
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def params_read?(node)
|
|
709
|
+
node.is_a?(Prism::CallNode) && node.name == :[] && node.receiver&.slice&.end_with?("params")
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# The controller file of a route's action, and the nodes that run for
|
|
713
|
+
# it: the action's def and its before_action filters. A filter's
|
|
714
|
+
# `only:`/`except:` is not read, so every filter counts.
|
|
715
|
+
def action_def(action)
|
|
716
|
+
controller, name = action.split("#")
|
|
717
|
+
src = @sources.find { |source| source.path == "app/controllers/#{underscore_path(controller)}.rb" } or return
|
|
718
|
+
|
|
719
|
+
defs = {}
|
|
720
|
+
filters = []
|
|
721
|
+
each_node(src.tree) do |node, stack|
|
|
722
|
+
defs[node.name.to_s] ||= node if node.is_a?(Prism::DefNode)
|
|
723
|
+
next unless node.is_a?(Prism::CallNode) && node.name == :before_action && node.receiver.nil? && stack.none?(Prism::DefNode)
|
|
724
|
+
|
|
725
|
+
filters.concat(arguments(node).filter_map { |arg| literal_name(arg) })
|
|
726
|
+
end
|
|
727
|
+
return unless defs[name]
|
|
728
|
+
|
|
729
|
+
[src, [defs[name], *filters.filter_map { |filter| defs[filter] }]]
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# Variable and method names that hold a model, by name: `branch`,
|
|
733
|
+
# `@branch`, `home_branch` (from `class_name: "Branch"`), and plurals.
|
|
734
|
+
def model_names
|
|
735
|
+
names = {}
|
|
736
|
+
@models.each do |model|
|
|
737
|
+
one = snake(model)
|
|
738
|
+
names[one] = [model, :one]
|
|
739
|
+
names[plural(one)] = [model, :many]
|
|
740
|
+
end
|
|
741
|
+
@sources.each do |src|
|
|
742
|
+
each_node(src.tree) do |node, _|
|
|
743
|
+
next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && %i[belongs_to has_one has_many].include?(node.name)
|
|
744
|
+
|
|
745
|
+
args = arguments(node)
|
|
746
|
+
assoc = literal_name(args.first) or next
|
|
747
|
+
class_name = literal_name(keywords(args)["class_name"])
|
|
748
|
+
model = class_name || @inflections.camelize(node.name == :has_many ? @inflections.singularize(assoc) : assoc)
|
|
749
|
+
names[assoc] ||= [model, node.name == :has_many ? :many : :one]
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
names
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# The model an expression holds, and whether one or many, judged by its
|
|
756
|
+
# last name only.
|
|
757
|
+
def model_of(node)
|
|
758
|
+
name = case node
|
|
759
|
+
when Prism::InstanceVariableReadNode then node.name.to_s.delete_prefix("@")
|
|
760
|
+
when Prism::LocalVariableReadNode then node.name.to_s
|
|
761
|
+
when Prism::CallNode then node.arguments || node.block ? nil : node.name.to_s
|
|
762
|
+
end
|
|
763
|
+
return unless name
|
|
764
|
+
return @names[name] if @names[name]
|
|
765
|
+
|
|
766
|
+
suffix = @suffixes.find { |known| name.end_with?("_#{known}") }
|
|
767
|
+
suffix && @names[suffix]
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def plural(word) = @inflections.pluralize(word)
|
|
771
|
+
|
|
772
|
+
# The model a table holds, or the table when no model in app/models does.
|
|
773
|
+
def model_of_table(table)
|
|
774
|
+
@tables ||= @models.to_h { |model| [plural(snake(model)), model] }
|
|
775
|
+
@tables.fetch(table, table)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# `Admin::OrderItem` is an `order_item`, the name its variables take.
|
|
779
|
+
def snake(constant) = @inflections.underscore(constant.split("::").last)
|
|
780
|
+
|
|
781
|
+
def underscore_path(constant)
|
|
782
|
+
constant.split("::").map { |part| @inflections.underscore(part) }.join("/")
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Every class, module, and constant a file defines, by full name, and
|
|
786
|
+
# each class's superclass and `include`s as nodes, with the scopes they
|
|
787
|
+
# are written in.
|
|
788
|
+
def constant_table
|
|
789
|
+
@defined = {}
|
|
790
|
+
@parents = Hash.new { |hash, key| hash[key] = [] }
|
|
791
|
+
@sources.each do |src|
|
|
792
|
+
each_node(src.tree) do |node, stack|
|
|
793
|
+
next unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ConstantWriteNode) ||
|
|
794
|
+
(node.is_a?(Prism::CallNode) && node.receiver.nil? && %i[include prepend].include?(node.name))
|
|
795
|
+
|
|
796
|
+
crefs = nesting(stack)
|
|
797
|
+
case node
|
|
798
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
799
|
+
@defined[nesting(stack + [node]).last] = true
|
|
800
|
+
@parents[nesting(stack + [node]).last] << [node.superclass, crefs] if node.is_a?(Prism::ClassNode) && node.superclass
|
|
801
|
+
when Prism::ConstantWriteNode
|
|
802
|
+
@defined[[crefs.last, node.name.to_s].compact.join("::")] = true
|
|
803
|
+
when Prism::CallNode
|
|
804
|
+
next unless %i[include prepend].include?(node.name) && node.receiver.nil? && crefs.any? && stack.none?(Prism::DefNode)
|
|
805
|
+
|
|
806
|
+
arguments(node).each do |arg|
|
|
807
|
+
@parents[crefs.last] << [arg, crefs] if arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode)
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# The full name a constant reference means, the way Ruby looks it up:
|
|
815
|
+
# the enclosing scopes from inside out, then the innermost scope's
|
|
816
|
+
# ancestors, then the top level. `also` counts as defined (a value the
|
|
817
|
+
# change removed). nil when nothing visible defines it. `seen` stops a
|
|
818
|
+
# class whose `include` names itself through its ancestors from looping.
|
|
819
|
+
def resolve(node, crefs, also = nil, seen = {})
|
|
820
|
+
known = ->(full) { @defined[full] || full == also }
|
|
821
|
+
case node
|
|
822
|
+
when Prism::ConstantReadNode
|
|
823
|
+
name = node.name.to_s
|
|
824
|
+
hit = crefs.reverse.map { |cref| "#{cref}::#{name}" }.find(&known)
|
|
825
|
+
hit ||= ancestors(crefs.last, seen).map { |owner| "#{owner}::#{name}" }.find(&known) if crefs.any?
|
|
826
|
+
hit || (name if known.(name))
|
|
827
|
+
when Prism::ConstantPathNode
|
|
828
|
+
return path_name(node) if node.parent.nil?
|
|
829
|
+
|
|
830
|
+
owner = resolve(node.parent, crefs, also, seen) or return
|
|
831
|
+
[owner, *ancestors(owner, seen)].map { |scope| "#{scope}::#{path_name(node)}" }.find(&known)
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def ancestors(full, seen = {})
|
|
836
|
+
return [] if seen[full]
|
|
837
|
+
|
|
838
|
+
seen[full] = true
|
|
839
|
+
@parents[full].flat_map do |ref, crefs|
|
|
840
|
+
parent = resolve(ref, crefs, nil, seen) or next []
|
|
841
|
+
[parent, *ancestors(parent, seen)]
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Every call at head with the nodes around it. Each rule walks them for
|
|
846
|
+
# each value, so they are collected once.
|
|
847
|
+
def each_call(&block)
|
|
848
|
+
@calls ||= @sources.flat_map do |src|
|
|
849
|
+
found = []
|
|
850
|
+
each_node(src.tree) { |node, stack| found << [src, node, stack.dup] if node.is_a?(Prism::CallNode) }
|
|
851
|
+
found
|
|
852
|
+
end
|
|
853
|
+
@calls.each { |src, node, stack| block.call(src, node, stack) }
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def each_node(node, stack = [], &block)
|
|
857
|
+
return unless node
|
|
858
|
+
|
|
859
|
+
yield node, stack
|
|
860
|
+
stack.push(node)
|
|
861
|
+
node.compact_child_nodes.each { |child| each_node(child, stack, &block) }
|
|
862
|
+
stack.pop
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def arguments(node)
|
|
866
|
+
node.arguments&.arguments || []
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def literal_name(node)
|
|
870
|
+
node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def keywords(args)
|
|
874
|
+
hash = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) }
|
|
875
|
+
return {} unless hash
|
|
876
|
+
|
|
877
|
+
hash.elements.each_with_object({}) do |assoc, acc|
|
|
878
|
+
next unless assoc.is_a?(Prism::AssocNode)
|
|
879
|
+
|
|
880
|
+
key = literal_name(assoc.key)
|
|
881
|
+
acc[key] = assoc.value if key
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
# Prism 0.19, which Ruby 3.3 ships, names a path's last part through `child`.
|
|
886
|
+
def path_name(node)
|
|
887
|
+
(node.respond_to?(:name) ? node.name : node.child.name).to_s
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def enclosing_class(stack)
|
|
891
|
+
stack.select { |node| node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) }.map { |klass| klass.constant_path.slice }.last
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# The lexical scopes around a node, outermost first, as full names:
|
|
895
|
+
# `module Admin; class OrdersController` gives Admin, Admin::OrdersController.
|
|
896
|
+
def nesting(stack)
|
|
897
|
+
stack.select { |node| node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) }.each_with_object([]) do |klass, crefs|
|
|
898
|
+
path = klass.constant_path.slice
|
|
899
|
+
crefs << (path.start_with?("::") ? path.delete_prefix("::") : [crefs.last, path].compact.join("::"))
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def enclosing_def(stack)
|
|
904
|
+
stack.grep(Prism::DefNode).last&.name
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
end
|