rails-ai-context 5.30.2 → 5.31.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +173 -0
  3. data/docs/COMPATIBILITY.md +21 -7
  4. data/docs/CONFIGURATION.md +3 -0
  5. data/docs/GUIDE.md +7 -1
  6. data/docs/INTROSPECTORS.md +6 -1
  7. data/docs/SECURITY.md +23 -1
  8. data/docs/TOOLS.md +11 -2
  9. data/lib/rails_ai_context/action_filters.rb +2 -3
  10. data/lib/rails_ai_context/base_mixins.rb +44 -16
  11. data/lib/rails_ai_context/concern_macros.rb +698 -92
  12. data/lib/rails_ai_context/concern_membership.rb +6 -4
  13. data/lib/rails_ai_context/concern_paths.rb +29 -3
  14. data/lib/rails_ai_context/configuration.rb +2 -2
  15. data/lib/rails_ai_context/git_ignore.rb +13 -11
  16. data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
  17. data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
  18. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
  19. data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
  20. data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
  21. data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
  22. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
  23. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
  24. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
  25. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
  26. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  27. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  28. data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
  29. data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
  30. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
  31. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
  32. data/lib/rails_ai_context/method_name.rb +31 -0
  33. data/lib/rails_ai_context/output_guard.rb +6 -4
  34. data/lib/rails_ai_context/redaction.rb +8 -2
  35. data/lib/rails_ai_context/safe_file.rb +4 -1
  36. data/lib/rails_ai_context/safe_path.rb +17 -4
  37. data/lib/rails_ai_context/server.rb +26 -7
  38. data/lib/rails_ai_context/tools/base_tool.rb +11 -2
  39. data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
  40. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  41. data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
  42. data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
  43. data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
  44. data/lib/rails_ai_context/tools/get_schema.rb +4 -0
  45. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  46. data/lib/rails_ai_context/tools/query.rb +122 -28
  47. data/lib/rails_ai_context/tools/search_code.rb +64 -22
  48. data/lib/rails_ai_context/tools/search_docs.rb +4 -5
  49. data/lib/rails_ai_context/tools/security_scan.rb +3 -1
  50. data/lib/rails_ai_context/version.rb +1 -1
  51. data/lib/rails_ai_context.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +4 -1
@@ -10,11 +10,506 @@ module RailsAiContext
10
10
  module ConcernMacros
11
11
  MAX_DEPTH = 3
12
12
 
