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
@@ -501,9 +501,10 @@ module RailsAiContext
501
501
  # and the superclasses are merged here too, or the static tier
502
502
  # out-answers this one.
503
503
  bases = booted_declaring_bases(model)
504
- calls = class_calls([ model_source_path(model), *bases.map(&:last) ])
504
+ calls = singleton_lookup([ [ model.name, model_source_path(model) ], *bases ])
505
505
  source_data, unread, bases_unread, hidden =
506
- merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model))
506
+ merge_class_and_bases(own_source, model.name, calls, bases, file: model_source_path(model),
507
+ commits_in_order: booted_commits_in_order)
507
508
 
508
509
  class_methods = extract_class_methods_from_ast(model, source_data)
509
510
  instance_methods = extract_instance_methods_from_ast(model, source_data)
@@ -659,8 +660,7 @@ module RailsAiContext
659
660
  AstWalk.each(AstCache.parse_string(source).value) do |node|
660
661
  next unless node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::SelfNode)
661
662
 
662
- setting = required_default_literal(node)
663
- return setting unless setting.nil?
663
+ return setting_value(node) if node.name == :belongs_to_required_by_default=
664
664
  end
665
665
  end
666
666
  nil
@@ -708,67 +708,79 @@ module RailsAiContext
708
708
  validator.kind == :presence && validator.options[:message] == :required
709
709
  end
710
710
 
711
- # Rails' rule: an explicit optional: wins, then required:, then the app's
712
- # default. An option the source cannot evaluate makes it conditional.
711
+ # Rails' rule: a required: key sets optional: to its negation, then a nil
712
+ # optional: means the app's default. An unevaluable option makes it conditional.
713
713
  def static_required_belongs_to(associations, default = belongs_to_required_by_default?)
714
714
  Array(associations).select { |a| a.is_a?(Hash) && a[:type].to_s == "belongs_to" }.filter_map do |a|
715
715
  name = a[:name].to_s
716
- optional_value = a.key?(:optional) ? a[:optional] : a.dig(:options, :optional)
717
- required_value = a.dig(:options, :required)
718
- optional = literal_boolean(optional_value)
719
- required = literal_boolean(required_value)
720
- if !optional.nil? then (name unless optional)
721
- elsif !optional_value.nil? then [ name, "optional: #{optional_value} is false" ]
722
- elsif !required.nil? then (name if required)
723
- elsif !required_value.nil? then [ name, "required: #{required_value} is true" ]
716
+ options = a[:options] || {}
717
+ optional_value = a.key?(:optional) ? a[:optional] : options[:optional]
718
+ if options.key?(:required)
719
+ required = literal_boolean(options[:required])
720
+ required.nil? ? [ name, "required: #{options[:required]} is true" ] : (name if required)
721
+ elsif !optional_value.nil?
722
+ optional = literal_boolean(optional_value)
723
+ optional.nil? ? [ name, "optional: #{optional_value} is false" ] : (name unless optional)
724
+ elsif default.is_a?(String) then [ name, default ]
724
725
  elsif default then name
725
726
  end
726
727
  end
727
728
  end
728
729
 
729
730
  def literal_boolean(value)
730
- { true => true, false => false, "true" => true, "false" => false }[value]
731
+ { true => true, false => false, nil => false, "true" => true, "false" => false }[value]
731
732
  end
732
733
 
733
- # The last `belongs_to_required_by_default =` Rails would run, from
734
- # config/application.rb then each initializer in load order, on any
735
- # receiver (`config.active_record`, `ActiveRecord::Base`); without one,
736
- # Rails turns it on from `load_defaults 5.0`.
734
+ # Without a `belongs_to_required_by_default =` of its own, Rails turns it
735
+ # on from `load_defaults 5.0`. True, false, or the condition it holds under.
737
736
  def belongs_to_required_by_default?
