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.
@@ -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