13
+ # A call made inside a class method's body, standing where the call that ran
14
+ # that body (`outer`) was made; `definition` is the body it sits in.
15
+ Relayed = Struct.new(:node, :outer, :definition) do
16
+ def arguments = node.arguments
17
+ def location = node.location
18
+ def name = node.name
19
+ end
20
+
21
+ # Ruby's class-method lookup over a class (rank 0), its bases nearest first and the modules
22
+ # every model has: which definitions a call runs, and so where what they declare lands.
23
+ class SingletonLookup
24
+ # The lookup order inside one rank.
25
+ PREPENDED = 0
26
+ OWN = 1
27
+ MIXED = 2
28
+ # The lookup order of the defs that join with a module, by how it is added.
29
+ JOINS = { prepended: PREPENDED, mixed: MIXED }.freeze
30
+ # The defs a module gives that run with code, by group: [run by a hook?, lookup order].
31
+ RAN = { block: [ false, OWN ], hook: [ true, OWN ], hook_mixed: [ true, MIXED ], hook_prepended: [ true, PREPENDED ] }.freeze
32
+
33
+ # A class method; `owner` is a class file's rank or a module's label, `alias_of` the name an alias copies,
34
+ # `at` its place in the owner's lookup when not its line (a base body's, after the base's file),
35
+ # `joined` [line, argument index] of the hook's call adding the nested module it is in.
36
+ Def = Struct.new(:owner, :name, :line, :super_line, :calls, :alias_of, :at, :joined) do
37
+ def key = at ? [ owner, *at ] : [ owner, line ]
38
+ end
39
+ # One step of a singleton ancestry, existing from `at` ([line, order, ...]).
40
+ Provider = Struct.new(:rank, :group, :at, :defs)
41
+ # The class files' body calls by name, each one's rank, their own class methods, and the outermost rank.
42
+ Read = Struct.new(:found, :ranks, :defs, :outer)
43
+ # A module a walk reached, with the class methods it gives (`module_defs`). Added at `at` ([line, order]), or
44
+ # within each run of the code `added` names (`added_in`); an every-model module at the outermost rank.
45
+ Mixin = Struct.new(:label, :macro, :defs, :added, :at, :every)
46
+ Reached = Struct.new(:included, :blocks, :mixins)
47
+ # Each pass can reach one more module added inside a body, and a walk reads MAX_DEPTH levels;
48
+ # one more pass shows nothing changed.
49
+ PASSES = MAX_DEPTH + 2
50
+
51
+ def self.definition(owner, node)
52
+ super_node = node.body && Introspectors::AstWalk.each(node.body).find do |inner|
53
+ inner.is_a?(Prism::SuperNode) || inner.is_a?(Prism::ForwardingSuperNode)
54
+ end
55
+ calls = node.body ? Introspectors::SourceIntrospector.calls_outside_methods(node.body, self_receiver: true) : {}
56
+ Def.new(owner, node.name.to_s, node.location.start_line, super_node&.location&.start_line, calls)
57
+ end
58
+
59
+ # Where a base body's def sits in the lookup: after every class file's, then by load order and line.
60
+ AFTER_CLASS_FILES = Float::INFINITY
61
+
62
+ # The class methods a body run with the class as self defines: `def self.x`, and `def x` and aliases
63
+ # inside `class << self`. `owner` is a class file's rank or a module's label; `order` a base body's load order.
64
+ def self.own_defs(scope, owner, order = nil) = member_defs(scope, owner, order).map(&:last)
65
+
66
+ # [node, Def] for each of `own_defs`.
67
+ def self.member_defs(scope, owner, order = nil)
68
+ singleton_members(scope).map do |node|
69
+ names = alias_names(node)
70
+ found = names ? Def.new(owner, names.first, node.location.start_line, nil, {}, names.last) : definition(owner, node)
71
+ found.at = [ AFTER_CLASS_FILES, order, found.line ] if order
72
+ [ node, found ]
73
+ end
74
+ end
75
+
76
+ # The `def` and alias nodes behind `own_defs`.
77
+ def self.singleton_members(scope)
78
+ scope.flat_map do |node|
79
+ case node
80
+ when Prism::DefNode then node.receiver.is_a?(Prism::SelfNode) ? [ node ] : []
81
+ when Prism::SingletonClassNode
82
+ next [] unless node.expression.is_a?(Prism::SelfNode)
83
+
84
+ Array(node.body&.body).select { |member| (member.is_a?(Prism::DefNode) && member.receiver.nil?) || alias_names(member) }
85
+ else []
86
+ end
87
+ end
88
+ end
89
+
90
+ def self.alias_names(node)
91
+ pair =
92
+ case node
93
+ when Prism::AliasMethodNode then [ node.new_name, node.old_name ]
94
+ when Prism::CallNode then node.name == :alias_method && node.receiver.nil? ? Array(node.arguments&.arguments) : []
95
+ else []
96
+ end
97
+ names = pair.map { |arg| arg.unescaped if arg.respond_to?(:unescaped) }
98
+ names if names.size == 2 && names.all?
99
+ end
100
+
101
+ # The class methods the module `own` gives a class, by how they join: `:mixed` and `:prepended` with the
102
+ # module, `:block` (an included block's) at its first add, and at each add `:hook` from its line and
103
+ # `:hook_mixed`/`:hook_prepended` (a nested module's a hook adds) from the line adding it.
104
+ def self.module_defs(own, label, macro)
105
+ given = { prepended: [], mixed: [], block: [], hook: [], hook_mixed: [], hook_prepended: [] }
106
+ return given unless own&.body
107
+
108
+ hook_defs = hooks(own, macro)
109
+ if ConcernMembership::SINGLETON_MACROS.include?(macro)
110
+ given[group(macro)] = body_defs(own.body)
111
+ else
112
+ concern = macro == :prepend ? :prepended : :mixed
113
+ class_methods = concern?(own)
114
+ own.body.compact_child_nodes.each do |node|
115
+ if node.is_a?(Prism::CallNode) && node.name == :class_methods && node.block then given[concern].concat(body_defs(node.block.body))
116
+ elsif node.is_a?(Prism::CallNode) && node.name == ConcernMembership::CONCERN_BLOCKS[macro] && node.block
117
+ given[:block].concat(singleton_scope_defs(node.block.body, label))
118
+ elsif node.is_a?(Prism::ModuleNode) && class_methods && module_name(node) == "ClassMethods"
119
+ given[concern].concat(body_defs(node.body))
120
+ end
121
+ end
122
+ end
123
+ short = label.to_s.split("::").last
124
+ hook_defs.flat_map { |hook| hook_joins(hook) }.each do |added, arg, call|
125
+ next unless ConcernMembership::SINGLETON_MACROS.include?(added)
126
+
127
+ name = short_name(arg, short)
128
+ given[group(added)].concat(body_defs(own.body)) if name == short
129
+ nested_modules(own).select { |node| module_name(node) == name }.each do |node|
130
+ given[RAN.key([ true, JOINS[group(added)] ])].concat(body_defs(node.body).map { |inner| definition(label, inner).tap { |found| found.joined = [ call.location.start_line, call.arguments.arguments.index(arg) ] } })
131
+ end
132
+ end
133
+ given[:hook] = hook_defs.flat_map { |hook| singleton_defs(hook, label) }
134
+ given.to_h { |group, nodes| [ group, nodes.map { |node| node.is_a?(Def) ? node : definition(label, node) } ] }
135
+ end
136
+
137
+ def self.nested_modules(own) = own.body.compact_child_nodes.grep(Prism::ModuleNode)
138
+
139
+ def self.module_name(node) = node.constant_path.slice.split("::").last
140
+
141
+ # A `def self.included` (or the hook `macro` runs) on the module itself.
142
+ def self.hook?(node, macro)
143
+ node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::HOOKS_BY_MACRO.fetch(macro, []).include?(node.name.to_s)
144
+ end
145
+
146
+ def self.concern?(own)
147
+ own.body.compact_child_nodes.any? do |node|
148
+ node.is_a?(Prism::CallNode) && node.name == :extend && node.receiver.nil? &&
149
+ Array(node.arguments&.arguments).any? { |arg| arg.slice.delete_prefix("::") == "ActiveSupport::Concern" }
150
+ end
151
+ end
152
+
153
+ def self.group(macro) = macro == :singleton_prepend ? :prepended : :mixed
154
+
155
+ def self.short_name(arg, short) = arg.is_a?(Prism::SelfNode) ? short : arg.slice.split("::").last
156
+
157
+ # The hooks `macro` runs that the module `own` defines: `extended` for an extend, none for a `singleton_class` mixin.
158
+ def self.hooks(own, macro)
159
+ return [] if own&.body.nil?
160
+
161
+ own.body.compact_child_nodes.select { |node| node.is_a?(Prism::DefNode) && hook?(node, macro) && node.body }
162
+ end
163
+
164
+ # A reader of the hook's parameter, the class mixing the module in; nil when the hook takes none.
165
+ def self.base_reader(hook)
166
+ param = hook.parameters&.requireds&.first
167
+ ->(node) { node.is_a?(Prism::LocalVariableReadNode) && node.name == param.name } if param.respond_to?(:name)
168
+ end
169
+
170
+ # [macro, argument, call] for each module a hook adds to the class: `base.include`, `prepend` and `extend`,
171
+ # and `base.singleton_class.include`/`prepend`, `base` being the hook's parameter.
172
+ def self.hook_joins(hook)
173
+ base = base_reader(hook)
174
+ return [] unless base
175
+
176
+ Introspectors::AstWalk.each(hook.body).flat_map do |call|
177
+ next [] unless call.is_a?(Prism::CallNode)
178
+
179
+ name = call.name
180
+ arguments = Array(call.arguments&.arguments)
181
+ if %i[send public_send].include?(name) && arguments.first.is_a?(Prism::SymbolNode)
182
+ name = arguments.first.unescaped.to_sym
183
+ arguments = arguments.drop(1)
184
+ end
185
+ macro =
186
+ if base.call(call.receiver) then name if Introspectors::Listeners::MixinsListener::MIXIN_MACROS.include?(name)
187
+ elsif Introspectors::Listeners::MixinsListener.singleton_class_of?(call.receiver, &base)
188
+ { include: :singleton_include, prepend: :singleton_prepend }[name]
189
+ end
190
+ next [] unless macro
191
+
192
+ arguments.filter_map do |arg|
193
+ [ macro, arg, call ] if arg.is_a?(Prism::SelfNode) || arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode)
194
+ end
195
+ end
196
+ end
197
+
198
+ # A mixin record the walk follows for each module from elsewhere a hook adds;
199
+ # `module_defs` reads the module itself and one nested in it.
200
+ def self.hook_mixins(own, label, macro)
201
+ short = label.to_s.split("::").last
202
+ read = [ short, *(own&.body ? nested_modules(own).map { |node| module_name(node) } : []) ]
203
+ hooks(own, macro).flat_map { |hook| hook_joins(hook) }.filter_map do |added, arg, call|
204
+ next if read.include?(short_name(arg, short))
205
+
206
+ Introspectors::Listeners::MixinsListener.record(
207
+ call, added, arg.slice.delete_prefix("::"), ancestor: Introspectors::Listeners::MixinsListener::ANCESTOR_MACROS.include?(added)
208
+ )
209
+ end
210
+ end
211
+
212
+ # The class's own methods a hook defines: `def x` in `class << base`, and `def self.x` or
213
+ # `class << self` in `base.class_eval`, `base` being the class mixing the module in.
214
+ def self.singleton_defs(hook, label)
215
+ base = base_reader(hook)
216
+ return [] unless base
217
+
218
+ Introspectors::AstWalk.each(hook.body).flat_map do |node|
219
+ if node.is_a?(Prism::SingletonClassNode) && base.call(node.expression) then body_defs(node.body).map { |inner| definition(label, inner) }
220
+ elsif node.is_a?(Prism::CallNode) && %i[class_eval class_exec].include?(node.name) && base.call(node.receiver) && node.block
221
+ singleton_scope_defs(node.block.body, label)
222
+ else []
223
+ end
224
+ end
225
+ end
226
+
227
+ # The `def self.x` and `class << self` defs a block run with the class as self makes.
228
+ def self.singleton_scope_defs(body, label)
229
+ own_defs(body.is_a?(Prism::StatementsNode) ? body.body : [], label)
230
+ end
231
+
232
+ # The `def x` a module body runs with the module as self, `private def x` included.
233
+ def self.body_defs(node, found = [])
234
+ case node
235
+ when nil then found
236
+ when Prism::DefNode then node.receiver.nil? ? found << node : found
237
+ when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then found
238
+ when Prism::CallNode
239
+ return found if %i[class_methods included prepended].include?(node.name) && node.block
240
+
241
+ node.compact_child_nodes.each_with_object(found) { |child, into| body_defs(child, into) }
242
+ else node.compact_child_nodes.each_with_object(found) { |child, into| body_defs(child, into) }
243
+ end
244
+ end
245
+
246
+ # Where code adding a module at `line` of `owner` runs, when not where `owner` itself joins:
247
+ # [:body, [owner, def line]] in a method, [:block, owner, hook?] in a Concern's block or a hook's class_eval.
248
+ def self.added_in(owner, line, bodies, blocks = [], evals = [])
249
+ body = ConcernMacros.enclosing(bodies, line)
250
+ if body then [ :body, [ owner, body.first.begin ] ]
251
+ elsif blocks.any? { |range| range.cover?(line) } then [ :block, owner, false ]
252
+ elsif evals.any? { |range| range.cover?(line) } then in_hook(owner)
253
+ end
254
+ end
255
+
256
+ # Where a hook of `owner` runs, each time `owner` is added.
257
+ def self.in_hook(owner) = [ :block, owner, true ]
258
+
259
+ # Whether a mixin record adds to the class: an include or prepend, or an `extend` or `singleton_class`
260
+ # include or prepend run with the class as self; in a module's own body `extend` extends the module.
261
+ def self.joins?(mixin, class_self)
262
+ mixin[:ancestor] || (ConcernMembership::SINGLETON_MACROS.include?(mixin[:macro]) && !mixin[:receiver] && class_self)
263
+ end
264
+
265
+ def initialize(reader)
266
+ @reader = reader
267
+ @walks = {}
268
+ forget
269
+ end
270
+
271
+ # What the walk of the class at `rank` found: the calls its concerns' blocks and hooks make, each
272
+ # one's [label, hook?] by object id, and the modules it reached. A later walk of a rank replaces it.
273
+ def add(rank, included, blocks, mixins)
274
+ @walks[rank] = Reached.new(included, blocks, mixins.to_h { |mixin| [ mixin.label, mixin ] })
275
+ forget
276
+ end
277
+
278
+ # Every call the classes may make, by name. Resolving one needs every walk,
279
+ # so a body is read for any call of its name.
280
+ def sites_by_name
281
+ @sites_by_name ||= begin
282
+ found = Run.merge_calls({}, read.found)
283
+ @walks.each_value { |walk| Run.merge_calls(found, walk.included) }
284
+ queue = found.flat_map { |name, sites| sites.compact.map { |site| [ name, site ] } }
285
+ read_bodies = Set.new
286
+ until queue.empty?
287
+ name, site = queue.shift
288
+ defs_named(name).each do |definition|
289
+ next unless read_bodies.add?([ definition.key, root(site).__id__ ])
290
+
291
+ # A call of an alias runs the original's body.
292
+ if definition.alias_of
293
+ (found[definition.alias_of] ||= []) << site
294
+ queue << [ definition.alias_of, site ]
295
+ end
296
+ definition.calls.each do |inner, nodes|
297
+ nodes.each do |node|
298
+ relayed = Relayed.new(node, site, definition)
299
+ (found[inner] ||= []) << relayed
300
+ queue << [ inner, relayed ]
301
+ end
302
+ end
303
+ end
304
+ end
305
+ found
306
+ end
307
+ end
308
+
309
+ # A copy of `entry` for each run of a call that reaches `definer`'s body
310
+ # ([owner, line]), placed at that call with `tail` after it; none when no call does.
311
+ def placed(entry, definer, tail, site = nil)
312
+ resolve
313
+ sites = site ? [ site ] : @running.fetch(definer, [])
314
+ sites.flat_map { |each| placements(each, definer, tail) }.uniq.map do |rank, at|
315
+ entry.merge(rank: rank, chain_at: at.map(&:to_i))
316
+ end
317
+ end
318
+
319
+ # A copy of `entry`, which a module the walk at `rank` reached declares, at each run of that code.
320
+ # One in a method stands at each call reaching it, read from one walk: the method's key is the module's.
321
+ def mixed_in(entry, rank)
322
+ resolve
323
+ mixin = @walks[rank]&.mixins&.[](entry[:from_concern])
324
+ return [ entry.merge(rank: rank) ] unless mixin
325
+ return placed(entry.except(:site, :definer), entry[:definer], [ entry[:location] ], entry[:site]) if entry[:site] && first(mixin.label)&.first == rank
326
+ return [] if entry[:site]
327
+
328
+ ran(rank, mixin, entry[:hook]).map { |_, run, at| entry.merge(rank: run, chain_at: [ *at, entry[:location].to_i ]) }
329
+ end
330
+
331
+ private
332
+
333
+ # Where code of `mixin`, reached by the walk at `rank`, runs: a hook's each time the walk's class
334
+ # adds the module, the rest once, where the chain first adds it (Ruby adds a module once).
335
+ def ran(rank, mixin, hook)
336
+ hook ? events(rank, mixin) : [ first(mixin.label) ].select { |walk, *| walk == rank }
337
+ end
338
+
339
+ # [walk rank, rank, at] for each time the code adding `mixin` runs.
340
+ def events(rank, mixin)
341
+ key = [ rank, mixin.label ]
342
+ return @events[key] if @events.key?(key)
343
+
344
+ # A module reached again while its own adds are worked out adds nothing more.
345
+ @events[key] = []
346
+ kind, where, hook = mixin.added
347
+ @events[key] =
348
+ case kind
349
+ when :body
350
+ @running.fetch(where, []).flat_map { |site| placements(site, where, mixin.at) }.uniq.map { |run, at| [ rank, run, at ] }
351
+ when :block
352
+ parent = @walks[rank].mixins[where]
353
+ parent ? ran(rank, parent, hook).map { |_, run, at| [ rank, run, [ *at, *mixin.at ] ] } : []
354
+ else [ [ rank, mixin.every ? read.outer : rank, mixin.at ] ]
355
+ end
356
+ end
357
+
358
+ def first(label)
359
+ return @first[label] if @first.key?(label)
360
+
361
+ # A module whose first add depends on its own (a hook adding it again) has none until one is found.
362
+ @first[label] = nil
363
+ @first[label] = @walks.flat_map { |rank, walk| walk.mixins.key?(label) ? events(rank, walk.mixins[label]) : [] }
364
+ .min_by { |_, run, at| [ -run, at ] }
365
+ end
366
+
367
+ def placements(site, definer, tail)
368
+ runs(root(site)).filter_map do |rank, at|
369
+ path = run_path(site, rank, at)
370
+ chain = path && chain(site, rank, at)
371
+ index = chain&.index { |definition| definition.key == definer }
372
+ [ rank, path + chain.first(index).map(&:super_line) + tail ] if index
373
+ end
374
+ end
375
+
376
+ # Where a call stands in the run of its root call: nil when the body making it never runs.
377
+ def run_path(site, rank, at)
378
+ return at unless site.is_a?(Relayed)
379
+
380
+ path = run_path(site.outer, rank, at)
381
+ chain = path && chain(site.outer, rank, at)
382
+ index = chain&.index { |definition| definition.key == site.definition.key }
383
+ path + chain.first(index).map(&:super_line) + [ site.location.start_line ] if index
384
+ end
385
+
386
+ # The definitions a call runs: the first the lookup finds, then each one a `super` reaches.
387
+ def chain(site, rank, at)
388
+ @chains[[ site.__id__, rank, at ]] ||= begin
389
+ found = lookup(site.name.to_s, rank, at)
390
+ found.take_while.with_index { |_, index| index.zero? || found[index - 1].super_line }
391
+ end
392
+ end
393
+
394
+ # Defs one hook call adds share its place: as in Ruby, its earlier argument wins, then the later def in one module.
395
+ def lookup(name, rank, at)
396
+ place = lambda do |provider|
397
+ found = provider.defs[name]
398
+ [ provider.at, -found.joined.to_a.last.to_i, found.line, found.owner.to_s ]
399
+ end
400
+ @providers.select { |provider| provider.defs.key?(name) && provider.rank >= rank && (provider.rank > rank || (provider.at <=> at) <= 0) }
401
+ .sort { |a, b| ([ a.rank, a.group ] <=> [ b.rank, b.group ]).nonzero? || (place.call(b) <=> place.call(a)) }
402
+ .flat_map { |provider| resolved(provider.defs[name], provider) }
403
+ end
404
+
405
+ # An alias runs what its original name ran where the alias stands.
406
+ def resolved(definition, provider)
407
+ definition.alias_of ? lookup(definition.alias_of, provider.rank, provider.at).first(1) : [ definition ]
408
+ end
409
+
410
+ # Each [rank, at] a call outside any method runs in: a class-body call once, at its line;
411
+ # a call a concern's block or hook makes where `ran` puts that concern's code.
412
+ def runs(site)
413
+ rank = read.ranks[site.__id__]
414
+ return [ [ rank, [ site.location.start_line, -1 ] ] ] if rank
415
+
416
+ @walks.flat_map do |walk_rank, walk|
417
+ label, hook = walk.blocks[site.__id__]
418
+ mixin = label && walk.mixins[label]
419
+ mixin ? ran(walk_rank, mixin, hook) : []
420
+ end.uniq.map { |_, run, at| [ run, [ *at, site.location.start_line ] ] }
421
+ end
422
+
423
+ # Modules join where code adding them runs, and which code a call runs depends on the
424
+ # modules joined before it, so both are read again until neither changes.
425
+ def resolve
426
+ return if @running
427
+
428
+ @running = {}
429
+ @providers = []
430
+ inside = @walks.each_value.any? { |walk| walk.mixins.each_value.any? { |mixin| mixin.added&.first == :body } }
431
+ PASSES.times do
432
+ # Cleared so each pass rereads them against the new providers.
433
+ @chains = {}
434
+ @events = {}
435
+ @first = {}
436
+ @providers = providers
437
+ @chains = {}
438
+ running = running_sites
439
+ signature = [ @providers.map { |provider| [ provider.rank, provider.group, provider.at, provider.defs.values.map(&:key) ] },
440
+ running.transform_values { |sites| sites.map(&:__id__) } ]
441
+ settled = signature == @signature
442
+ @signature = signature
443
+ @running = running
444
+ break if settled || !inside
445
+ end
446
+ end
447
+
448
+ # The class files' own methods, each module's where the chain first adds it, and the class's own
449
+ # methods a block or hook defines and a nested module's a hook adds, from their line in each run of that code.
450
+ def providers
451
+ own = read.defs.map { |definition| Provider.new(definition.owner, OWN, definition.at || [ definition.line, 0 ], by_name([ definition ])) }
452
+ joined = @walks.each_value.flat_map { |walk| walk.mixins.keys }.uniq.flat_map do |label|
453
+ walk, rank, at = first(label)
454
+ mixin = walk && @walks[walk].mixins[label]
455
+ next [] unless mixin
456
+
457
+ JOINS.filter_map do |kind, group|
458
+ Provider.new(rank, group, at, by_name(mixin.defs[kind])) if mixin.defs[kind].any?
459
+ end
460
+ end
461
+ ran_defs = @walks.flat_map do |rank, walk|
462
+ walk.mixins.each_value.flat_map do |mixin|
463
+ RAN.flat_map do |kind, (hook, group)|
464
+ next [] if mixin.defs[kind].empty?
465
+
466
+ ran(rank, mixin, hook).flat_map do |_, run, at|
467
+ mixin.defs[kind].map { |definition| Provider.new(run, group, [ *at, definition.joined&.first || definition.line ], by_name([ definition ])) }
468
+ end
469
+ end
470
+ end
471
+ end
472
+ own + joined + ran_defs
473
+ end
474
+
475
+ def by_name(defs) = defs.to_h { |definition| [ definition.name, definition ] }
476
+
477
+ # The sites whose call may run each body, by the body's key.
478
+ def running_sites
479
+ sites_by_name.each_value.with_object(Hash.new { |hash, key| hash[key] = [] }) do |sites, index|
480
+ sites.compact.each do |site|
481
+ runs(root(site)).each { |rank, at| chain(site, rank, at).each { |definition| index[definition.key] |= [ site ] } }
482
+ end
483
+ end
484
+ end
485
+
486
+ def defs_named(name)
487
+ @defs_named ||= (read.defs + @walks.each_value.flat_map { |walk| walk.mixins.each_value.flat_map { |mixin| mixin.defs.values.flatten } })
488
+ .group_by(&:name)
489
+ @defs_named.fetch(name, [])
490
+ end
491
+
492
+ def root(site)
493
+ site = site.outer while site.is_a?(Relayed)
494
+ site
495
+ end
496
+
497
+ def forget
498
+ @sites_by_name = @running = @defs_named = @signature = nil
499
+ @chains = {}
500
+ end
501
+
502
+ def read = (@read ||= @reader.call)
503
+ end
504
+
13
505
  # One walk's state. The root, the directories, the keys and the cache are