738
- return @belongs_to_required if defined?(@belongs_to_required)
739
-
740
- root = app.root.to_s
741
- paths = [ File.join(root, "config", "application.rb"),
742
- *Dir.glob(File.join(root, "config", "initializers", "**", "*.rb")).sort ]
743
- setting = nil
744
- version = nil
745
- paths.each do |path|
746
- source = SafeFile.read(path) or next
747
- AstWalk.each(AstCache.parse_string(source).value) do |node|
748
- next unless node.is_a?(Prism::CallNode)
749
-
750
- if node.name == :belongs_to_required_by_default= && node.receiver
751
- literal = required_default_literal(node)
752
- setting = literal unless literal.nil?
753
- elsif node.name == :load_defaults && path.end_with?("application.rb") && node.receiver&.slice.to_s == "config"
754
- version = defaults_version(node.arguments&.arguments&.first)
737
+ setting = framework_setting(:belongs_to_required_by_default=, since: 5.0)
738
+ setting.assigned ? setting.value : setting.version.to_f >= 5.0
739
+ end
740
+
741
+ # `value` is the deciding assignment's setting_value when `assigned`;
742
+ # else `load_defaults`' `version` decides.
743
+ FrameworkSetting = Struct.new(:assigned, :value, :version, :read, keyword_init: true)
744
+
745
+ # Application.rb then the initializers, as Rails runs them; a `load_defaults`
746
+ # of version `since` or later assigns the setting too, and the last line wins.
747
+ def framework_setting(setter, since:)
748
+ @framework_settings ||= {}
749
+ @framework_settings[setter] ||= begin
750
+ root = app.root.to_s
751
+ application = File.join(root, "config", "application.rb")
752
+ setting = FrameworkSetting.new(assigned: false, read: false)
753
+ [ application, *Dir.glob(File.join(root, "config", "initializers", "**", "*.rb")).sort ].each do |path|
754
+ source = SafeFile.read(path) or next
755
+ setting.read ||= path == application
756
+ AstWalk.each(AstCache.parse_string(source).value) do |node|
757
+ next unless node.is_a?(Prism::CallNode)
758
+
759
+ if node.name == setter && node.receiver
760
+ setting.assigned = true
761
+ setting.value = setting_value(node)
762
+ elsif node.name == :load_defaults && path == application && node.receiver&.slice.to_s == "config"
763
+ setting.version = defaults_version(node.arguments&.arguments&.first)
764
+ setting.assigned = false if setting.version.to_f >= since
765
+ end
755
766
  end
756
767
  end
768
+ setting
757
769
  end
758
- @belongs_to_required = setting.nil? ? version.to_f >= 5.0 : setting
759
770
  end
760
771
 
761
- # The true or false a `belongs_to_required_by_default =` call assigns;
762
- # nil for another call or a value the source cannot evaluate.
763
- def required_default_literal(node)
764
- return nil unless node.name == :belongs_to_required_by_default=
765
-
772
+ # A literal true, false or nil (false, as Rails tests for truth) as itself;
773
+ # any other value reads as the condition "<setting> = <expression> is true".
774
+ def setting_value(node)
766
775
  value = node.arguments&.arguments&.first
767
- { Prism::TrueNode => true, Prism::FalseNode => false }[value.class]
776
+ literal = { Prism::TrueNode => true, Prism::FalseNode => false, Prism::NilNode => false }[value.class]
777
+ return literal unless literal.nil? && value
778
+
779
+ "#{node.name.to_s.delete_suffix('=')} = #{value.slice} is true"
768
780
  end
769
781
 
770
- # A literal version as written; anything else (`Rails::VERSION::STRING.to_f`)
771
- # is the running Rails, which is past 5.0.
782
+ # A literal version as written; anything else (`Rails::VERSION::STRING.to_f`) is the
783
+ # running Rails, Float::INFINITY: on for belongs_to, unknown for the commit order.
772
784
  def defaults_version(arg)
773
785
  case arg
774
786
  when Prism::FloatNode, Prism::IntegerNode then arg.value.to_f
@@ -979,15 +991,15 @@ module RailsAiContext
979
991
 
980
992
  CONDITION_KEYS = %i[if unless].freeze
981
993
 
982
- # One conditions entry per callback occurrence, in step with the name list: a model can
983
- # declare one method twice under different conditions.
994
+ # One conditions entry per callback, in step with the name list: one method can sit under
995
+ # two types with different conditions.
984
996
  def callback_conditions(callbacks)
985
997
  Array(callbacks).each_with_object({}) do |cb, hash|
986
998
  next unless cb.is_a?(Hash) && cb[:type]
987
999
 
988
- # A plain after_commit carries the events its one declaration names.
989
- keys = cb[:type].to_s == "after_commit" ? CONDITION_KEYS + [ :on ] : CONDITION_KEYS
990
- conditions = (cb[:options] || {}).slice(*keys)
1000
+ options = cb[:options] || {}
1001
+ conditions = options.select { |key, _| CONDITION_KEYS.include?(key) }
1002
+ conditions = { on: options[:on] }.merge(conditions) if options.key?(:on) && !Listeners::CallbacksListener.names_event?(cb[:type])
991
1003
  (hash[cb[:type].to_s] ||= []) << (conditions.empty? ? nil : conditions)
992
1004
  end.reject { |_, list| list.none? }
993
1005
  end
@@ -1340,8 +1352,9 @@ module RailsAiContext
1340
1352
  def static_model_details(path, class_name, file: relative_to_root(path), table_name: nil, inherited_from: [],
1341
1353
  sti: nil, parent_model: nil)
