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
|
@@ -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 =
|
|
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
|
-
|
|
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:
|
|
712
|
-
# default. An option
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
elsif !optional_value.nil?
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
#
|
|
734
|
-
#
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
#
|
|
762
|
-
#
|
|
763
|
-
def
|
|
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
|
-
#
|
|
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
|
|
983
|
-
#
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
conditions =
|
|
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 =
|
|
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
|
|
1412
|
-
|
|
1413
|
-
|
|
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
|
|
1431
|
-
collected, unread, hidden, included,
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
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 =
|
|
1462
|
-
mine =
|
|
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.
|
|
1466
|
-
break unless mine.
|
|
1511
|
+
known = Set.new(calls.sites_by_name.keys)
|
|
1512
|
+
break unless mine.skipped.intersect?(known)
|
|
1467
1513
|
|
|
1468
|
-
mine =
|
|
1469
|
-
held = walked.flat_map { |_name,
|
|
1470
|
-
grown = Set.new(calls.
|
|
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
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
1716
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1865
|
+
ConcernMacros::SingletonLookup.new(reader)
|
|
1731
1866
|
end
|
|
1732
1867
|
|
|
1733
|
-
#
|
|
1734
|
-
#
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1771
|
-
callback_conditions: callback_conditions(
|
|
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
|
-
#
|
|
84
|
-
#
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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 =
|
|
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}" } +
|