14
506
  # fixed for the run and `seen`, `collected` and `unresolved` accumulate
15
507
  # across it, so they belong to the run rather than to every call.
16
508
  class Run
17
- attr_reader :unresolved, :hidden, :included_calls, :skipped_methods, :placement
509
+ # Each runs its block with the class as self.
510
+ EVALS = %i[class_eval class_exec instance_eval instance_exec].freeze
511
+
512
+ attr_reader :unresolved, :hidden, :included_calls, :skipped_methods, :placement, :block_sites, :mixins
18
513
 
19
514
  # The default block belongs to the walk. Once the entries leave it, a
20
515
  # caller reading a key the walk never produced would grow one.
@@ -31,27 +526,29 @@ module RailsAiContext
31
526
  @consulted.any? { |name| called.include?(name) }
32
527
  end
33
528
 
34
- def initialize(root, dirs, keys, cache, listeners, calls = nil, extra = [], file = nil)
35
- # The class's own file: a module it declares there and includes is
36
- # its own body, already read with it.
529
+ def initialize(root, dirs, keys, cache, listeners, calls = nil, extra = [], file = nil, known: nil)
530
+ # The class's own file, which can declare a module it includes.
37
531
  @own_file = file
532
+ # Written at the top level, so each name is the constant it resolves to.
38
533
  @paths = extra.to_h { |mixin| [ mixin.name, mixin.path ] }