1342
1354
  own = own_body(source_walk(path), class_name)
1343
- calls = class_calls([ path, *Array(inherited_from).map(&:last) ])
1344
- data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path)
1355
+ calls = singleton_lookup([ [ class_name, path ], *Array(inherited_from) ])
1356
+ data, unread, bases_unread, hidden = merge_class_and_bases(own, class_name, calls, inherited_from, file: path,
1357
+ commits_in_order: static_commits_in_order)
1345
1358
  own_methods = ActionResolver.own_methods(own[:methods], class_name)
1346
1359
  scope_names = Array(data[:scopes]).filter_map { |scope| scope[:name]&.to_s }.to_set
1347
1360
  static_instance_methods = own_methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }.map { |m| m[:name].to_s }
@@ -1372,6 +1385,7 @@ module RailsAiContext
1372
1385
  concern_callbacks: concern_callbacks(data[:callbacks]),
1373
1386
  concerns_unread: (unread if unread.any?),
1374
1387
  bases_unread: (bases_unread if bases_unread.any?),
1388
+ commit_order_unread: commit_order_unread(data[:callbacks]),
1375
1389
  conditional_declarations: declarations(data[:conditional]),
1376
1390
  foreign_declarations: declarations(data[:foreign]),
1377
1391
  macros: data[:macros],
@@ -1408,36 +1422,68 @@ module RailsAiContext
1408
1422
  # so on the booted tier an unread concern costs the other keys only.
1409
1423
  REFLECTED_CONCERN_KEYS = %i[associations validations enums].freeze
1410
1424
 
1411
- # The mixin names are in the same walk and their files are on disk, so
1412
- # the class's own declarations and its concerns' answer as one. Methods
1413
- # and mixins stay the model's own: those are its interface, not the
1414
- # sum of what it included.
1415
- # The modules mixed into every model from outside, and the app modules
1416
- # the class extends itself with: a macro method one of them defines is
1417
- # read where the class calls it. An extended module whose file is not the
1418
- # app's is a gem's, and a gem's macro methods are not read.
1419
- def class_extras(own, class_name)
1420
- extended = Array(own[:mixins]).filter_map do |mixin|
1421
- next unless mixin[:macro] == :extend && !mixin[:receiver]
1422
- next unless ConcernMembership.candidate?(mixin[:name])
1423
-
1424
- path = ConcernPaths.find_file(app.root.to_s, mixin[:name], prefer: "model", within: class_name)
1425
- BaseMixins::Mixin.new(mixin[:name], path, :extend) if path
1426
- end
1427
- BaseMixins.models(app.root.to_s) + extended
1425
+ # The walk only collects: where a declaration lands waits for every class's walk.
1426
+ Walk = Struct.new(:own, :collected, :unread, :hidden, :skipped, :rank) do
1427
+ def self.empty(own) = new(own, {}, [], [], Set.new, 0)
1428
1428
  end
1429
1429
 
