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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +173 -0
- data/docs/COMPATIBILITY.md +21 -7
- data/docs/CONFIGURATION.md +3 -0
- data/docs/GUIDE.md +7 -1
- data/docs/INTROSPECTORS.md +6 -1
- data/docs/SECURITY.md +23 -1
- data/docs/TOOLS.md +11 -2
- data/lib/rails_ai_context/action_filters.rb +2 -3
- data/lib/rails_ai_context/base_mixins.rb +44 -16
- data/lib/rails_ai_context/concern_macros.rb +698 -92
- data/lib/rails_ai_context/concern_membership.rb +6 -4
- data/lib/rails_ai_context/concern_paths.rb +29 -3
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/introspectors/action_presence.rb +5 -3
- data/lib/rails_ai_context/introspectors/action_resolver.rb +2 -1
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +198 -18
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/declared_constant.rb +15 -6
- data/lib/rails_ai_context/introspectors/job_introspector.rb +55 -10
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +3 -3
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +28 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +3 -1
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +21 -28
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +277 -118
- data/lib/rails_ai_context/introspectors/source_scan.rb +5 -3
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +13 -4
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +104 -21
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +17 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/tools/base_tool.rb +11 -2
- data/lib/rails_ai_context/tools/get_callbacks.rb +14 -6
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_conventions.rb +5 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +8 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +4 -3
- data/lib/rails_ai_context/tools/get_schema.rb +4 -0
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/search_code.rb +64 -22
- data/lib/rails_ai_context/tools/search_docs.rb +4 -5
- data/lib/rails_ai_context/tools/security_scan.rb +3 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +1 -1
- data/server.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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?(
|
|
591
|
+
@hidden << name if path && !@paths.key?(label) && !singleton
|
|
88
592
|
next
|
|
89
593
|
end
|
|
90
594
|
|
|
91
|
-
data = nested ? introspect_nested(
|
|
595
|
+
data = nested ? introspect_nested(source, nested) : path && introspect(path)
|
|
92
596
|
if data.nil?
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
Run.merge_calls(@included_calls,
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
@keys
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 =
|
|
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)
|
|
227
|
-
|
|
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
|
|
789
|
+
def own_node(tree, name)
|
|
245
790
|
short = name.to_s.split("::").last.to_s
|
|
246
|
-
|
|
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
|
|
263
|
-
#
|
|
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,
|
|
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
|
-
|
|
302
|
-
return unless
|
|
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
|
|
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&.
|
|
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
|
-
|
|
340
|
-
|
|
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 [
|
|
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)]
|
|
365
|
-
# collected entries per key, the names whose file could not be read,
|
|
366
|
-
# names `excluded_concerns` hid that the walk would otherwise have
|
|
367
|
-
# the methods `included` blocks call with their call sites, and for
|
|
368
|
-
# concern read the top-level mixin that reached it and its place in
|
|
369
|
-
# order Ruby adds them,
|
|
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
|
-
|
|
374
|
-
|
|
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&.
|
|
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
|
|
392
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|