39
- @macros = extra.to_h { |mixin| [ mixin.name, mixin.macro ] }
40
- @extended = extra.select { |mixin| mixin.macro == :extend }.map(&:name).to_set
41
534
  @root = root
42
535
  @dirs = dirs
43
536
  @keys = keys
44
537
  @cache = cache
45
538
  @listeners = listeners
46
539
  @calls = calls
540
+ @known = known
47
541
  @seen = Set.new
48
542
  @collected = Hash.new { |hash, key| hash[key] = [] }
49
543
  @unresolved = []
50
544
  @hidden = []
51
545
  @included_calls = {}
546
+ @block_sites = {}
52
547
  @skipped_methods = Set.new
53
548
  @consulted = Set.new
54
549
  @placement = {}
550
+ @mixins = []
551
+ @inside = nil
55
552
  end
56
553
 
57
554
  # Raw mixin names in, so the exclusion happens here: this is the only
@@ -63,57 +560,77 @@ module RailsAiContext
63
560
 
64
561
  # `names` may be the mixin records themselves, which say how each
65
562
  # module is mixed in; that decides which of its hooks run.
66
- if names.first.is_a?(Hash)
67
- names.each { |mixin| @macros[mixin[:name]] ||= mixin[:macro] }
68
- names = names.map { |mixin| mixin[:name] }.uniq
69
- end
70
-
71
- names.each do |name|
563
+ names.each do |mixin|
564
+ name, written, given = mixin.is_a?(Hash) ? mixin.values_at(:name, :macro, :path) : mixin
565
+ # A base module comes with the file its top-level name resolves to.
566
+ base = mixin.is_a?(Hash) && mixin.key?(:path)
72
567
  @top = name if file.nil?