1430
- def merge_concern_macros(own, class_name, calls = nil, extra: [], file: nil)
1431
- collected, unread, hidden, included, _placement, skipped = ConcernMacros.collect(
1430
+ def walk_class(own, class_name, calls, extra: [], file: nil, rank: 0)
1431
+ collected, unread, hidden, included, placement, skipped, blocks, mixed = ConcernMacros.collect(
1432
1432
  app.root.to_s, own[:mixins] || [],
1433
1433
  keys: [ *WALKED_KEYS, :expanded ], prefer: "model", within: class_name,
1434
1434
  cache: @source_cache, calls: calls, extra: extra, file: file
1435
1435
  )
1436
- ConcernMacros::Run.merge_calls(calls.included, included) if calls.respond_to?(:included)
1437
- own = without_expanded_calls(own, collected.delete(:expanded))
1438
- return [ own, unread, hidden, skipped ] if collected.empty?
1436
+ included_at = included_at(own, placement)
1437
+ every = extra.map(&:name).to_set
1438
+ bodies = method_bodies(own)
1439
+ mixins = mixed.map do |label, macro, defs, in_module|
1440
+ line, order = included_at.call(label)
1441
+ added, line = in_module || [ ConcernMacros::SingletonLookup.added_in(rank, line, bodies), line ]
1442
+ ConcernMacros::SingletonLookup::Mixin.new(label, macro, defs, added, [ line, order ], every.include?(placement.dig(label, 0)))
1443
+ end
1444
+ calls.add(rank, included, blocks, mixins)
1445
+ Walk.new(own, collected, unread, hidden, skipped, rank)
1446
+ end
1447
+
1448
+ # Where a concern the walk read joins the class's chain: at the line
1449
+ # including it, after the concerns that include brought in before it.
1450
+ def included_at(own, placement)
1451
+ line_of = Array(own[:mixins]).reverse.to_h { |mixin| [ mixin[:name], mixin[:location].to_i ] }
1452
+ lambda do |name|
1453
+ top, order = placement[name]
1454
+ top ? [ line_of[top].to_i, order.to_i ] : [ 0, 0 ]
1455
+ end
1456
+ end
1439
1457
 
1440
- [ merge_inherited(own, collected), unread, hidden, skipped ]
1458
+ # A walk's declarations where Ruby runs them: one in a method body, the class's own or a
1459
+ # module's, once per call running that body; a concern's where its code runs.
1460
+ def settle(walk, calls, expanded = nil)
1461
+ own, rank = walk.own, walk.rank
1462
+ collected = walk.collected.to_h do |key, entries|
1463
+ [ key, entries.flat_map { |entry| place(entry, rank, calls) }.then { |found| key == :callbacks ? found : found.map { |entry| entry.except(*CHAIN_KEYS) }.uniq } ]
1464
+ end
1465
+ own = without_expanded_calls(own, Array(collected.delete(:expanded)) + Array(expanded))
1466
+ callbacks = Array(own[:callbacks]).flat_map do |cb|
1467
+ method = own_method(own, cb[:location])
1468
+ next [ cb.merge(rank: rank) ] unless method
1469
+ next [] unless method[:scope] == :class
1470
+
1471
+ calls.placed(cb, [ rank, method[:location] ], [ cb[:location] ])
1472
+ end
1473
+ own = own.merge(callbacks: callbacks + Array(collected.delete(:callbacks)))
1474
+ [ collected.empty? ? own : merge_inherited(own, collected), walk.unread, walk.hidden ]
1475
+ end
1476
+
1477
+ def place(entry, rank, calls)
1478
+ entry.is_a?(Hash) ? calls.mixed_in(entry, rank) : [ entry ]
1479
+ end
1480
+
1481
+ def own_method(own, line)
1482
+ ConcernMacros.enclosing(method_bodies(own), line)&.last
1483
+ end
1484
+
1485
+ def method_bodies(own)
1486
+ Array(own[:methods]).filter_map { |m| [ m[:location]..m[:end_location], m ] if m[:location] && m[:end_location] }
1441
1487
  end
1442
1488
 
1443
1489
  # A `validates_translation :title` call the listener read as a validation
@@ -1457,33 +1503,111 @@ module RailsAiContext
1457
1503
  #
1458
1504
  # Each side is walked again only when the other has since learned a call
1459
1505
  # that releases something it held back.
1460
- def merge_class_and_bases(own, class_name, calls, bases, file:)
1461
- extra = class_extras(own, class_name)
1462
- mine = merge_concern_macros(own, class_name, calls, extra: extra, file: file)
1506
+ def merge_class_and_bases(own, class_name, calls, bases, file:, commits_in_order:)
1507
+ extra = BaseMixins.models(app.root.to_s)
1508
+ mine = walk_class(own, class_name, calls, extra: extra, file: file)
1463
1509
  walked = walk_bases(bases, calls)
1464
1510
  ConcernMacros::MAX_DEPTH.times do
1465
- known = Set.new(calls.call.keys)
1466
- break unless mine.last.intersect?(known)
1511
+ known = Set.new(calls.sites_by_name.keys)
1512
+ break unless mine.skipped.intersect?(known)
1467
1513
 
1468
- mine = merge_concern_macros(own, class_name, calls, extra: extra, file: file)
1469
- held = walked.flat_map { |_name, base_own, *rest| base_own ? Array(rest.last).to_a : [] }.to_set
1470
- grown = Set.new(calls.call.keys) - known
1514
+ mine = walk_class(own, class_name, calls, extra: extra, file: file)
1515
+ held = walked.flat_map { |_name, walk| walk ? walk.skipped.to_a : [] }.to_set
1516
+ grown = Set.new(calls.sites_by_name.keys) - known
1471
1517
  walked = walk_bases(bases, calls) if held.intersect?(grown)
1472
1518
  end
1473
- data, unread, hidden = mine
1474
- merge_inherited_macros(data, unread, hidden, walked)
1519
+ declared = base_declarations(calls, [ [ class_name, file ], *bases ])
1520
+ expanded = Array(declared.delete(:expanded)).group_by { |call| call[:rank] }
1521
+ data, unread, hidden = settle(mine, calls, expanded[0])
1522
+ data = merge_inherited(data, declared)
1523
+ settled = walked.map { |name, walk| walk ? [ name, walk.own, *settle(walk, calls, expanded[walk.rank]) ] : [ name, nil ] }
1524
+ data, *rest = merge_inherited_macros(data, unread, hidden, settled)
1525
+ [ data.merge(callbacks: chain_order(data[:callbacks], commits_in_order)), *rest ]
1526
+ end
1527
+
1528
+ CHAIN_KEYS = %i[rank chain_at hook owner].freeze
1529
+
1530
+ # What the class methods the app's base bodies give the class declare, at each call reaching them,
1531
+ # with the calls read that way (`:expanded`) by the rank they stand at.
1532
+ def base_declarations(calls, classes)
1533
+ found = Hash.new { |hash, key| hash[key] = [] }
1534
+ base_defs(classes).each do |node, definition|
1535
+ next unless node.is_a?(Prism::DefNode)
1536
+
1537
+ sites = Array(calls.sites_by_name[definition.name]).compact
1538
+ expansion, = ConcernMacros.expand_calls(node, sites, [ *WALKED_KEYS, :expanded ], SourceIntrospector::LISTENER_MAP) do |entry, site|
1539
+ calls.placed(entry, definition.key, [ entry[:location] ], site)
1540
+ end
1541
+ expansion.each do |key, entries|
1542
+ found[key].concat(%i[callbacks expanded].include?(key) ? entries : entries.map { |entry| entry.except(*CHAIN_KEYS) }.uniq)
1543
+ end
1544
+ end
1545
+ found.to_h
1546
+ end
1547
+
1548
+ # Rails builds the chain from the outermost base in, each class in the
1549
+ # order its body runs.
1550
+ def chain_order(callbacks, commits_in_order)
1551
+ sorted = Array(callbacks).each_with_index.sort_by do |cb, index|
1552
+ [ -cb[:rank].to_i, cb[:chain_at] || [ cb[:location].to_i, -1 ], index ]
1553
+ end
1554
+ run_order(callback_chain(sorted.map(&:first)), commits_in_order).map { |cb| cb.except(*CHAIN_KEYS) }
1555
+ end
1556
+
1557
+ # A prepended callback goes to the front and after callbacks run from the
1558
+ # back; ActiveModel prepends every after_*, after_commit only on request.
1559
+ def run_order(callbacks, commits_in_order)
1560
+ ordered = callbacks.dup
1561
+ callbacks.each_index.group_by { |i| Listeners::CallbacksListener.chain_key(callbacks[i][:type]) }.each_value do |slots|
1562
+ chain = slots.each_with_object([]) do |i, list|
1563
+ prepended?(callbacks[i], commits_in_order) ? list.unshift(callbacks[i]) : list.push(callbacks[i])
1564
+ end
1565
+ chain.reverse! if Listeners::CallbacksListener.after?(callbacks[slots.first][:type])
1566
+ slots.zip(chain) { |i, cb| ordered[i] = cb }
1567
+ end
1568
+ ordered
1569
+ end
1570
+
1571
+ # An unknown setting keeps the transaction callbacks in declaration order.
1572
+ def prepended?(callback, commits_in_order)
1573
+ declared = callback.dig(:options, :prepend).to_s == "true"
1574
+ return declared unless Listeners::CallbacksListener.after?(callback[:type])
1575
+ return true unless Listeners::CallbacksListener.transaction?(callback[:type])
1576
+
1577
+ declared || commits_in_order != false
1578
+ end
1579
+
1580
+ # Rails 7.0 has no setting and always runs them last declared first.
1581
+ def booted_commits_in_order
1582
+ ActiveRecord.respond_to?(:run_after_transaction_callbacks_in_order_defined) &&
1583
+ ActiveRecord.run_after_transaction_callbacks_in_order_defined == true
1584
+ end
1585
+
1586
+ # nil when the config cannot say: no config/application.rb, a version or
1587
+ # a value that is not a literal. A chain order has no way to carry a condition.
1588
+ def static_commits_in_order
1589
+ setting = framework_setting(:run_after_transaction_callbacks_in_order_defined=, since: 7.1)
1590
+ return (setting.value unless setting.value.is_a?(String)) if setting.assigned
1591
+ return nil if !setting.read || setting.version == Float::INFINITY
1592
+
1593
+ setting.version.to_f >= 7.1
1594
+ end
1595
+
1596
+ # Only a chain with two of them can be out of order.
1597
+ def commit_order_unread(callbacks)
1598
+ return nil unless static_commits_in_order.nil?
1599
+
1600
+ chains = Array(callbacks).select { |cb| Listeners::CallbacksListener.transaction?(cb[:type]) }.map { |cb| Listeners::CallbacksListener.chain_key(cb[:type]) }
1601
+ true if chains.tally.values.any? { |count| count > 1 }
1475
1602
  end
1476
1603
 
1477
1604
  # Each base read with its concerns; a base's concern macro that sits in a
1478
1605
  # class method runs for the class that calls it, the child as often as
1479
1606
  # the base.
1480
1607
  def walk_bases(bases, calls)
1481
- Array(bases).map do |name, path|
1608
+ Array(bases).each_with_index.map do |(name, path), index|
1482
1609
  own = sti_base_source(path)
1483
- next [ name, nil ] if own.nil?
1484
-
1485
- own = own_body(own, name)
1486
- [ name, own, *merge_concern_macros(own, name, calls, file: path) ]
1610
+ [ name, own && walk_class(own_body(own, name), name, calls, file: path, rank: index + 1) ]
1487
1611
  end
1488
1612
  end
1489
1613
 
@@ -1560,14 +1684,7 @@ module RailsAiContext
1560
1684
  # declaration: the line it was read at differs between two files, and
1561
1685
  # the concern tag differs between two ways of reaching one file.
1562
1686
  merged[:macros] = dedup(merged[:macros]) { |m| m.except(:from_concern, :location) }
1563
- # Rails keeps one entry for a symbol callback declared on a base and
1564
- # again on the child, and two validators for a validation declared
1565
- # twice, so these two are not deduped alike. Two blocks are two
1566
- # callbacks unless they are one line of one file read twice.
1567
- merged[:callbacks] = dedup(merged[:callbacks]) do |c|
1568
- block = c[:method].to_s == Listeners::CallbacksListener::INLINE_BLOCK
1569
- [ c[:type], c[:method].to_s, (block ? [ c[:from_concern], c[:location] ] : nil) ]
1570
- end
1687
+ merged[:callbacks] = Array(inherited[:callbacks]) + Array(mine[:callbacks])
1571
1688
  # One source line read twice is still one declaration: a concern the
1572
1689
  # model and one of its bases both include is walked once per class, and
1573
1690
  # `included do` runs once. Two validations really written twice differ
@@ -1576,6 +1693,16 @@ module RailsAiContext
1576
1693
  merged
1577
1694
  end
1578
1695
 
1696
+ SYMBOL_TARGET = /\A[a-z_]\w*[?!]?\z/
1697
+
1698
+ # Rails keeps the later of a symbol declared twice; two blocks stay two.
1699
+ def callback_chain(callbacks)
1700
+ keys = callbacks.each_with_index.map do |c, i|
1701
+ c[:method].to_s.match?(SYMBOL_TARGET) ? [ Listeners::CallbacksListener.chain_key(c[:type]), c[:method].to_s ] : i
1702
+ end
1703
+ keys.zip(callbacks).reverse.uniq(&:first).reverse.map(&:last)
1704
+ end
1705
+
1579
1706
  # The model's own declaration wins: it is the one whose options the
1580
1707
  # class actually runs with.
1581
1708
  def dedup(entries)
@@ -1636,7 +1763,7 @@ module RailsAiContext
1636
1763
  next unless models.include?(class_name)
1637
1764
 
1638
1765
  result[class_name] = if entry[:source].include?("Mongoid::Document")
1639
- mongoid_model_details(entry[:source]).merge(file: relative_to_root(entry[:path]))
1766
+ mongoid_model_details(entry[:source], class_name, entry[:path]).merge(file: relative_to_root(entry[:path]))
1640
1767
  else
1641
1768
  # This walk keeps no candidate hash, so an AR model in a
1642
1769
  # hybrid app gets the table it assigns itself and the derived
@@ -1704,7 +1831,8 @@ module RailsAiContext
1704
1831
  # A path the read could not answer falls through to the path walk, which raises. A class
1705
1832
  # nested in the model's file includes for itself, not for the model.
1706
1833
  def own_body(data, class_name)
1707
- data.merge(mixins: ConcernMembership.own_mixins(data[:mixins], class_name))
1834
+ data.merge(mixins: ConcernMembership.owned_by(data[:mixins], class_name),
1835
+ callbacks: ConcernMembership.owned_by(data[:callbacks], class_name))
1708
1836
  end
1709
1837
 
1710
1838
  def source_walk(path)
@@ -1712,36 +1840,62 @@ module RailsAiContext
1712
1840
  source ? SourceIntrospector.walk_source(source) : SourceIntrospector.call(path)
1713
1841
  end
1714
1842
 
1715
- # Class methods the class and its bases call, read only when a concern asks: a macro
1716
- # inside `def acts_as_watchable` belongs to the models that call it.
1717
- #
1718
- # A concern's `included do` calls join the set too, so a base walked later sees them.
1719
- def class_calls(paths)
1843
+ # The class files' side of the lookup, read only when a concern asks: their class-body
1844
+ # calls and their own class methods, `alias_method` in `class << self` included.
1845
+ def singleton_lookup(classes)
1720
1846
  reader = lambda do
1721
- paths.compact.select { |path| readable_source?(path) }.each_with_object({}) do |path, found|
1847
+ ranks = {}
1848
+ defs = []
1849
+ readable = classes.each_with_index.select { |(_, path), _| path && readable_source?(path) }
1850
+ found = readable.each_with_object({}) do |((name, path), rank), into|
1722
1851
  source = model_source(path)
1723
- tree = (source ? AstCache.parse_string(source) : AstCache.parse(path)).value
1724
- ConcernMacros::Run.merge_calls(found, SourceIntrospector.calls_outside_methods(tree))
1852
+ scope = class_scope((source ? AstCache.parse_string(source) : AstCache.parse(path)).value, name)
1853
+ own = scope.select { |node| node.is_a?(Prism::CallNode) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) }
1854
+ .group_by { |node| node.name.to_s }
1855
+ own.each_value { |sites| sites.each { |site| ranks[site.__id__] = rank } }
1856
+ defs.concat(ConcernMacros::SingletonLookup.own_defs(scope, rank))
1857
+ ConcernMacros::Run.merge_calls(into, own)
1725
1858
  rescue StandardError, ScriptError => e
