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
@@ -14,6 +14,8 @@ module RailsAiContext
14
14
  include: %w[included append_features], prepend: %w[prepended prepend_features], extend: %w[extended]
15
15
  }.freeze
16
16
  MIXIN_HOOKS = HOOKS_BY_MACRO.values.flatten.freeze
17
+ # The macros that give the class a module's methods as class methods.
18
+ SINGLETON_MACROS = %i[extend singleton_include singleton_prepend].freeze
17
19
  # The block ActiveSupport::Concern runs for each.
18
20
  CONCERN_BLOCKS = { include: :included, prepend: :prepended }.freeze
19
21
  FRAMEWORK_PREFIXES = %w[
@@ -60,12 +62,12 @@ module RailsAiContext
60
62
 
61
63
  # Matched on the last segment: `module Edition; module Featurable` and
62
64
  # `module Edition::Featurable` both write one constant.
63
- def own_mixins(mixins, name)
64
- return Array(mixins) if Array(mixins).none? { |mixin| mixin.key?(:owner) }
65
+ def owned_by(records, name)
66
+ return Array(records) if Array(records).none? { |record| record.key?(:owner) }
65
67
 
66
- own = own_owner(mixins, name)
68
+ own = own_owner(records, name)
67
69
  # `Other.include X` written in the body mixes into Other.
68
- Array(mixins).select { |mixin| own && !mixin[:receiver] && Array(mixin[:owner]).join("::") == own }
70
+ Array(records).select { |record| own && !record[:receiver] && Array(record[:owner]).join("::") == own }
69
71
  end
70
72
 
71
73
  def own_owner(records, name)
@@ -133,7 +133,34 @@ module RailsAiContext
133
133
  # bare `include DebugConcern` inside Fasp::Provider resolves at runtime
134
134
  # to Fasp::Provider::DebugConcern, which the literal spelling misses.
135
135
  # @param dirs [Array<String>, nil] pre-resolved concern directories
136
- def find_file(root, concern_name, prefer: nil, within: nil, dirs: nil)
136
+ # @param outer [Boolean] whether a module with no file of its own is found in its outer constant's file
137
+ def find_file(root, concern_name, prefer: nil, within: nil, dirs: nil, outer: true)
138
+ found = find_named(root, concern_name, prefer: prefer, within: within, dirs: dirs)
139
+ (found || (outer_named(root, concern_name, within: within, dirs: dirs) if outer))&.last
140
+ end
141
+
142
+ # The source of the module a reference resolves to: its file, or its own node in its outer constant's file.
143
+ def module_source(root, concern_name, prefer: nil, within: nil)
144
+ path = find_file(root, concern_name, prefer: prefer, within: within, outer: false)
145
+ return SafeFile.read(path) if path
146
+
147
+ name, path = outer_named(root, concern_name, within: within)
148
+ path && Introspectors::DeclaredConstant.module_node(AstCache.parse(path).value, name)&.slice
149
+ end
150
+
151
+ # [`Outer::Inner`, Outer's file] for a module with no file of its own, which Zeitwerk loads with Outer.
152
+ def outer_named(root, concern_name, within: nil, dirs: nil)
153
+ RunCache.fetch([ :outer_named, root, concern_name, within, dirs ]) do
154
+ candidate_names(concern_name, within).lazy.filter_map do |name|
155
+ outer = name.rpartition("::").first
156
+ path = !outer.empty? && find_file(root, outer, dirs: dirs)
157
+ [ name, path ] if path && Introspectors::DeclaredConstant.module_node(AstCache.parse(path).value, name)
158
+ end.first
159
+ end
160
+ end
161
+
162
+ # [the constant the reference resolves to, its file] for `find_file`'s file, or nil.
163
+ def find_named(root, concern_name, prefer: nil, within: nil, dirs: nil)
137
164
  # Wherever Zeitwerk would look, plus an in-repo path gem's lib. A lib the app does
138
165
  # not add to its autoload paths holds nothing it autoloads, so it is skipped.
139
166
  dirs = ordered_dirs(root, prefer, dirs) + PathResolver.app_roots(root) + PathResolver.declared_roots(root) +
@@ -144,8 +171,7 @@ module RailsAiContext
144
171
  next if underscore.empty? || underscore.include?("..")
145
172
 
146
173
  found = dirs.find { |dir| file_exist?(dir, "#{underscore}.rb") }
147
- path = found && File.join(found, "#{underscore}.rb")
148
- return path if path
174
+ return [ name, File.join(found, "#{underscore}.rb") ] if found
149
175
  end
150
176
 
151
177
  nil
@@ -387,8 +387,8 @@ module RailsAiContext
387
387
  @server_name = "rails-ai-context"
388
388
  @introspectors = PRESETS[:full].dup
389
389
  @excluded_paths = %w[node_modules tmp log vendor .git doc docs]
390
- # Files each tool's own docs say to gitignore. The patterns leave the placeholder
391
- # beside one (config/application.example.yml, .env.example) readable.
390
+ # Files each tool's own docs say to gitignore. A placeholder beside one
391
+ # (config/application.example.yml, .env.example) stays readable: see SafePath.sensitive?.
392
392
  @sensitive_patterns = %w[
393
393
  .env .env.* *.env .envrc
394
394
  config/master.key
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
-
5
3
  module RailsAiContext
6
4
  # git's ignore-file rules (globs, anchoring, last match wins, negation) for
7
5
  # the doctor, and ripgrep's walk over a tree for the search fallback.
@@ -147,7 +145,7 @@ module RailsAiContext
147
145
  def global
148
146
  return @global if defined?(@global)
149
147
 
150
- @global = source(@root, GitIgnore.global_excludes_path(@root))
148
+ @global = source(@root, GitIgnore.global_excludes_path)
151
149
  end
152
150
  end
153
151
 
@@ -169,15 +167,19 @@ module RailsAiContext
169
167
  nil
170
168
  end
171
169
 
172
- # `core.excludesFile` as git resolves it from the app's directory, or the
173
- # default git uses when it is unset.
174
- def global_excludes_path(root)
175
- configured, status = Open3.capture2("git", "config", "--get", "core.excludesFile", chdir: root)
176
- configured = configured.to_s.strip
177
- return File.expand_path(configured) if status.success? && !configured.empty?
178
-
170
+ # `core.excludesFile` as ripgrep 15 finds it: ~/.gitconfig, then the XDG
171
+ # git config, then git's default (ignore crate `gitconfig_excludes_path`).
172
+ def global_excludes_path
179
173
  xdg = ENV["XDG_CONFIG_HOME"].to_s
180
- File.join(xdg.empty? ? File.join(Dir.home, ".config") : xdg, "git", "ignore")
174
+ config_dir = xdg.empty? ? File.join(Dir.home, ".config") : xdg
175
+ # A setting that is not UTF-8 is skipped, as ripgrep skips it.
176
+ configured = [ File.join(Dir.home, ".gitconfig"), File.join(config_dir, "git", "config") ].lazy.filter_map do |config|
177
+ path = File.file?(config) && File.binread(config)[/^\s*excludesfile\s*=\s*"?\s*(\S+?)\s*"?\s*$/i, 1]
178
+ path && path.force_encoding(Encoding::UTF_8).valid_encoding? ? path : nil
179
+ end.first
180
+ return File.expand_path(configured.gsub("~", Dir.home)) if configured
181
+
182
+ File.join(config_dir, "git", "ignore")
181
183
  rescue StandardError
182
184
  nil
183
185
  end
@@ -83,7 +83,7 @@ module RailsAiContext
83
83
  # the actions its class macros build with define_method, and an unread
84
84
  # one only hides class methods.
85
85
  def read_modules(source, owner, root, names, unread, depth)
86
- mixins = ConcernMembership.own_mixins(
86
+ mixins = ConcernMembership.owned_by(
87
87
  SourceIntrospector.walk_source(source, { mixins: Listeners::MixinsListener })[:mixins], owner
88
88
  )
89
89
  mixins.select { |m| m[:ancestor] || (m[:macro] == :extend && !m[:receiver]) }.uniq { |m| m[:name] }.each do |mixin|
@@ -104,8 +104,10 @@ module RailsAiContext
104
104
  end
105
105
 
106
106
  def module_source(root, mod, owner)
107
- path = ConcernPaths.find_file(root.to_s, mod, within: owner) ||
108
- SuperclassChain.resolve_in_scope(owner, mod) { |c| PathResolver.file_for_constant(root.to_s, c) }
107
+ source = ConcernPaths.module_source(root.to_s, mod, within: owner)
108
+ return source if source
109
+
110
+ path = SuperclassChain.resolve_in_scope(owner, mod) { |c| PathResolver.file_for_constant(root.to_s, c) }
109
111
  path && SafeFile.read(path)
110
112
  end
111
113
 
@@ -197,7 +197,8 @@ module RailsAiContext
197
197
  end
198
198
 
199
199
  def scanned_body(lines, method_name)
200
- start_idx = lines.index { |l| l.match?(/^\s*def\s+#{Regexp.escape(method_name.to_s)}\b/i) }
200
+ name_end = RailsAiContext::MethodName.definition_end(method_name)
201
+ start_idx = lines.index { |l| l.match?(/^\s*def\s+#{Regexp.escape(method_name.to_s)}#{name_end}/i) }
201
202
  return nil unless start_idx
202
203
 
203
204
  indent = lines[start_idx][/\A\s*/].length
@@ -12,7 +12,8 @@ module RailsAiContext
12
12
  UNKNOWN = Object.new.freeze
13
13
  # `pairs` is set for a literal hash: its `key: value` text, which reads
14
14
  # as keyword arguments where the parameter is passed as the last one.
15
- Binding = Struct.new(:value, :source, :pairs, :value_sources)
15
+ # `items` is set for a list of literals: each one's source.
16
+ Binding = Struct.new(:value, :source, :pairs, :value_sources, :items)
16
17
 
17
18
  # The rewritten body, and for each of its lines the line of the mixin's
18
19
  # file it came from, so what the body declares keeps its real location.
@@ -62,7 +63,7 @@ module RailsAiContext
62
63
  def entries(definition, call, listeners)
63
64
  return {} unless definition.body
64
65
 
65
- bindings = unwritten(bind(definition.parameters, call), definition.body)
66
+ bindings = rest_bindings(unwritten(bind(definition.parameters, call), definition.body), definition, call)
66
67
  out = Output.new
67
68
  undecided = []
68
69
  emit(definition.body, bindings, out, undecided, 0, [])
@@ -79,6 +80,16 @@ module RailsAiContext
79
80
  [ key, kept ]
80
81
  end
81
82
  end
83
+ unbound = bindings.filter_map { |name, binding| name if name.is_a?(Symbol) && binding.source.nil? }
84
+ if unbound.any?
85
+ data = data.transform_values do |found|
86
+ Array(found).reject do |entry|
87
+ held = names_unbound?(entry, out, unbound)
88
+ conditional << declaration(entry, out, []) if held
89
+ held
90
+ end
91
+ end
92
+ end
82
93
  data = data.transform_values { |found| Array(found).map { |entry| relocated(entry, out) } }
83
94
  data.merge(conditional: conditional, foreign: foreign_entries(out, listeners))
84
95
  end
@@ -128,14 +139,30 @@ module RailsAiContext
128
139
  # The call written at the entry's line, less any block, with the
129
140
  # conditions it runs under.
130
141
  def declaration(entry, out, conditions)
131
- call = AstWalk.each(AstCache.parse_string(out.text).value).find do |node|
132
- node.is_a?(Prism::CallNode) && node.location.start_line == entry[:location]
133
- end
142
+ call = call_at(out, entry[:location])
134
143
  { declaration: call ? call_source(call) : out.text.lines[entry[:location] - 1].to_s.strip,
135
144
  condition: (conditions.join(" and ") if conditions.any?),
136
145
  location: out.source_line(entry[:location]) || entry[:location] }.compact
137
146
  end
138
147
 
148
+ def call_at(out, line)
149
+ AstWalk.each(AstCache.parse_string(out.text).value).find do |node|
150
+ node.is_a?(Prism::CallNode) && node.location.start_line == line
151
+ end
152
+ end
153
+
154
+ # A parameter the expansion cannot bind is written as its bare name, which a
155
+ # listener would read as what the call declares.
156
+ def names_unbound?(entry, out, names)
157
+ return false unless entry.is_a?(Hash) && entry[:location]
158
+
159
+ Array(call_at(out, entry[:location])&.arguments&.arguments).any? do |argument|
160
+ argument = argument.expression if argument.is_a?(Prism::SplatNode)
161
+ (argument.is_a?(Prism::LocalVariableReadNode) || (argument.is_a?(Prism::CallNode) && argument.variable_call?)) &&
162
+ names.include?(argument.name)
163
+ end
164
+ end
165
+
139
166
  def call_source(call)
140
167
  text = call.slice
141
168
  text = text.byteslice(0, call.block.location.start_offset - call.location.start_offset) if call.block.is_a?(Prism::BlockNode)
@@ -197,19 +224,118 @@ module RailsAiContext
197
224
  Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode
198
225
  ].freeze
199
226
 
200
- # A parameter the body assigns, in a block too, no longer holds the
201
- # call's argument wherever it is read, so it is bound to nothing.
227
+ # A parameter the body assigns or changes in place, in a block too, no
228
+ # longer holds the call's argument wherever it is read, so it is bound to nothing.
202
229
  def unwritten(bindings, body)
230
+ changed(body).each { |name| bindings[name] = unknown if bindings.key?(name) }
231
+ bindings
232
+ end
233
+
234
+ # `local[key] ||= v`, `&&=` and `+=` change the local in place too.
235
+ INDEX_WRITES = [ Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode ].freeze
236
+
237
+ # Methods that change their receiver in place.
238
+ MUTATORS = %i[<< []= push append unshift prepend insert concat pop shift delete delete_at delete_if keep_if
239
+ clear replace store update fill].freeze
240
+
241
+ # Readers that return an object the receiver holds, not a copy.
242
+ HELD_READERS = %i[[] fetch dig].freeze
243
+
244
+ # The method's own locals the body writes or changes in place, outside the `kept` statements.
245
+ def changed(body, kept = [])
246
+ found = Set.new
203
247
  stack = [ [ body, 0 ] ]
204
248
  until stack.empty?
205
249
  node, blocks = stack.pop
206
- bindings[node.name] = unknown if WRITES.include?(node.class) && node.depth == blocks && bindings.key?(node.name)
250
+ next if kept.any? { |statement| statement.equal?(node) }
251
+
252
+ found << node.name if WRITES.include?(node.class) && node.depth == blocks
253
+ receiver = node.receiver if INDEX_WRITES.include?(node.class) ||
254
+ (node.is_a?(Prism::CallNode) && (MUTATORS.include?(node.name) || node.name.match?(/\w!\z/)))
255
+ # `options[:a][:b] = v` changes what `options` holds; `options.dup[:a] = v` changes a copy.
256
+ receiver = receiver.receiver while receiver.is_a?(Prism::CallNode) && HELD_READERS.include?(receiver.name)
257
+ found << receiver.name if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.depth == blocks
207
258
  blocks += 1 if node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
208
259
  stack.concat(node.compact_child_nodes.map { |child| [ child, blocks ] })
209
260
  end
261
+ found
262
+ end
263
+
264
+ # `*args` holds the call's positionals past the other parameters, less a trailing hash the body takes
265
+ # (`extract_options!`), plus literals the leading statements push; a local changed elsewhere is unknown.
266
+ def rest_bindings(bindings, definition, call)
267
+ rest = definition.parameters&.rest
268
+ return bindings unless call && rest.respond_to?(:name) && rest.name && definition.body.is_a?(Prism::StatementsNode)
269
+
270
+ given = rest_arguments(definition.parameters, call)
271
+ taken = [ "#{rest.name}.extract_options!", "#{rest.name}.last.is_a?(Hash)?#{rest.name}.pop:{}" ]
272
+ kept = []
273
+ list = given
274
+ leading = true
275
+ definition.body.body.each do |node|
276
+ if node.is_a?(Prism::LocalVariableWriteNode) && taken.include?(node.value.slice.delete(" "))
277
+ last = given&.last
278
+ bindings[node.name] =
279
+ if given.nil? then unknown
280
+ elsif symbol_keyed?(last) then literal(last)
281
+ elsif last.nil? || literal_source?(last) then hash_binding({}, {})
282
+ else unknown
283
+ end
284
+ list = leading && list && (symbol_keyed?(list.last) ? list[0...-1] : list)
285
+ kept << node
286
+ elsif leading && (grown = pushed(node, rest.name, list, bindings))
287
+ list = grown
288
+ kept << node
289
+ else
290
+ leading = false
291
+ end
292
+ end
293
+ changed_names = changed(definition.body, kept)
294
+ kept.each { |node| bindings[node.name] = unknown if node.is_a?(Prism::LocalVariableWriteNode) && changed_names.include?(node.name) }
295
+ list = nil if !list || changed_names.include?(rest.name) || list.any? { |item| !literal_source?(item) }
296
+ bindings[rest.name] = list ? list_binding(list) : unknown
210
297
  bindings
211
298
  end
212
299
 
300
+ # The call's arguments `*rest` takes, nil when they cannot be told (a splat, too few).
301
+ def rest_arguments(parameters, call)
302
+ arguments = Array(call.arguments&.arguments)
303
+ return nil if arguments.any? { |argument| argument.is_a?(Prism::SplatNode) }
304
+
305
+ takes_keywords = parameters.keywords.any? || parameters.keyword_rest
306
+ arguments = arguments[0...-1] if takes_keywords && arguments.last.is_a?(Prism::KeywordHashNode)
307
+ before = parameters.requireds.size
308
+ after = parameters.posts.size
309
+ return nil if arguments.size < before + after
310
+
311
+ before += [ parameters.optionals.size, arguments.size - before - after ].min
312
+ arguments[before...(arguments.size - after)]
313
+ end
314
+
315
+ # The list `args << :x` or `args.push(:x)` leaves, under a modifier `if` the bindings decide; nil for any other statement.
316
+ def pushed(node, name, list, bindings)
317
+ return nil unless list
318
+
319
+ if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
320
+ return nil if node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
321
+ return nil unless node.statements&.body&.one? && (grown = pushed(node.statements.body.first, name, list, bindings))
322
+
323
+ truth = truth_of(evaluate(node.predicate, bindings.merge(name => list_binding(list)), 0))
324
+ return nil if truth == UNKNOWN
325
+
326
+ return (node.is_a?(Prism::UnlessNode) ? !truth : truth) ? grown : list
327
+ end
328
+ return nil unless node.is_a?(Prism::CallNode) && %i[<< push].include?(node.name) && node.block.nil?
329
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name == name
330
+
331
+ items = Array(node.arguments&.arguments)
332
+ list + items if items.all? { |item| literal_source?(item) }
333
+ end
334
+
335
+ def list_binding(items)
336
+ Binding.new(items.map { |item| value_of(item) }, "[#{items.map(&:slice).join(", ")}]", nil, nil, items.map(&:slice))
337
+ end
338
+
213
339
  # A hash with symbol keys binds by its pairs' own source, whatever
214
340
  # the values are: `length: { in: 4..Proposal.max }` stands in for itself.
215
341
  def literal(node)
@@ -270,9 +396,14 @@ module RailsAiContext
270
396
  def emit(node, bindings, out, undecided, depth, conditions)
271
397
  case node
272
398
  when Prism::LocalVariableReadNode
273
- source = node.depth == depth && bindings[node.name]&.source
399
+ source = bound(bindings, node, depth)&.source
274
400
  return out.append(source, node.location.start_line) if source
401
+ when Prism::InterpolatedSymbolNode, Prism::InterpolatedStringNode
402
+ folded = !heredoc?(node) && interpolated(node, bindings, depth)
403
+ return out.append(folded.inspect, node.location.start_line) if folded
275
404
  when Prism::CallNode
405
+ return emit_each(node, bindings, out, undecided, depth, conditions) if unrolled?(node, bindings, depth)
406
+
276
407
  looked_up = hash_lookup(node, bindings, depth)
277
408
  return out.append(looked_up, node.location.start_line) if looked_up
278
409
  when Prism::IfNode, Prism::UnlessNode
@@ -310,13 +441,57 @@ module RailsAiContext
310
441
  out.append(text.byteslice(position, text.bytesize - position), line_at.call(position))
311
442
  end
312
443
 
444
+ # The binding a read reaches: the method's parameter, or the parameter of a block written once per item.
445
+ def bound(bindings, node, depth)
446
+ level = depth - node.depth
447
+ level.zero? ? bindings[node.name] : bindings[[ node.name, level ]]
448
+ end
449
+
450
+ # `"#{field}_changed?"` with every part a literal or a bound literal, as the value it makes.
451
+ def interpolated(node, bindings, depth)
452
+ text = node.parts.map do |part|
453
+ next part.unescaped if part.is_a?(Prism::StringNode)
454
+
455
+ statements = part.is_a?(Prism::EmbeddedStatementsNode) && part.statements&.body
456
+ return nil unless statements&.one?
457
+
458
+ inner = statements.first
459
+ value = inner.is_a?(Prism::LocalVariableReadNode) ? bound(bindings, inner, depth)&.value : value_of(inner)
460
+ return nil if value.nil? || value == UNKNOWN || value.is_a?(Hash) || value.is_a?(Array)
461
+
462
+ value.to_s
463
+ end.join
464
+ node.is_a?(Prism::InterpolatedSymbolNode) ? text.to_sym : text
465
+ end
466
+
467
+ # `args.each do |field| ... end` over a bound list of literals.
468
+ def unrolled?(node, bindings, depth)
469
+ receiver = node.receiver
470
+ return false unless node.name == :each && node.arguments.nil? && node.block.is_a?(Prism::BlockNode) && node.block.body
471
+ return false unless receiver.is_a?(Prism::LocalVariableReadNode) && bound(bindings, receiver, depth)&.items
472
+
473
+ params = node.block.parameters&.parameters
474
+ params && params.requireds.one? && params.requireds.first.respond_to?(:name) && params.optionals.empty? && params.rest.nil? && params.posts.empty?
475
+ end
476
+
477
+ # The block's body written once per item, its parameter bound to that item.
478
+ def emit_each(node, bindings, out, undecided, depth, conditions)
479
+ name = node.block.parameters.parameters.requireds.first.name
480
+ binding = bound(bindings, node.receiver, depth)
481
+ binding.items.zip(binding.value).each do |source, value|
482
+ emit(node.block.body, bindings.merge([ name, depth + 1 ] => Binding.new(value, source)), out, undecided, depth + 1, conditions)
483
+ out.append("\n", node.location.end_line)
484
+ end
485
+ out
486
+ end
487
+
313
488
  # A call's arguments, each written from its own node and joined with a
314
489
  # comma: an argument that stands for no keywords at all (an options hash
315
490
  # left at `{}`) is left out whole, with no separator to dangle.
316
491
  def emit_arguments(node, bindings, out, undecided, depth, conditions)
317
492
  written = false
318
493
  node.arguments.each do |argument|
319
- pairs = keyword_argument(node, argument, bindings, depth)
494
+ pairs = keyword_argument(node, argument, bindings, depth) || splatted_items(argument, bindings, depth)
320
495
  next if pairs&.empty?
321
496
 
322
497
  out.append(", ", argument.location.start_line) if written
@@ -326,12 +501,19 @@ module RailsAiContext
326
501
  out
327
502
  end
328
503
 
504
+ # `*names` over a bound list of literals is its items.
505
+ def splatted_items(argument, bindings, depth)
506
+ return unless argument.is_a?(Prism::SplatNode) && argument.expression.is_a?(Prism::LocalVariableReadNode)
507
+
508
+ bound(bindings, argument.expression, depth)&.items&.join(", ")
509
+ end
510
+
329
511
  # `options[:length]` on a hash parameter the call passed as a literal:
330
512
  # the value's own source, or `nil` for a key the call left out.
331
513
  def hash_lookup(node, bindings, depth)
332
- return unless node.name == :[] && node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.depth == depth
514
+ return unless node.name == :[] && node.receiver.is_a?(Prism::LocalVariableReadNode)
333
515
 
334
- sources = bindings[node.receiver.name]&.value_sources
516
+ sources = bound(bindings, node.receiver, depth)&.value_sources
335
517
  key = Array(node.arguments&.arguments)
336
518
  return unless sources && key.one? && key.first.is_a?(Prism::SymbolNode)
337
519
 
@@ -350,9 +532,7 @@ module RailsAiContext
350
532
  # `slice`, `except` or a `reject { |key| key == :x }` on it. Anything else
351
533
  # on it is not read, and the expression stays as written.
352
534
  def hash_expression(node, bindings, depth)
353
- if node.is_a?(Prism::LocalVariableReadNode)
354
- return node.depth == depth ? bindings[node.name]&.then { |b| b if b.value_sources } : nil
355
- end
535
+ return bound(bindings, node, depth)&.then { |b| b if b.value_sources } if node.is_a?(Prism::LocalVariableReadNode)
356
536
  return unless node.is_a?(Prism::CallNode) && node.receiver
357
537
 
358
538
  base = hash_expression(node.receiver, bindings, depth) or return
@@ -363,7 +543,7 @@ module RailsAiContext
363
543
  when :merge
364
544
  return unless arguments.one? && symbol_keyed?(arguments.first) && node.block.nil?
365
545
 
366
- added = arguments.first.elements.to_h { |pair| [ key_of(pair), pair.value.slice ] }
546
+ added = arguments.first.elements.to_h { |pair| [ key_of(pair), emit(pair.value, bindings, Output.new, [], depth, []).text ] }
367
547
  hash_binding(UNKNOWN, sources.merge(added))
368
548
  when :slice then keys && hash_binding(UNKNOWN, sources.slice(*keys))
369
549
  when :except then keys && hash_binding(UNKNOWN, sources.except(*keys))
@@ -465,7 +645,7 @@ module RailsAiContext
465
645
  def evaluate(node, bindings, depth)
466
646
  case node
467
647
  when Prism::LocalVariableReadNode
468
- node.depth == depth ? bindings.fetch(node.name, unknown).value : UNKNOWN
648
+ (bound(bindings, node, depth) || unknown).value
469
649
  when Prism::ParenthesesNode
470
650
  body = node.body&.body
471
651
  body&.one? ? evaluate(body.first, bindings, depth) : UNKNOWN
@@ -502,7 +682,7 @@ module RailsAiContext
502
682
  when :[] then receiver.is_a?(Hash) ? receiver[arguments.first] : UNKNOWN
503
683
  when :key?, :has_key?, :include? then receiver.is_a?(Hash) ? receiver.key?(arguments.first) : UNKNOWN
504
684
  when :many?, :any?, :empty?, :size, :length
505
- return UNKNOWN unless receiver.is_a?(Hash) && arguments.empty? && !node.block
685
+ return UNKNOWN unless (receiver.is_a?(Hash) || receiver.is_a?(Array)) && arguments.empty? && !node.block
506
686
 
507
687
  node.name == :many? ? receiver.size > 1 : receiver.public_send(node.name)
508
688
  when :fetch
@@ -290,10 +290,10 @@ module RailsAiContext
290
290
  # would have Rails dispatch to; the routes decide which are actions.
291
291
  def concern_actions(concerns, within, filters)
292
292
  Array(concerns).flat_map do |concern|
293
- path = ConcernPaths.find_file(app.root.to_s, concern.to_s, prefer: "controller", within: within)
294
- next [] unless path
293
+ source = ConcernPaths.module_source(app.root.to_s, concern.to_s, prefer: "controller", within: within)
294
+ next [] unless source
295
295
 
296
- offered = (@concern_actions ||= {})[path] ||= (source = SafeFile.read(path)) ? ActionResolver.module_actions(source) : []
296
+ offered = (@concern_actions ||= {})[source] ||= ActionResolver.module_actions(source)
297
297
  ActionResolver.deliverable_actions(offered, filter_names(filters))
298
298
  end.uniq
299
299
  rescue => e
@@ -157,17 +157,26 @@ module RailsAiContext
157
157
  # @param root [Prism::Node] the file's parse tree
158
158
  # @return [Prism::ModuleNode, nil]
159
159
  def module_node(root, name)
160
- constants(root).find { |qualified, node| node.is_a?(Prism::ModuleNode) && qualified == name }&.[](1)
160
+ module_nodes(root)[name]
161
161
  end
162
162
 
163
- # Every module the tree declares, by qualified name, the first one
164
- # `module_node` would find for each: one walk for many lookups.
163
+ # Every module the tree declares, by qualified name: the first that is
164
+ # not a stub, as a namespace stub above the definition is not it.
165
165
  #
166
166
  # @return [Hash{String => Prism::ModuleNode}]
167
167
  def module_nodes(root)
168
- constants(root).each_with_object({}) do |(qualified, node), found|
169
- found[qualified] ||= node if node.is_a?(Prism::ModuleNode)
170
- end
168
+ constants(root).select { |_, node| node.is_a?(Prism::ModuleNode) }
169
+ .group_by(&:first).transform_values { |found| definition(found.map { |_, node| node }) }
170
+ end
171
+
172
+ # Of the nodes writing one constant, the one defining it: the first that is not a stub.
173
+ def definition(nodes)
174
+ nodes.find { |node| !stub?(node) } || nodes.first
175
+ end
176
+
177
+ # A class or module holding nothing but such stubs: `module Acts; module Journalized; end; end`.
178
+ def stub?(node)
179
+ Array(node.body&.compact_child_nodes).all? { |child| (child.is_a?(Prism::ModuleNode) || child.is_a?(Prism::ClassNode)) && stub?(child) }
171
180
  end
172
181
 
173
182
  # Each class and module the tree declares, in source order: its
@@ -77,12 +77,15 @@ module RailsAiContext
77
77
  end
78
78
 
79
79
  queue = job.queue_name
80
- # ActiveJob defaults queue_name to a lambda; any other Proc is a
81
- # queue_as block, and only the source says what it computes.
80
+ # The default is a lambda; another Proc is a queue_as block, and a
81
+ # Proc argument is stored as its inspect string, with the line that set it.
82
82
  if queue.equal?(ActiveJob::Base.queue_name)
83
83
  queue = job.queue_name_from_part(nil)
84
84
  elsif queue.is_a?(Proc)
85
- queue = (job_candidates.key?(job.name) && inherited_queue(job.name)) || COMPUTED_QUEUE
85
+ queue = labelled(COMPUTED_QUEUE, queue.source_location && block_at(*queue.source_location))
86
+ elsif queue.to_s.include?("#<Proc:")
87
+ location = queue.match(/#<Proc:0x\h+ (.+):(\d+)(?: \(lambda\))?>/)
88
+ queue = labelled(PROC_QUEUE, location && proc_at(location[1], location[2].to_i))
86
89
  end
87
90
 
88
91
  {
@@ -139,7 +142,39 @@ module RailsAiContext
139
142
  end
140
143
 
141
144
  def inherited_queue(name)
142
- chain_of(name).lazy.filter_map { |link| queue_as(job_candidates[link].ast[:macros]) }.first
145
+ chain_of(name).lazy.filter_map { |link| queue_as(job_candidates[link].ast) }.first
146
+ end
147
+
148
+ def labelled(label, source)
149
+ source ? "#{label}: `#{source}`" : label
150
+ end
151
+
152
+ def block_at(file, line)
153
+ queue_as_walk(file)&.dig(:macros)&.find { |m| m[:macro] == :queue_as && m[:location] == line && m[:block] }&.dig(:block)
154
+ end
155
+
156
+ # Only one Proc literal on the line says which one it was.
157
+ def proc_at(file, line)
158
+ found = queue_as_walk(file)&.dig(:procs)&.select { |literal| literal[:line] == line } || []
159
+ found.first[:source] if found.one?
160
+ end
161
+
162
+ # Only the app's own files are read; a job file reuses the walk that found it.
163
+ def queue_as_walk(file)
164
+ @queue_as_walks ||= {}
165
+ @queue_as_walks.fetch(file) do
166
+ @queue_as_walks[file] = begin
167
+ real = File.realpath(file)
168
+ root = app.root.to_s
169
+ if SourceScan.under_root?(file, real, root, app_root_real)
170
+ relative = SourceScan.relative_file(file, real, root, app_root_real)
171
+ job_candidates.values.find { |candidate| candidate.file == relative }&.ast ||
172
+ (source = SafeFile.read(file)) && SourceIntrospector.walk_source(source, QUEUE_AS_LISTENERS)
173
+ end
174
+ rescue SystemCallError
175
+ nil
176
+ end
177
+ end
143
178
  end
144
179
 
145
180
  # This class and its ancestors among the candidates, nearest first.
@@ -153,17 +188,28 @@ module RailsAiContext
153
188
  end
154
189
 
155
190
  COMPUTED_QUEUE = "computed by a block"
191
+ PROC_QUEUE = "queue_as given a Proc: ActiveJob does not call it, so the queue is named after the Proc's text"
192
+ PROC_LITERAL = /\A(?:->|(?:lambda|proc|Proc\.new)(?![\w.]))/
193
+
194
+ QUEUE_AS_LISTENERS = {
195
+ macros: -> { Listeners::GenericMacroListener.new(*JOB_MACROS, block_source: [ :queue_as ]) },
196
+ procs: Listeners::ProcLiteralListener
197
+ }.freeze
156
198
 
157
199
  # A literal queue by name; one picked at enqueue time by the source that
158
200
  # picks it, the way other computed values read.
159
- def queue_as(macros)
160
- hit = macros.find { |m| m[:macro] == :queue_as } or return nil
201
+ def queue_as(ast)
202
+ hit = ast[:macros].find { |m| m[:macro] == :queue_as } or return nil
161
203
  return hit[:args].first.to_s if hit[:args].any?
204
+ return labelled(COMPUTED_QUEUE, hit[:block]) if hit[:block]
162
205
 
163
206
  source = hit[:values].first.to_s.gsub(/\s+/, " ").strip
164
207
  return COMPUTED_QUEUE if source.empty?
165
208
 
166
- source.start_with?("->", "lambda", "proc") ? "#{COMPUTED_QUEUE}: `#{source}`" : "`#{source}` (computed)"
209
+ assigned = Array(ast[:procs]).find { |literal| literal[:constant] == source }
210
+ return labelled(PROC_QUEUE, assigned[:source]) if assigned
211
+
212
+ source.match?(PROC_LITERAL) ? labelled(PROC_QUEUE, source) : "`#{source}` (computed)"
167
213
  end
168
214
 
169
215
  def sidekiq_options(macros)
@@ -321,11 +367,10 @@ module RailsAiContext
321
367
  declared: declaration.name,
322
368
  declares: declarations.map(&:name),
323
369
  superclass: declaration.superclass,
324
- ast: SourceIntrospector.walk_source(record.source, {
325
- macros: -> { Listeners::GenericMacroListener.new(*JOB_MACROS) },
370
+ ast: SourceIntrospector.walk_source(record.source, QUEUE_AS_LISTENERS.merge(
326
371
  methods: Listeners::MethodsListener,
327
372
  calls: SourceCalls.listener
328
- })
373
+ ))
329
374
  )
330
375
  end
331
376
  end
@@ -322,9 +322,9 @@ module RailsAiContext
322
322
  end
323
323
  end
324
324
 
325
- def confidence_for(node)
326
- RailsAiContext::Confidence.for_node(node)
327
- end
325
+ def confidence_for(node) = self.class.confidence_for(node)
326
+
327
+ def self.confidence_for(node) = RailsAiContext::Confidence.for_node(node)
328
328
  end
329
329
  end
330
330
  end