73
- next unless @seen.add?(name)
74
568
  next unless ConcernMembership.candidate?(name)
75
- next if file.nil? && @own_file && nested_module(@own_file, name, within)
76
569
 
77
- nested = file && nested_module(file, name, within)
78
- path =
79
- if nested then file
80
- elsif @paths.key?(name) then @paths[name]
81
- else ConcernPaths.find_file(@root, name, within: within, dirs: @dirs)
570
+ source = file || @own_file
571
+ nested = !base && source && nested_module(source, name, within)
572
+ label, path =
573
+ if base then [ name, given ]
574
+ elsif nested then [ nested.first, source ]
575
+ else named(name, within) || beside(file, name, within)
82
576
  end
577
+ if path.nil? && !base && (outer = in_outer_file(name, within))
578
+ source, nested = outer
579
+ label, path = nested.first, source
580
+ end
581
+ # A module is walked once, as the constant the name resolves to where it is written;
582
+ # a name resolving to none keeps no module's place.
583
+ next unless @seen.add?(path ? label : [ :unresolved, name ])
584
+
585
+ macro = written || :include
586
+ singleton = ConcernMembership::SINGLETON_MACROS.include?(macro)
83
587
  if ConcernMembership.excluded?(name)
84
588
  # Hiding a concern hides what it declared. Only one whose file is
85
589
  # here would have been read, so only that one is worth counting.
86
590
  # A module mixed in from outside the class's file is not its concern to hide.
87
- @hidden << name if path && !@paths.key?(name)
591
+ @hidden << name if path && !@paths.key?(label) && !singleton
88
592
  next
89
593
  end
90
594
 
91
- data = nested ? introspect_nested(file, nested) : path && introspect(path)
595
+ data = nested ? introspect_nested(source, nested) : path && introspect(path)
92
596
  if data.nil?
93
- @unresolved << name
597
+ # A module a class extends itself with from a gem is no concern of the app's.
598
+ @unresolved << name unless singleton && path.nil?
94
599
  next
95
600
  end
96
601
 
97
- label = nested ? nested.first : name
98
602
  # A nested module's lines count from its own slice, so the ranges parse that.
99
603
  tree = nested ? AstCache.parse_string(nested.last.slice).value : AstCache.parse(path).value
100
- macro = @macros[name] || :include
101
- source_key = nested ? "#{file}##{nested.first}" : path
102
- Run.merge_calls(@included_calls,
103
- memo([ :included_calls, source_key, label, macro ]) { included_block_calls(tree, label, macro) })
604
+ source_key = nested ? "#{source}##{nested.first}" : path
605
+ block_calls, hooked, blocks, evals = memo([ :included_calls, source_key, label, macro ]) { included_block_calls(tree, label, macro) }
606
+ Run.merge_calls(@included_calls, block_calls)
607
+ hooked = hooked.to_set
608
+ block_calls.each_value { |sites| sites.each { |site| @block_sites[site.__id__] ||= [ label, hooked.include?(site.__id__) ] if site } }
609
+ @mixins << [ label, macro, memo([ :module_defs, source_key, label, macro ]) { module_defs(tree, label, macro) }, @inside ]
104
610
  bodies, hooks = method_bodies(data, macro)
105
611
  own_lines, inner = memo([ :ranges, source_key, label ]) { own_and_nested_ranges(tree, label) }
106
- extended = @extended.include?(name)
107
- scope = [ extended, inner, own_lines, hooks ]
108
- @keys.each do |key|
109
- applied(data[key], bodies, *scope).each { |entry| @collected[key] << tagged(entry, label) }
612
+ scope = [ singleton, inner, own_lines, hooks ]
613
+ # The class's own file was read with the class; only its callbacks go by owner.
614
+ keys = nested && source == @own_file ? @keys & [ :callbacks ] : @keys
615
+ keys.each do |key|
616
+ applied(data[key], bodies, *scope).each { |entry| @collected[key] << tagged(entry, label, hook: in_hook?(entry, hooks)) }
110
617
  end