1726
1859
  # A file the walk cannot read calls nothing it can see.
1727
1860
  RailsAiContext.debug_fail(e, nil, label: "class calls of #{path}")
1728
1861
  end
1862
+ defs.concat(base_defs(classes).map(&:last))
1863
+ ConcernMacros::SingletonLookup::Read.new(found, ranks, defs, classes.size)
1729
1864
  end
1730
- ClassCalls.new(reader, {})
1865
+ ConcernMacros::SingletonLookup.new(reader)
1731
1866
  end
1732
1867
 
1733
- # The class files' own calls are read once; the included-block calls
1734
- # grow as the walks read concerns.
1735
- ClassCalls = Struct.new(:reader, :included) do
1736
- def call = ConcernMacros::Run.merge_calls(ConcernMacros::Run.merge_calls({}, own), included)
1868
+ # [node, definition] for each class method a base body outside app/models gives the class: one
1869
+ # reopening ApplicationRecord at that base's rank, the rest at the outermost; each after its base's file.
1870
+ def base_defs(classes)
1871
+ application = classes.index { |name, _| name == "ApplicationRecord" }
1872
+ BaseMixins.bodies(app.root.to_s).flat_map do |body|
1873
+ rank = body.target == "ApplicationRecord" ? application : classes.size
1874
+ next [] unless rank
1737
1875
 