111
- expand_called(tree, data, own_lines, label).each do |key, entries|
618
+ expand_called(tree, data, own_lines, label, keys).each do |key, entries|
112
619
  entries.each { |entry| @collected[key] << tagged(entry, label) }
113
620
  end
114
621
 
115
- mixins = applied(data[:mixins], bodies, *scope, keep_called: true)
116
- walk(mixins.select { |mixin| mixin[:ancestor] }, label, depth - 1, path)
622
+ joined = applied(data[:mixins], bodies, *scope, keep_called: true).filter_map do |mixin|
623
+ added = SingletonLookup.added_in(label, mixin[:location], bodies, blocks, evals)
624
+ [ mixin, added ] if SingletonLookup.joins?(mixin, !added.nil?)
625
+ end
626
+ hooked_mixins = memo([ :hook_mixins, source_key, label, macro ]) { hook_mixins(tree, label, macro) }
627
+ joined.concat(hooked_mixins.map { |mixin| [ mixin, SingletonLookup.in_hook(label) ] })
628
+ joined.sort_by.with_index { |(mixin, _), index| [ mixin[:location].to_i, index ] }.each do |mixin, added|
629
+ outer = @inside
630
+ @inside = [ added, mixin[:location] ] if added
631
+ walk([ mixin ], label, depth - 1, path)
632
+ @inside = outer
633
+ end
117
634
  # ActiveSupport::Concern runs a concern's dependencies before it, so
118
635
  # the walk's post-order is the order the class receives them.
119
636
  @placement[label] = [ @top, @placement.size ]
@@ -150,6 +667,40 @@ module RailsAiContext
150
667
  RailsAiContext.debug_fail(e, nil, label: "nested module #{name} in #{file}")
151
668
  end
152
669
 
670
+ # The first candidate Ruby would find, a base module or a file.
671
+ def named(name, within)
672
+ found = ConcernPaths.find_named(@root, name, within: within, dirs: @dirs)
673
+ candidates = ConcernPaths.candidate_names(name, within)
674
+ extra = candidates.find { |candidate| @paths.key?(candidate) }
675
+ return found unless extra && (found.nil? || candidates.index(extra) <= candidates.index(found.first))
676
+
677
+ [ extra, @paths[extra] ]
678
+ end
679
+
680
+ # A plugin's lib is on the load path, so a module it names can sit under the
681
+ # namer's own directory by its constant path, required by a glob.
682
+ def beside(file, name, within)
683
+ return nil unless file
684
+
685
+ memo([ :beside, file, name, within ]) do
686
+ dir = File.dirname(file)
687
+ candidates = ConcernPaths.candidate_names(name, within)
688
+ candidate, path = candidates.map { |each| [ each, File.join(dir, "#{each.underscore}.rb") ] }.find { |_, each| File.file?(each) }
689
+ # The file can sit under a shorter path than the constant it declares.
690
+ declared = path && Introspectors::DeclaredConstant.named(SafeFile.read(path), candidate)
691
+ [ candidates.include?(declared) ? declared : candidate, path ] if path
692
+ end
693
+ end
694
+
695
+ # `Outer::Inner` with no file of its own, from Outer's file, which Zeitwerk loads it with.
696
+ def in_outer_file(name, within)
697
+ memo([ :outer, name, within ]) do
698
+ candidate, file = ConcernPaths.outer_named(@root, name, within: within, dirs: @dirs)
699
+ found = file && nested_module(file, candidate, nil)
700
+ [ file, found ] if found
701
+ end
702
+ end
703
+
153
704
  def introspect_nested(file, (qualified, node))
154
705
  memo("#{file}##{qualified}") { Introspectors::SourceIntrospector.walk_source(node.slice, @listeners) }
155
706
  end
@@ -199,7 +750,7 @@ module RailsAiContext
199
750
  line = entry.is_a?(Hash) && entry[:location]
200
751
  next false if line && own_lines && !own_lines.cover?(line)
201
752
 
202
- enclosing = line && bodies.select { |range, _| range.cover?(line) }.min_by { |range, _| range.size }
753
+ enclosing = ConcernMacros.enclosing(bodies, line)
203
754
  next true if enclosing.nil? && hooks.any? { |range| range.cover?(line) }
204
755
  next !extended && inner.none? { |range| range.cover?(line) } if enclosing.nil?
205
756
  next keep_called if calls?(enclosing.last)
@@ -210,7 +761,7 @@ module RailsAiContext
210
761
  end
211
762
 
212
763
  # Each called method is read again with that call's literal arguments.
213
- def expand_called(tree, data, own_lines, label)
764
+ def expand_called(tree, data, own_lines, label, keys)
214
765
  found = Hash.new { |hash, key| hash[key] = [] }
215
766
  Array(data[:methods]).each do |method|
216
767
  name = method[:name].to_s
@@ -223,29 +774,27 @@ module RailsAiContext
223
774
  end
224
775
  next unless definition
225
776
 
226
- call_sites.fetch(name).each do |call|
227
- expansion = Introspectors::CallSiteExpansion.entries(definition, call, @listeners)
228
- # `:conditional` and `:foreign` come back as keys too, when asked for.
229
- expansion.each { |key, entries| found[key].concat(Array(entries)) if @keys.include?(key) }
230
- # The call site now reads as what the method declares; a caller that
231
- # read the call itself as a declaration (`validates_translation` as a
232
- # validation) drops that reading by its line.
233
- found[:expanded] << { method: name, line: call.location.start_line } if call && @keys.include?(:expanded)
234
- rescue StandardError => e
235
- # One call the expansion cannot read costs that method, named as unread.
236
- owner = Array(method[:owner]).join("::")
237
- @unresolved |= [ "#{owner.empty? ? label : owner}##{name}" ]
238
- RailsAiContext.debug_fail(e, nil, label: "called method expansion of #{name}")
777
+ expansion, read = ConcernMacros.expand_calls(definition, call_sites.fetch(name), keys, @listeners) do |entry, call|
778
+ [ at_call(entry, call, [ label, method[:location] ]) ]
239
779
  end
780
+ expansion.each { |key, entries| found[key].concat(entries) }
781
+ next if read
782
+
783
+ owner = Array(method[:owner]).join("::")
784
+ @unresolved |= [ "#{owner.empty? ? label : owner}##{name}" ]
240
785
  end
241
786
  found
242
787
  end
243
788
 
244
- def own_and_nested_ranges(tree, name)
789
+ def own_node(tree, name)
245
790
  short = name.to_s.split("::").last.to_s
246
- own = Introspectors::AstWalk.each(tree).find do |node|
791
+ Introspectors::DeclaredConstant.definition(Introspectors::AstWalk.each(tree).select do |node|
247
792
  constant_node?(node) && node.constant_path.slice.split("::").last.casecmp?(short)
248
- end
793
+ end)
794
+ end
795
+
796
+ def own_and_nested_ranges(tree, name)
797
+ own = own_node(tree, name)
249
798
  return [ nil, [] ] unless own
250
799
 