1738
- def own
1739
- @own ||= reader.call
1876
+ ConcernMacros::SingletonLookup.member_defs(body.statements, rank, body.order)
1740
1877
  end
1741
1878
  end
1742
1879
 
1880
+ # The nodes the class body runs with the class as self: the node opening a
1881
+ # method, nested class or `class << x` is there, its body is not.
1882
+ def class_scope(tree, class_name)
1883
+ short = class_name.to_s.split("::").last.to_s
1884
+ roots = AstWalk.each(tree).select { |node| node.is_a?(Prism::ClassNode) && node.constant_path.slice.split("::").last.casecmp?(short) }
1885
+ roots.empty? ? scope_nodes(tree) : roots.flat_map { |root| scope_nodes(root.body) }
1886
+ end
1887
+
1888
+ def scope_nodes(node, found = [])
1889
+ return found unless node
1743
1890
 
1744
- def mongoid_model_details(source)
1891
+ found << node
1892
+ case node
1893
+ when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then found
1894
+ else node.child_nodes.compact.each_with_object(found) { |child, into| scope_nodes(child, into) }
1895
+ end
1896
+ end
1897
+
1898
+ def mongoid_model_details(source, class_name, path)
1745
1899
  data = SourceIntrospector.walk_source(source, {
1746
1900
  mongoid: -> { Listeners::GenericMacroListener.new(%i[field embeds_many embeds_one embedded_in store_in]) },
1747
1901
  associations: Listeners::AssociationsListener,
@@ -1751,6 +1905,11 @@ module RailsAiContext
1751
1905
  methods: Listeners::MethodsListener
1752
1906
  })
1753
1907
  macros = data[:mongoid] || []
1908
+ calls = singleton_lookup([ [ class_name, path ] ])
1909
+ calls.add(0, {}, {}, [])
1910
+ own = own_body(data.merge(mixins: []), class_name)
1911
+ # Mongoid sets after_commit without prepend, as Rails 7.0 does, so it runs last declared first.
1912
+ callbacks = chain_order(settle(Walk.empty(own), calls).first[:callbacks], false)
1754
1913
  details = {
1755
1914
  confidence: Confidence::STATIC,
1756
1915
  mongoid: true,
@@ -1767,8 +1926,8 @@ module RailsAiContext
1767
1926
  # `callbacks.is_a?(Hash)` - so passing it through rendered "No models
1768
1927
  # with callbacks found" and then raised a TypeError on a Hash lookup
1769
1928
  # against an Array.
1770
- callbacks: group_callbacks_by_type(data[:callbacks]),
1771
- callback_conditions: callback_conditions(data[:callbacks]),
1929
+ callbacks: group_callbacks_by_type(callbacks),
1930
+ callback_conditions: callback_conditions(callbacks),
1772
1931
  methods: data[:methods]
1773
1932
  }
1774
1933
  collection = macros.find { |m| m[:macro] == :store_in }&.dig(:options, :collection)