251
800
  # A class nested deeper sits inside one of these ranges already.
@@ -259,13 +808,16 @@ module RailsAiContext
259
808
  node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode)
260
809
  end
261
810
 
262
- # Receiverless calls in `included do` outside any method run in the includer's
263
- # class, so the includer calls them.
811
+ # Receiverless calls in `included do` outside any method are the includer's;
812
+ # the ids of a plain mixin hook's calls come back apart, as it reruns per include.
813
+ # Also the lines of those blocks, and of the `base.class_eval` blocks in hooks.
264
814
  def included_block_calls(tree, name, macro = :include)
265
815
  short = name.to_s.split("::").last.to_s
266
816
  block = ConcernMembership::CONCERN_BLOCKS[macro]
267
- runs = ConcernMembership::HOOKS_BY_MACRO.fetch(macro, [])
268
817
  found = {}
818
+ hooked = {}
819
+ ranges = []
820
+ evals = []
269
821
  visit = lambda do |node, owner|
270
822
  case node
271
823
  when Prism::ClassNode, Prism::ModuleNode
@@ -273,39 +825,35 @@ module RailsAiContext
273
825
  when Prism::CallNode
274
826
  if node.name == block && node.receiver.nil? && node.block && owner.to_s.casecmp?(short)
275
827
  Introspectors::SourceIntrospector.calls_outside_methods(node.block, found)
828
+ ranges << (node.location.start_line..node.location.end_line)
276
829
  return
277
830
  end
278
831
  when Prism::DefNode
279
- return hook_calls(node, found) if mixin_hook?(node, runs) && owner.to_s.casecmp?(short)
832
+ return hook_calls(node, hooked, evals) if SingletonLookup.hook?(node, macro) && owner.to_s.casecmp?(short)
280
833
  end
281
834
  node.child_nodes.compact.each { |child| visit.call(child, owner) }
282
835
  end
283
836
  visit.call(tree, nil)
284
- found
837
+ [ Run.merge_calls(found, hooked), hooked.values.flatten.map(&:__id__), ranges, evals ]
285
838
  rescue StandardError => e
286
- RailsAiContext.debug_fail(e, {}, label: "included block calls of #{name}")
287
- end
288
-
289
-
290
- def mixin_hook?(node, runs)
291
- node.receiver.is_a?(Prism::SelfNode) && runs.include?(node.name.to_s)
839
+ RailsAiContext.debug_fail(e, [ {}, [], [], [] ], label: "included block calls of #{name}")
292
840
  end
293
841
 
294
842
  # A hook runs in the includer: its receiverless calls (inside
295
843
  # `base.class_eval`) and its calls sent to the includer (`base.x`) are
296
844
  # calls the includer makes.
297
- def hook_calls(node, found)
845
+ def hook_calls(node, found, evals)
298
846
  return unless node.body
299
847
 
300
848
  Introspectors::SourceIntrospector.calls_outside_methods(node.body, found)
301
- param = node.parameters&.requireds&.first
302
- return unless param.respond_to?(:name)
849
+ base = SingletonLookup.base_reader(node)
850
+ return unless base
303
851
 
304
852
  Introspectors::AstWalk.each(node.body).each do |call|
305
- next unless call.is_a?(Prism::CallNode) && call.receiver.is_a?(Prism::LocalVariableReadNode)
306
- next unless call.receiver.name == param.name
853
+ next unless call.is_a?(Prism::CallNode) && base.call(call.receiver)
307
854
 
308
855
  (found[call.name.to_s] ||= []) << call
856
+ evals << (call.location.start_line..call.location.end_line) if EVALS.include?(call.name) && call.block
309
857
  end
310
858
  end
311
859
 
@@ -323,7 +871,7 @@ module RailsAiContext
323
871
  # Method name => its call sites; nil stands for a call whose arguments
324
872
  # are not known (a caller that names the methods only).
325
873
  def call_sites
326
- @call_sites ||= Run.merge_calls({}, @calls&.call).transform_values { |sites| sites.uniq(&:__id__) }
874
+ @call_sites ||= Run.merge_calls(Run.merge_calls({}, @calls&.sites_by_name), @known).transform_values { |sites| sites.uniq(&:__id__) }
327
875
  end
328
876
 
329
877
  # Folds `more` - a Hash of call sites, or bare names - into `into`.
@@ -336,13 +884,65 @@ module RailsAiContext
336
884
  into
337
885
  end
338
886
 
339
- def tagged(entry, concern_name)
340
- entry.is_a?(Hash) ? entry.merge(from_concern: concern_name) : entry
887
+ # A mixin hook runs again for a subclass that includes the module again; a Concern's block does not.
888
+ def tagged(entry, concern_name, hook: false)
889
+ return entry unless entry.is_a?(Hash)
890
+
891
+ hook ? entry.merge(from_concern: concern_name, hook: true) : entry.merge(from_concern: concern_name)
892
+ end
893
+
894
+ # The call and the body it runs travel with the entry; the class places
895
+ # it once every walk is known.
896
+ def at_call(entry, call, definer)
897
+ call && entry.is_a?(Hash) ? entry.merge(site: call, definer: definer) : entry
898
+ end
899
+
900
+ def module_defs(tree, label, macro)
901
+ SingletonLookup.module_defs(own_node(tree, label), label, macro)
902
+ rescue StandardError => e
903
+ RailsAiContext.debug_fail(e, SingletonLookup.module_defs(nil, label, macro), label: "class methods of #{label}")
904
+ end
905
+
906
+ def hook_mixins(tree, label, macro)
907
+ SingletonLookup.hook_mixins(own_node(tree, label), label, macro)
908
+ rescue StandardError => e
909
+ RailsAiContext.debug_fail(e, [], label: "hook mixins of #{label}")
910
+ end
911
+
912
+ def in_hook?(entry, hooks)
913
+ line = entry.is_a?(Hash) && entry[:location]
914
+ line && hooks.any? { |range| range.cover?(line) }
341
915
  end
342
916
  end
343
917
 
344
918
  module_function
345
919
 
920
+ # What the method `definition` declares at each call in `sites`, by key, each entry placed by the block
921
+ # ([entry, call] in, entries out), with the calls it read as `:expanded`; false second when a call was not.
922
+ def expand_calls(definition, sites, keys, listeners)
923
+ found = Hash.new { |hash, key| hash[key] = [] }
924
+ read = true
925
+ sites.each do |call|
926
+ # `:conditional` and `:foreign` come back as keys too, when asked for.
927
+ Introspectors::CallSiteExpansion.entries(definition, call, listeners).each do |key, entries|
928
+ found[key].concat(Array(entries).flat_map { |entry| yield entry, call }) if keys.include?(key)
929
+ end
930
+ # The call now reads as what the method declares; a caller that read the call itself as a
931
+ # declaration (`validates_translation` as a validation) drops that reading by its line.
932
+ found[:expanded].concat(yield({ method: definition.name.to_s, line: call.location.start_line }, call)) if call && keys.include?(:expanded)
933
+ rescue StandardError => e
934
+ # One call the expansion cannot read costs that call.
935
+ read = false
936
+ RailsAiContext.debug_fail(e, nil, label: "called method expansion of #{definition.name}")
937
+ end
938
+ [ found, read ]
939
+ end
940
+
941
+ # The innermost of `bodies`, [range, name] pairs, around `line`.
942
+ def enclosing(bodies, line)
943
+ line && bodies.select { |range, _| range.cover?(line) }.min_by { |range, _| range.size }
944
+ end
945
+
346
946
  # @param root [String] application root