@@ -80,10 +80,12 @@ module RailsAiContext
80
80
  path.delete_prefix(root + File::SEPARATOR)
81
81
  end
82
82
 
83
- # Whether either spelling of a path lands inside either spelling of the
84
- # root. Only one of the two holds for a symlinked pack.
83
+ # Either spelling may land inside the root (only one does for a symlinked
84
+ # pack), but a spelled ".." can climb out through a symlink, so it needs the real one.
85
85
  def under_root?(path, real, root, real_root)
86
- path.start_with?(root + File::SEPARATOR) || real.start_with?(real_root + File::SEPARATOR)
86
+ return true if real.start_with?(real_root + File::SEPARATOR)
87
+
88
+ !path.split(File::SEPARATOR).include?("..") && File.expand_path(path).start_with?(root + File::SEPARATOR)
87
89
  end
88
90
  end
89
91
  end
@@ -144,7 +144,7 @@ module RailsAiContext
144
144
  # the rails-new home an app's own loader can strip a segment the path keeps.
145
145
  def identifier_for(path, js_root)
146
146
  parts = path.sub("#{js_root}/", "").split("/")
147
- index = parts.rindex("controllers")
147
+ index = parts.index("controllers")
148
148
  stem = (index ? parts[(index + 1)..] : parts).join("/")
149
149
  .sub(/(?:_controller|\.controller)\.\w+\z/, "")
150
150
  [ stem.gsub("/", "--").tr("_", "-"), !default_home?(path, js_root) ]
@@ -159,7 +159,7 @@ module RailsAiContext
159
159
  # controller left without a free name is qualified by its code root and marked a guess.
160
160
  def resolve_names(paths, referenced, registered = {}, root: nil, loader_dirs: [])
161
161
  derived = paths.map { |path, js_root| [ path, js_root, identifier_for(path, js_root) ] }
162
- .sort_by { |path, js_root, _| [ js_root.count("/"), path ] }
162
+ .sort_by { |path, js_root, (_name, guess)| [ code_root_of(js_root).count("/"), guess ? 1 : 0, path ] }
163
163
  claimed = derived.map { |_path, _js_root, (name, _guess)| name }.to_set
164
164
  taken = Set.new
165
165
  resolved = {}
@@ -238,7 +238,7 @@ module RailsAiContext
238
238
 
239
239
  def controllers_home(path, js_root)
240
240
  parts = path.delete_prefix("#{js_root}/").split("/")
241
- index = parts.rindex("controllers") or return nil
241
+ index = parts.index("controllers") or return nil
242
242
  File.join(js_root, *parts.first(index + 1))
243
243
  end
244
244
 
@@ -255,11 +255,20 @@ module RailsAiContext
255
255
  end
256
256
  end
257
257
 
258
+ def js_dir_of(js_root)
259
+ JS_ROOTS.find { |dir| js_root == dir || js_root.end_with?("/#{dir}") }
260
+ end
261
+
262
+ # The code root a JS root belongs to: `frontend` and `app/javascript` share one.
263
+ def code_root_of(js_root)
264
+ js_root.delete_suffix(js_dir_of(js_root).to_s).chomp("/")
265
+ end
266
+
258
267
  # Names for a controller whose derived one is taken: prefixed by more and more
259
268
  # of its code root (`chat--foo`, `plugins--chat--foo`), then the whole path.
260
269
  def qualified_names(name, js_root, root)
261
270
  relative = root ? js_root.delete_prefix("#{root}/") : js_root
262
- js_dir = JS_ROOTS.find { |dir| relative == dir || relative.end_with?("/#{dir}") }
271
+ js_dir = js_dir_of(relative)
263
272
  segments = (js_dir ? relative.delete_suffix(js_dir).chomp("/") : relative).split("/").reject(&:empty?)
264
273
  segments = [ js_dir.tr("/", "-") ] if segments.empty? && js_dir
265
274
  (1..segments.size).map { |n| "#{segments.last(n).join('--')}--#{name}" } +