347
947
  # @param mixins [Array<Hash>] MixinsListener records
348
948
  # @param keys [Array<Symbol>] payload keys to collect
@@ -350,7 +950,7 @@ module RailsAiContext
350
950
  # resolves to this one's concerns directory
351
951
  # @param within [String, nil] the enclosing constant of the class, for a
352
952
  # namespace-relative `include`
353
- # @param calls [#call, nil] answers the class methods the including class
953
+ # @param calls [SingletonLookup, nil] the class methods the including class
354
954
  # calls in its body; nil counts none
355
955
  # @param cache [Hash, nil] a caller-owned store keyed by concern file, so
356
956
  # one run walks a file once however many classes include it. The caller
@@ -361,23 +961,29 @@ module RailsAiContext
361
961
  # the kind from outside its file, read from the file they name
362
962
  # @param listeners [Hash] the listener map each concern file is walked
363
963
  # with; it must carry `mixins` for the walk to follow nested concerns
364
- # @return [Array(Hash, Array<String>, Array<String>, Hash, Hash, Set)] the
365
- # collected entries per key, the names whose file could not be read, the
366
- # names `excluded_concerns` hid that the walk would otherwise have read,
367
- # the methods `included` blocks call with their call sites, and for each
368
- # concern read the top-level mixin that reached it and its place in the
369
- # order Ruby adds them, and the methods whose declarations the walk held
370
- # back because nothing it knew of calls them
964
+ # @return [Array(Hash, Array<String>, Array<String>, Hash, Hash, Set, Hash, Array)]
965
+ # the collected entries per key, the names whose file could not be read,
966
+ # the names `excluded_concerns` hid that the walk would otherwise have
967
+ # read, the methods `included` blocks call with their call sites, and for
968
+ # each concern read the top-level mixin that reached it and its place in
969
+ # the order Ruby adds them, the methods whose declarations the walk held
970
+ # back because nothing it knew of calls them, the concern each `included`
971
+ # block call site belongs to and whether it is a plain hook, by the site's
972
+ # object id, and for each module read [label, macro, the class methods it gives
973
+ # (`SingletonLookup.module_defs`), and [added, line] when code a module runs later adds it
974
+ # (`SingletonLookup.added_in`)]
371
975
  def collect(root, mixins, keys:, prefer: nil, within: nil, cache: nil, calls: nil,
372
976
  listeners: Introspectors::SourceIntrospector::LISTENER_MAP, extra: [], file: nil)
373
- names = ConcernMembership.mixin_names(mixins) | extra.map(&:name)
374
- return [ {}, [], [], {}, {}, Set.new ] if names.empty?
977
+ # A module the class extends itself with gives it class methods, though no ancestor.
978
+ walked = Array(mixins).select { |mixin| SingletonLookup.joins?(mixin, true) } +
979
+ extra.map { |mixin| { name: mixin.name, macro: mixin.macro, path: mixin.path } }
980
+ return [ {}, [], [], {}, {}, Set.new, {}, [] ] if walked.empty?
375
981
 
376
982
  # Most walks never look at the class's calls, so a base's walk is the
377
983
  # same for every subclass: kept in the caller's per-run cache.
378
984
  memo_key = cache && [ :collect, root.to_s, mixins, keys, prefer, within, listeners, extra, file ]
379
985
  if memo_key && (consulted, result = cache[memo_key])
380
- return fresh(result) if consulted.empty? || !consulted.intersect?(Run.merge_calls({}, calls&.call).keys.to_set)
986
+ return fresh(result) if consulted.empty? || !consulted.intersect?(Run.merge_calls({}, calls&.sites_by_name).keys.to_set)
381
987
  end
382
988
 
383
989
  # Resolved once per call and held by the run: the configured paths
@@ -385,32 +991,32 @@ module RailsAiContext
385
991
  # hook.
386
992
  dirs = ConcernPaths.ordered_dirs(root.to_s, prefer)
387
993
  run = Run.new(root.to_s, dirs, keys, cache, listeners, calls, extra, file)
388
- walked = Array(mixins).select { |mixin| mixin[:ancestor] } + extra.map { |mixin| { name: mixin.name, macro: mixin.macro } }
389
994
  run.walk(walked, within, MAX_DEPTH)
390
995
  depends_on_calls = run.calls_any_consulted?
391
- # An `included do` can call a method whose macros an earlier concern held back,
392
- # so walk again with the known calls until nothing held back is called.
996
+ # An `included do` can call a method a concern read before the block did, so walk
997
+ # again with the known calls until no method the walk asked about has a new call.
998
+ known = {}
393
999
  MAX_DEPTH.times do
394
- break unless run.skipped_methods.intersect?(run.included_calls.keys.to_set)
1000
+ asked = run.skipped_methods | run.consulted
1001
+ break if run.included_calls.none? { |name, sites| asked.include?(name) && (sites.map(&:__id__) - Array(known[name]).map(&:__id__)).any? }
395
1002
 
396
1003
  depends_on_calls = true
397
-
398
1004
  known = run.included_calls
399
- run = Run.new(root.to_s, dirs, keys, cache, listeners, -> { Run.merge_calls(Run.merge_calls({}, calls&.call), known) },
400
- extra, file)
1005
+ run = Run.new(root.to_s, dirs, keys, cache, listeners, calls, extra, file, known: known)
401
1006
  run.walk(walked, within, MAX_DEPTH)
402
1007
  end
403
1008
 
404
- result = [ run.collected, run.unresolved, run.hidden, run.included_calls, run.placement, run.skipped_methods ]
1009
+ result = [ run.collected, run.unresolved, run.hidden, run.included_calls, run.placement, run.skipped_methods,
1010
+ run.block_sites, run.mixins ]
405
1011
  cache[memo_key] = [ run.consulted, fresh(result) ] if memo_key && !depends_on_calls
406
1012
  result
407
1013
  end
408
1014
 
409
1015
  # A copy a caller may change without changing the cached walk.
410
1016
  def fresh(result)
411
- collected, unresolved, hidden, included, placement, skipped = result
1017
+ collected, unresolved, hidden, included, placement, skipped, blocks, mixins = result
412
1018
  [ collected.transform_values { |entries| entries.map { |entry| entry.is_a?(Hash) ? entry.dup : entry } },
413
- unresolved.dup, hidden.dup, included.transform_values(&:dup), placement.dup, skipped.dup ]
1019
+ unresolved.dup, hidden.dup, included.transform_values(&:dup), placement.dup, skipped.dup, blocks.dup, mixins.dup ]
414
1020
  end
415
1021
  private_class_method :fresh
416
1022
  end