rails-ai-context 5.30.2 → 5.32.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 +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- 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/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -10,48 +10,555 @@ module RailsAiContext
|
|
|
10
10
|
module ConcernMacros
|
|
11
11
|
MAX_DEPTH = 3
|
|
12
12
|
|
|
13
|
+
# What collect found.
|
|
14
|
+
Collected = Struct.new(:collected, :unread, :hidden, :included_calls, :placement, :skipped, :block_sites, :mixins)
|
|
15
|
+
|
|
16
|
+
# A call made inside a class method's body, standing where the call that ran
|
|
17
|
+
# that body (`outer`) was made; `definition` is the body it sits in.
|
|
18
|
+
Relayed = Struct.new(:node, :outer, :definition) do
|
|
19
|
+
def arguments = node.arguments
|
|
20
|
+
def location = node.location
|
|
21
|
+
def name = node.name
|
|
22
|
+
|
|
23
|
+
# The call outside any method a chain of relayed calls starts from.
|
|
24
|
+
def self.root(site)
|
|
25
|
+
site = site.outer while site.is_a?(Relayed)
|
|
26
|
+
site
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Ruby's class-method lookup over a class (rank 0), its bases nearest first and the modules
|
|
31
|
+
# every model has: which definitions a call runs, and so where what they declare lands.
|
|
32
|
+
class SingletonLookup
|
|
33
|
+
# The lookup order inside one rank.
|
|
34
|
+
PREPENDED = 0
|
|
35
|
+
OWN = 1
|
|
36
|
+
MIXED = 2
|
|
37
|
+
# The lookup order of the defs that join with a module, by how it is added.
|
|
38
|
+
JOINS = { prepended: PREPENDED, mixed: MIXED }.freeze
|
|
39
|
+
# The defs a module gives that run with code, by group: [run by a hook?, lookup order].
|
|
40
|
+
RAN = { block: [ false, OWN ], hook: [ true, OWN ], hook_mixed: [ true, MIXED ], hook_prepended: [ true, PREPENDED ] }.freeze
|
|
41
|
+
|
|
42
|
+
# A class method; `owner` is a class file's rank or a module's label, `alias_of` the name an alias copies,
|
|
43
|
+
# `at` its place in the owner's lookup when not its line (a base body's, after the base's file),
|
|
44
|
+
# `joined` [line, argument index] of the hook's call adding the nested module it is in; `node` the def itself.
|
|
45
|
+
Def = Struct.new(:owner, :name, :line, :super_line, :calls, :alias_of, :at, :joined, :node) do
|
|
46
|
+
def key = at ? [ owner, *at ] : [ owner, line ]
|
|
47
|
+
end
|
|
48
|
+
# One step of a singleton ancestry, existing from `at` ([line, order, ...]).
|
|
49
|
+
Provider = Struct.new(:rank, :group, :at, :defs)
|
|
50
|
+
# The class files' body calls by name, each one's rank, their own class methods, and the outermost rank.
|
|
51
|
+
Read = Struct.new(:found, :ranks, :defs, :outer)
|
|
52
|
+
# A module a walk reached, with the class methods it gives (`module_defs`). Added at `at` ([line, order]), or
|
|
53
|
+
# within each run of the code `added` names (`added_in`); an every-model module at the outermost rank.
|
|
54
|
+
Mixin = Struct.new(:label, :macro, :defs, :added, :at, :every)
|
|
55
|
+
Reached = Struct.new(:included, :blocks, :mixins)
|
|
56
|
+
# Each pass can reach one more module added inside a body, and a walk reads MAX_DEPTH levels;
|
|
57
|
+
# one more pass shows nothing changed.
|
|
58
|
+
PASSES = MAX_DEPTH + 2
|
|
59
|
+
|
|
60
|
+
def self.definition(owner, node)
|
|
61
|
+
super_node = node.body && Introspectors::AstWalk.each(node.body).find do |inner|
|
|
62
|
+
inner.is_a?(Prism::SuperNode) || inner.is_a?(Prism::ForwardingSuperNode)
|
|
63
|
+
end
|
|
64
|
+
calls = node.body ? Introspectors::SourceIntrospector.calls_outside_methods(node.body, self_receiver: true) : {}
|
|
65
|
+
Def.new(owner, node.name.to_s, node.location.start_line, super_node&.location&.start_line, calls).tap { |found| found.node = node }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Where a base body's def sits in the lookup: after every class file's, then by load order and line.
|
|
69
|
+
AFTER_CLASS_FILES = Float::INFINITY
|
|
70
|
+
|
|
71
|
+
# The class methods a body run with the class as self defines: `def self.x`, and `def x` and aliases
|
|
72
|
+
# inside `class << self`. `owner` is a class file's rank or a module's label; `order` a base body's load order.
|
|
73
|
+
def self.own_defs(scope, owner, order = nil) = member_defs(scope, owner, order).map(&:last)
|
|
74
|
+
|
|
75
|
+
# [node, Def] for each of `own_defs`.
|
|
76
|
+
def self.member_defs(scope, owner, order = nil)
|
|
77
|
+
singleton_members(scope).map do |node|
|
|
78
|
+
names = alias_names(node)
|
|
79
|
+
found = names ? Def.new(owner, names.first, node.location.start_line, nil, {}, names.last) : definition(owner, node)
|
|
80
|
+
found.at = [ AFTER_CLASS_FILES, order, found.line ] if order
|
|
81
|
+
[ node, found ]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The `def` and alias nodes behind `own_defs`.
|
|
86
|
+
def self.singleton_members(scope)
|
|
87
|
+
scope.flat_map do |node|
|
|
88
|
+
case node
|
|
89
|
+
when Prism::DefNode then node.receiver.is_a?(Prism::SelfNode) ? [ node ] : []
|
|
90
|
+
when Prism::SingletonClassNode
|
|
91
|
+
next [] unless node.expression.is_a?(Prism::SelfNode)
|
|
92
|
+
|
|
93
|
+
Array(node.body&.body).select { |member| (member.is_a?(Prism::DefNode) && member.receiver.nil?) || alias_names(member) }
|
|
94
|
+
else []
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.alias_names(node)
|
|
100
|
+
pair =
|
|
101
|
+
case node
|
|
102
|
+
when Prism::AliasMethodNode then [ node.new_name, node.old_name ]
|
|
103
|
+
when Prism::CallNode then node.name == :alias_method && node.receiver.nil? ? Array(node.arguments&.arguments) : []
|
|
104
|
+
else []
|
|
105
|
+
end
|
|
106
|
+
names = pair.map { |arg| arg.unescaped if arg.respond_to?(:unescaped) }
|
|
107
|
+
names if names.size == 2 && names.all?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The class methods the module `own` gives a class, by how they join: `:mixed` and `:prepended` with the
|
|
111
|
+
# module, `:block` (an included block's) at its first add, and at each add `:hook` from its line and
|
|
112
|
+
# `:hook_mixed`/`:hook_prepended` (a nested module's a hook adds) from the line adding it.
|
|
113
|
+
def self.module_defs(own, label, macro)
|
|
114
|
+
given = { prepended: [], mixed: [], block: [], hook: [], hook_mixed: [], hook_prepended: [] }
|
|
115
|
+
return given unless own&.body
|
|
116
|
+
|
|
117
|
+
hook_defs = hooks(own, macro)
|
|
118
|
+
if ConcernMembership::SINGLETON_MACROS.include?(macro)
|
|
119
|
+
given[group(macro)] = body_defs(own.body)
|
|
120
|
+
else
|
|
121
|
+
concern = macro == :prepend ? :prepended : :mixed
|
|
122
|
+
class_methods = concern?(own)
|
|
123
|
+
own.body.compact_child_nodes.each do |node|
|
|
124
|
+
if node.is_a?(Prism::CallNode) && node.name == :class_methods && node.block then given[concern].concat(body_defs(node.block.body))
|
|
125
|
+
elsif node.is_a?(Prism::CallNode) && node.name == ConcernMembership::CONCERN_BLOCKS[macro] && node.block
|
|
126
|
+
given[:block].concat(singleton_scope_defs(node.block.body, label))
|
|
127
|
+
elsif node.is_a?(Prism::ModuleNode) && class_methods && module_name(node) == "ClassMethods"
|
|
128
|
+
given[concern].concat(body_defs(node.body))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
short = label.to_s.split("::").last
|
|
133
|
+
hook_defs.flat_map { |hook| hook_joins(hook) }.each do |added, arg, call|
|
|
134
|
+
next unless ConcernMembership::SINGLETON_MACROS.include?(added)
|
|
135
|
+
|
|
136
|
+
name = short_name(arg, short)
|
|
137
|
+
given[group(added)].concat(body_defs(own.body)) if name == short
|
|
138
|
+
nested_modules(own).select { |node| module_name(node) == name }.each do |node|
|
|
139
|
+
given[RAN.key([ true, JOINS[group(added)] ])].concat(body_defs(node.body).map { |inner| definition(label, inner).tap { |found| found.joined = [ call.location.start_line, call.arguments.arguments.index(arg) ] } })
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
given[:hook] = hook_defs.flat_map { |hook| singleton_defs(hook, label) }
|
|
143
|
+
given.to_h { |group, nodes| [ group, nodes.map { |node| node.is_a?(Def) ? node : definition(label, node) } ] }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.nested_modules(own) = own.body.compact_child_nodes.grep(Prism::ModuleNode)
|
|
147
|
+
|
|
148
|
+
def self.module_name(node) = node.constant_path.slice.split("::").last
|
|
149
|
+
|
|
150
|
+
# A `def self.included` (or the hook `macro` runs) on the module itself.
|
|
151
|
+
def self.hook?(node, macro)
|
|
152
|
+
node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::HOOKS_BY_MACRO.fetch(macro, []).include?(node.name.to_s)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.concern?(own)
|
|
156
|
+
own.body.compact_child_nodes.any? do |node|
|
|
157
|
+
node.is_a?(Prism::CallNode) && node.name == :extend && node.receiver.nil? &&
|
|
158
|
+
Array(node.arguments&.arguments).any? { |arg| arg.slice.delete_prefix("::") == "ActiveSupport::Concern" }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.group(macro) = macro == :singleton_prepend ? :prepended : :mixed
|
|
163
|
+
|
|
164
|
+
def self.short_name(arg, short) = arg.is_a?(Prism::SelfNode) ? short : arg.slice.split("::").last
|
|
165
|
+
|
|
166
|
+
# The hooks `macro` runs that the module `own` defines: `extended` for an extend, none for a `singleton_class` mixin.
|
|
167
|
+
def self.hooks(own, macro)
|
|
168
|
+
return [] if own&.body.nil?
|
|
169
|
+
|
|
170
|
+
own.body.compact_child_nodes.select { |node| node.is_a?(Prism::DefNode) && hook?(node, macro) && node.body }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A reader of the hook's parameter, the class mixing the module in; nil when the hook takes none.
|
|
174
|
+
def self.base_reader(hook)
|
|
175
|
+
param = hook.parameters&.requireds&.first
|
|
176
|
+
->(node) { node.is_a?(Prism::LocalVariableReadNode) && node.name == param.name } if param.respond_to?(:name)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# [macro, argument, call] for each module a hook adds to the class: `base.include`, `prepend` and `extend`,
|
|
180
|
+
# and `base.singleton_class.include`/`prepend`, `base` being the hook's parameter.
|
|
181
|
+
def self.hook_joins(hook)
|
|
182
|
+
base = base_reader(hook)
|
|
183
|
+
return [] unless base
|
|
184
|
+
|
|
185
|
+
Introspectors::AstWalk.each(hook.body).flat_map do |call|
|
|
186
|
+
next [] unless call.is_a?(Prism::CallNode)
|
|
187
|
+
|
|
188
|
+
name = call.name
|
|
189
|
+
arguments = Array(call.arguments&.arguments)
|
|
190
|
+
if %i[send public_send].include?(name) && arguments.first.is_a?(Prism::SymbolNode)
|
|
191
|
+
name = arguments.first.unescaped.to_sym
|
|
192
|
+
arguments = arguments.drop(1)
|
|
193
|
+
end
|
|
194
|
+
macro =
|
|
195
|
+
if base.call(call.receiver) then name if Introspectors::Listeners::MixinsListener::MIXIN_MACROS.include?(name)
|
|
196
|
+
elsif Introspectors::Listeners::MixinsListener.singleton_class_of?(call.receiver, &base)
|
|
197
|
+
{ include: :singleton_include, prepend: :singleton_prepend }[name]
|
|
198
|
+
end
|
|
199
|
+
next [] unless macro
|
|
200
|
+
|
|
201
|
+
# Ruby adds the last argument first, as MixinsListener records them.
|
|
202
|
+
arguments.reverse.filter_map do |arg|
|
|
203
|
+
[ macro, arg, call ] if arg.is_a?(Prism::SelfNode) || arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A mixin record the walk follows for each module from elsewhere a hook adds;
|
|
209
|
+
# `module_defs` reads the module itself and one nested in it.
|
|
210
|
+
def self.hook_mixins(own, label, macro)
|
|
211
|
+
short = label.to_s.split("::").last
|
|
212
|
+
read = [ short, *(own&.body ? nested_modules(own).map { |node| module_name(node) } : []) ]
|
|
213
|
+
hooks(own, macro).flat_map { |hook| hook_joins(hook) }.filter_map do |added, arg, call|
|
|
214
|
+
next if read.include?(short_name(arg, short))
|
|
215
|
+
|
|
216
|
+
Introspectors::Listeners::MixinsListener.record(
|
|
217
|
+
call, added, arg.slice.delete_prefix("::"), ancestor: Introspectors::Listeners::MixinsListener::ANCESTOR_MACROS.include?(added)
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The class's own methods a hook defines: `def x` in `class << base`, and `def self.x` or
|
|
223
|
+
# `class << self` in `base.class_eval`, `base` being the class mixing the module in.
|
|
224
|
+
def self.singleton_defs(hook, label)
|
|
225
|
+
base = base_reader(hook)
|
|
226
|
+
return [] unless base
|
|
227
|
+
|
|
228
|
+
Introspectors::AstWalk.each(hook.body).flat_map do |node|
|
|
229
|
+
if node.is_a?(Prism::SingletonClassNode) && base.call(node.expression) then body_defs(node.body).map { |inner| definition(label, inner) }
|
|
230
|
+
elsif node.is_a?(Prism::CallNode) && %i[class_eval class_exec].include?(node.name) && base.call(node.receiver) && node.block
|
|
231
|
+
singleton_scope_defs(node.block.body, label)
|
|
232
|
+
else []
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The `def self.x` and `class << self` defs a block run with the class as self makes.
|
|
238
|
+
def self.singleton_scope_defs(body, label)
|
|
239
|
+
own_defs(body.is_a?(Prism::StatementsNode) ? body.body : [], label)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The `def x` a module body runs with the module as self, `private def x` included.
|
|
243
|
+
def self.body_defs(node, found = [])
|
|
244
|
+
case node
|
|
245
|
+
when nil then found
|
|
246
|
+
when Prism::DefNode then node.receiver.nil? ? found << node : found
|
|
247
|
+
when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then found
|
|
248
|
+
when Prism::CallNode
|
|
249
|
+
return found if %i[class_methods included prepended].include?(node.name) && node.block
|
|
250
|
+
|
|
251
|
+
node.compact_child_nodes.each_with_object(found) { |child, into| body_defs(child, into) }
|
|
252
|
+
else node.compact_child_nodes.each_with_object(found) { |child, into| body_defs(child, into) }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Where code adding a module at `line` of `owner` runs, when not where `owner` itself joins:
|
|
257
|
+
# [:body, [owner, def line]] in a method, [:block, owner, hook?] in a Concern's block or a hook's class_eval.
|
|
258
|
+
def self.added_in(owner, line, bodies, blocks = [], evals = [])
|
|
259
|
+
body = ConcernMacros.enclosing(bodies, line)
|
|
260
|
+
if body then [ :body, [ owner, body.first.begin ] ]
|
|
261
|
+
elsif blocks.any? { |range| range.cover?(line) } then [ :block, owner, false ]
|
|
262
|
+
elsif evals.any? { |range| range.cover?(line) } then in_hook(owner)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Where a hook of `owner` runs, each time `owner` is added.
|
|
267
|
+
def self.in_hook(owner) = [ :block, owner, true ]
|
|
268
|
+
|
|
269
|
+
# Whether a mixin record adds to the class: an include or prepend, or an `extend` or `singleton_class`
|
|
270
|
+
# include or prepend run with the class as self; in a module's own body `extend` extends the module.
|
|
271
|
+
def self.joins?(mixin, class_self)
|
|
272
|
+
mixin[:ancestor] || (ConcernMembership::SINGLETON_MACROS.include?(mixin[:macro]) && !mixin[:receiver] && class_self)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def initialize(reader)
|
|
276
|
+
@reader = reader
|
|
277
|
+
@walks = {}
|
|
278
|
+
forget
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# What the walk of the class at `rank` found: the calls its concerns' blocks and hooks make, each
|
|
282
|
+
# one's [label, hook?] by object id, and the modules it reached. A later walk of a rank replaces it.
|
|
283
|
+
def add(rank, included, blocks, mixins)
|
|
284
|
+
@walks[rank] = Reached.new(included, blocks, mixins.to_h { |mixin| [ mixin.label, mixin ] })
|
|
285
|
+
forget
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Every call the classes may make, by name. Resolving one needs every walk,
|
|
289
|
+
# so a body is read for any call of its name.
|
|
290
|
+
def sites_by_name
|
|
291
|
+
@sites_by_name ||= begin
|
|
292
|
+
found = Run.merge_calls({}, read.found)
|
|
293
|
+
@walks.each_value { |walk| Run.merge_calls(found, walk.included) }
|
|
294
|
+
queue = found.flat_map { |name, sites| sites.compact.map { |site| [ name, site ] } }
|
|
295
|
+
read_bodies = Set.new
|
|
296
|
+
until queue.empty?
|
|
297
|
+
name, site = queue.shift
|
|
298
|
+
defs_named(name).each do |definition|
|
|
299
|
+
next unless read_bodies.add?([ definition.key, root(site).__id__ ])
|
|
300
|
+
|
|
301
|
+
# A call of an alias runs the original's body.
|
|
302
|
+
if definition.alias_of
|
|
303
|
+
(found[definition.alias_of] ||= []) << site
|
|
304
|
+
queue << [ definition.alias_of, site ]
|
|
305
|
+
end
|
|
306
|
+
definition.calls.each do |inner, nodes|
|
|
307
|
+
nodes.each do |node|
|
|
308
|
+
relayed = Relayed.new(node, site, definition)
|
|
309
|
+
(found[inner] ||= []) << relayed
|
|
310
|
+
queue << [ inner, relayed ]
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
found
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# A copy of `entry` for each run of a call that reaches `definer`'s body
|
|
320
|
+
# ([owner, line]), placed at that call with `tail` after it; none when no call does.
|
|
321
|
+
def placed(entry, definer, tail, site = nil)
|
|
322
|
+
resolve
|
|
323
|
+
sites = site ? [ site ] : @running.fetch(definer, [])
|
|
324
|
+
sites.flat_map { |each| placements(each, definer, tail) }.uniq.map do |rank, at|
|
|
325
|
+
entry.merge(rank: rank, chain_at: at.map(&:to_i))
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# A copy of `entry`, which a module the walk at `rank` reached declares, at each run of that code.
|
|
330
|
+
# One in a method stands at each call reaching it, read from one walk: the method's key is the module's.
|
|
331
|
+
def mixed_in(entry, rank)
|
|
332
|
+
resolve
|
|
333
|
+
mixin = @walks[rank]&.mixins&.[](entry[:from_concern])
|
|
334
|
+
return [ entry.merge(rank: rank) ] unless mixin
|
|
335
|
+
return placed(entry.except(:site, :definer), entry[:definer], [ entry[:location] ], entry[:site]) if entry[:site] && first(mixin.label)&.first == rank
|
|
336
|
+
return [] if entry[:site]
|
|
337
|
+
|
|
338
|
+
ran(rank, mixin, entry[:hook]).map { |_, run, at| entry.merge(rank: run, chain_at: [ *at, entry[:location].to_i ]) }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
# Where code of `mixin`, reached by the walk at `rank`, runs: a hook's each time the walk's class
|
|
344
|
+
# adds the module, the rest once, where the chain first adds it (Ruby adds a module once).
|
|
345
|
+
def ran(rank, mixin, hook)
|
|
346
|
+
hook ? events(rank, mixin) : [ first(mixin.label) ].select { |walk, *| walk == rank }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# [walk rank, rank, at] for each time the code adding `mixin` runs.
|
|
350
|
+
def events(rank, mixin)
|
|
351
|
+
key = [ rank, mixin.label ]
|
|
352
|
+
return @events[key] if @events.key?(key)
|
|
353
|
+
|
|
354
|
+
# A module reached again while its own adds are worked out adds nothing more.
|
|
355
|
+
@events[key] = []
|
|
356
|
+
kind, where, hook = mixin.added
|
|
357
|
+
@events[key] =
|
|
358
|
+
case kind
|
|
359
|
+
when :body
|
|
360
|
+
@running.fetch(where, []).flat_map { |site| placements(site, where, mixin.at) }.uniq.map { |run, at| [ rank, run, at ] }
|
|
361
|
+
when :block
|
|
362
|
+
parent = @walks[rank].mixins[where]
|
|
363
|
+
parent ? ran(rank, parent, hook).map { |_, run, at| [ rank, run, [ *at, *mixin.at ] ] } : []
|
|
364
|
+
else [ [ rank, mixin.every ? read.outer : rank, mixin.at ] ]
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def first(label)
|
|
369
|
+
return @first[label] if @first.key?(label)
|
|
370
|
+
|
|
371
|
+
# A module whose first add depends on its own (a hook adding it again) has none until one is found.
|
|
372
|
+
@first[label] = nil
|
|
373
|
+
@first[label] = @walks.flat_map { |rank, walk| walk.mixins.key?(label) ? events(rank, walk.mixins[label]) : [] }
|
|
374
|
+
.min_by { |_, run, at| [ -run, at ] }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def placements(site, definer, tail)
|
|
378
|
+
runs(root(site)).filter_map do |rank, at|
|
|
379
|
+
path = run_path(site, rank, at)
|
|
380
|
+
chain = path && chain(site, rank, at)
|
|
381
|
+
index = chain&.index { |definition| definition.key == definer }
|
|
382
|
+
[ rank, path + chain.first(index).map(&:super_line) + tail ] if index
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Where a call stands in the run of its root call: nil when the body making it never runs.
|
|
387
|
+
def run_path(site, rank, at)
|
|
388
|
+
return at unless site.is_a?(Relayed)
|
|
389
|
+
|
|
390
|
+
path = run_path(site.outer, rank, at)
|
|
391
|
+
chain = path && chain(site.outer, rank, at)
|
|
392
|
+
index = chain&.index { |definition| definition.key == site.definition.key }
|
|
393
|
+
path + chain.first(index).map(&:super_line) + [ site.location.start_line ] if index
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# The definitions a call runs: the first the lookup finds, then each one a `super` reaches.
|
|
397
|
+
def chain(site, rank, at)
|
|
398
|
+
@chains[[ site.__id__, rank, at ]] ||= begin
|
|
399
|
+
found = lookup(site.name.to_s, rank, at)
|
|
400
|
+
found.take_while.with_index { |_, index| index.zero? || found[index - 1].super_line }
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Defs one hook call adds share its place: as in Ruby, its earlier argument wins, then the later def in one module.
|
|
405
|
+
def lookup(name, rank, at)
|
|
406
|
+
place = lambda do |provider|
|
|
407
|
+
found = provider.defs[name]
|
|
408
|
+
[ provider.at, -found.joined.to_a.last.to_i, found.line, found.owner.to_s ]
|
|
409
|
+
end
|
|
410
|
+
@providers.select { |provider| provider.defs.key?(name) && provider.rank >= rank && (provider.rank > rank || (provider.at <=> at) <= 0) }
|
|
411
|
+
.sort { |a, b| ([ a.rank, a.group ] <=> [ b.rank, b.group ]).nonzero? || (place.call(b) <=> place.call(a)) }
|
|
412
|
+
.flat_map { |provider| resolved(provider.defs[name], provider) }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# An alias runs what its original name ran where the alias stands.
|
|
416
|
+
def resolved(definition, provider)
|
|
417
|
+
definition.alias_of ? lookup(definition.alias_of, provider.rank, provider.at).first(1) : [ definition ]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Each [rank, at] a call outside any method runs in: a class-body call once, at its line;
|
|
421
|
+
# a call a concern's block or hook makes where `ran` puts that concern's code.
|
|
422
|
+
def runs(site)
|
|
423
|
+
rank = read.ranks[site.__id__]
|
|
424
|
+
return [ [ rank, [ site.location.start_line, -1 ] ] ] if rank
|
|
425
|
+
|
|
426
|
+
@walks.flat_map do |walk_rank, walk|
|
|
427
|
+
label, hook = walk.blocks[site.__id__]
|
|
428
|
+
mixin = label && walk.mixins[label]
|
|
429
|
+
mixin ? ran(walk_rank, mixin, hook) : []
|
|
430
|
+
end.uniq.map { |_, run, at| [ run, [ *at, site.location.start_line ] ] }
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Modules join where code adding them runs, and which code a call runs depends on the
|
|
434
|
+
# modules joined before it, so both are read again until neither changes.
|
|
435
|
+
def resolve
|
|
436
|
+
return if @running
|
|
437
|
+
|
|
438
|
+
@running = {}
|
|
439
|
+
@providers = []
|
|
440
|
+
inside = @walks.each_value.any? { |walk| walk.mixins.each_value.any? { |mixin| mixin.added&.first == :body } }
|
|
441
|
+
PASSES.times do
|
|
442
|
+
# Cleared so each pass rereads them against the new providers.
|
|
443
|
+
@chains = {}
|
|
444
|
+
@events = {}
|
|
445
|
+
@first = {}
|
|
446
|
+
@providers = providers
|
|
447
|
+
@chains = {}
|
|
448
|
+
running = running_sites
|
|
449
|
+
signature = [ @providers.map { |provider| [ provider.rank, provider.group, provider.at, provider.defs.values.map(&:key) ] },
|
|
450
|
+
running.transform_values { |sites| sites.map(&:__id__) } ]
|
|
451
|
+
settled = signature == @signature
|
|
452
|
+
@signature = signature
|
|
453
|
+
@running = running
|
|
454
|
+
break if settled || !inside
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# The class files' own methods, each module's where the chain first adds it, and the class's own
|
|
459
|
+
# methods a block or hook defines and a nested module's a hook adds, from their line in each run of that code.
|
|
460
|
+
def providers
|
|
461
|
+
own = read.defs.map { |definition| Provider.new(definition.owner, OWN, definition.at || [ definition.line, 0 ], by_name([ definition ])) }
|
|
462
|
+
joined = @walks.each_value.flat_map { |walk| walk.mixins.keys }.uniq.flat_map do |label|
|
|
463
|
+
walk, rank, at = first(label)
|
|
464
|
+
mixin = walk && @walks[walk].mixins[label]
|
|
465
|
+
next [] unless mixin
|
|
466
|
+
|
|
467
|
+
JOINS.filter_map do |kind, group|
|
|
468
|
+
Provider.new(rank, group, at, by_name(mixin.defs[kind])) if mixin.defs[kind].any?
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
ran_defs = @walks.flat_map do |rank, walk|
|
|
472
|
+
walk.mixins.each_value.flat_map do |mixin|
|
|
473
|
+
RAN.flat_map do |kind, (hook, group)|
|
|
474
|
+
next [] if mixin.defs[kind].empty?
|
|
475
|
+
|
|
476
|
+
ran(rank, mixin, hook).flat_map do |_, run, at|
|
|
477
|
+
mixin.defs[kind].map { |definition| Provider.new(run, group, [ *at, definition.joined&.first || definition.line ], by_name([ definition ])) }
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
own + joined + ran_defs
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def by_name(defs) = defs.to_h { |definition| [ definition.name, definition ] }
|
|
486
|
+
|
|
487
|
+
# The sites whose call may run each body, by the body's key.
|
|
488
|
+
def running_sites
|
|
489
|
+
sites_by_name.each_value.with_object(Hash.new { |hash, key| hash[key] = [] }) do |sites, index|
|
|
490
|
+
sites.compact.each do |site|
|
|
491
|
+
runs(root(site)).each { |rank, at| chain(site, rank, at).each { |definition| index[definition.key] |= [ site ] } }
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def defs_named(name)
|
|
497
|
+
@defs_named ||= (read.defs + @walks.each_value.flat_map { |walk| walk.mixins.each_value.flat_map { |mixin| mixin.defs.values.flatten } })
|
|
498
|
+
.group_by(&:name)
|
|
499
|
+
@defs_named.fetch(name, [])
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def root(site) = Relayed.root(site)
|
|
503
|
+
|
|
504
|
+
def forget
|
|
505
|
+
@sites_by_name = @running = @defs_named = @signature = nil
|
|
506
|
+
@chains = {}
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def read = (@read ||= @reader.call)
|
|
510
|
+
end
|
|
511
|
+
|
|
13
512
|
# One walk's state. The root, the directories, the keys and the cache are
|
|
14
513
|
# fixed for the run and `seen`, `collected` and `unresolved` accumulate
|
|
15
514
|
# across it, so they belong to the run rather than to every call.
|
|
16
515
|
class Run
|
|
17
|
-
|
|
516
|
+
# Each runs its block with the class as self.
|
|
517
|
+
EVALS = %i[class_eval class_exec instance_eval instance_exec].freeze
|
|
518
|
+
|
|
519
|
+
attr_reader :unresolved, :hidden, :included_calls, :skipped_methods, :placement, :block_sites, :mixins
|
|
18
520
|
|
|
19
521
|
# The default block belongs to the walk. Once the entries leave it, a
|
|
20
522
|
# caller reading a key the walk never produced would grow one.
|
|
21
523
|
def collected
|
|
22
|
-
|
|
524
|
+
@collected.to_h do |key, entries|
|
|
525
|
+
[ key, entries.filter_map do |entry|
|
|
526
|
+
next entry unless entry.is_a?(Hash) && entry.key?(:nested_def)
|
|
527
|
+
|
|
528
|
+
entry.except(:nested_def) unless @own_defs.include?(entry[:nested_def])
|
|
529
|
+
end ]
|
|
530
|
+
end
|
|
23
531
|
end
|
|
24
532
|
|
|
25
|
-
# The method names the walk asked the class's calls about
|
|
26
|
-
# the class calls
|
|
27
|
-
# for every class that calls none of them.
|
|
533
|
+
# The method names the walk asked the class's calls about: a walk that
|
|
534
|
+
# asked about none the class calls answers the same for every such class.
|
|
28
535
|
attr_reader :consulted
|
|
29
536
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def initialize(root, dirs, keys, cache, listeners, calls = nil, extra = [], file = nil)
|
|
35
|
-
# The class's own file: a module it declares there and includes is
|
|
36
|
-
# its own body, already read with it.
|
|
537
|
+
def initialize(root, dirs, keys, cache, listeners, calls = nil, extra = [], file = nil, known: nil)
|
|
538
|
+
# The class's own file, which can declare a module it includes.
|
|
37
539
|
@own_file = file
|
|
540
|
+
# Written at the top level, so each name is the constant it resolves to.
|
|
38
541
|
@paths = extra.to_h { |mixin| [ mixin.name, mixin.path ] }
|
|
39
|
-
@macros = extra.to_h { |mixin| [ mixin.name, mixin.macro ] }
|
|
40
|
-
@extended = extra.select { |mixin| mixin.macro == :extend }.map(&:name).to_set
|
|
41
542
|
@root = root
|
|
42
543
|
@dirs = dirs
|
|
43
544
|
@keys = keys
|
|
44
545
|
@cache = cache
|
|
45
546
|
@listeners = listeners
|
|
46
547
|
@calls = calls
|
|
548
|
+
@known = known
|
|
47
549
|
@seen = Set.new
|
|
48
550
|
@collected = Hash.new { |hash, key| hash[key] = [] }
|
|
49
551
|
@unresolved = []
|
|
50
552
|
@hidden = []
|
|
51
553
|
@included_calls = {}
|
|
554
|
+
@block_sites = {}
|
|
555
|
+
@includer_reads = Set.new
|
|
556
|
+
@own_defs = Set.new
|
|
52
557
|
@skipped_methods = Set.new
|
|
53
558
|
@consulted = Set.new
|
|
54
559
|
@placement = {}
|
|
560
|
+
@mixins = []
|
|
561
|
+
@inside = nil
|
|
55
562
|
end
|
|
56
563
|
|
|
57
564
|
# Raw mixin names in, so the exclusion happens here: this is the only
|
|
@@ -63,60 +570,85 @@ module RailsAiContext
|
|
|
63
570
|
|
|
64
571
|
# `names` may be the mixin records themselves, which say how each
|
|
65
572
|
# module is mixed in; that decides which of its hooks run.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
573
|
+
names.each do |mixin|
|
|
574
|
+
name, written, given = mixin.is_a?(Hash) ? mixin.values_at(:name, :macro, :path) : mixin
|
|
575
|
+
# A base module comes with the file its top-level name resolves to.
|
|
576
|
+
base = mixin.is_a?(Hash) && mixin.key?(:path)
|
|
577
|
+
# A `concerning` block is the class's own body, read with the class.
|
|
578
|
+
next if mixin.is_a?(Hash) && mixin[:inline]
|
|
70
579
|
|
|
71
|
-
names.each do |name|
|
|
72
580
|
@top = name if file.nil?
|
|
73
|
-
next unless @seen.add?(name)
|
|
74
581
|
next unless ConcernMembership.candidate?(name)
|
|
75
|
-
next if file.nil? && @own_file && nested_module(@own_file, name, within)
|
|
76
582
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
583
|
+
source = file || @own_file
|
|
584
|
+
nested = !base && source && nested_module(source, name, within)
|
|
585
|
+
label, path =
|
|
586
|
+
if base then [ name, given ]
|
|
587
|
+
elsif nested then [ nested.first, source ]
|
|
588
|
+
else named(name, within) || beside(file, name, within)
|
|
82
589
|
end
|
|
590
|
+
if path.nil? && !base && (outer = in_outer_file(name, within))
|
|
591
|
+
source, nested = outer
|
|
592
|
+
label, path = nested.first, source
|
|
593
|
+
end
|
|
594
|
+
# A module is walked once, as the constant the name resolves to where it is written;
|
|
595
|
+
# a name resolving to none keeps no module's place.
|
|
596
|
+
next unless @seen.add?(path ? label : [ :unresolved, name ])
|
|
597
|
+
|
|
598
|
+
macro = written || :include
|
|
599
|
+
singleton = ConcernMembership::SINGLETON_MACROS.include?(macro)
|
|
83
600
|
if ConcernMembership.excluded?(name)
|
|
84
601
|
# Hiding a concern hides what it declared. Only one whose file is
|
|
85
602
|
# here would have been read, so only that one is worth counting.
|
|
86
603
|
# A module mixed in from outside the class's file is not its concern to hide.
|
|
87
|
-
@hidden << name if path && !@paths.key?(
|
|
604
|
+
@hidden << name if path && !@paths.key?(label) && !singleton
|
|
88
605
|
next
|
|
89
606
|
end
|
|
90
607
|
|
|
91
|
-
data = nested ? introspect_nested(
|
|
608
|
+
data = nested ? introspect_nested(source, nested) : path && introspect(path)
|
|
92
609
|
if data.nil?
|
|
93
|
-
|
|
610
|
+
# A module a class extends itself with from a gem is no concern of the app's.
|
|
611
|
+
@unresolved << name unless singleton && path.nil?
|
|
94
612
|
next
|
|
95
613
|
end
|
|
96
614
|
|
|
97
|
-
label = nested ? nested.first : name
|
|
98
615
|
# A nested module's lines count from its own slice, so the ranges parse that.
|
|
99
616
|
tree = nested ? AstCache.parse_string(nested.last.slice).value : AstCache.parse(path).value
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Run.merge_calls(@included_calls,
|
|
103
|
-
|
|
617
|
+
source_key = nested ? "#{source}##{nested.first}" : path
|
|
618
|
+
block_calls, hooked, blocks, evals, unrun, bases = memo([ :included_calls, source_key, label, macro ]) { included_block_calls(tree, label, macro) }
|
|
619
|
+
Run.merge_calls(@included_calls, block_calls)
|
|
620
|
+
@includer_reads.merge(bases)
|
|
621
|
+
hooked = hooked.to_set
|
|
622
|
+
block_calls.each_value { |sites| sites.each { |site| @block_sites[site.__id__] ||= [ label, hooked.include?(site.__id__) ] if site } }
|
|
623
|
+
@mixins << [ label, macro, memo([ :module_defs, source_key, label, macro ]) { module_defs(tree, label, macro) }, @inside ]
|
|
104
624
|
bodies, hooks = method_bodies(data, macro)
|
|
105
|
-
own_lines, inner = memo([ :ranges, source_key, label ]) { own_and_nested_ranges(tree, label) }
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
@keys
|
|
109
|
-
|
|
625
|
+
own_lines, inner, concern = memo([ :ranges, source_key, label ]) { own_and_nested_ranges(tree, label) }
|
|
626
|
+
scope = [ singleton, inner + unrun, own_lines, hooks ]
|
|
627
|
+
# The class's own file was read with the class; only its callbacks go by owner.
|
|
628
|
+
keys = nested && source == @own_file ? @keys & [ :callbacks ] : @keys
|
|
629
|
+
shift = nested ? nested.last.location.start_line - 1 : 0
|
|
630
|
+
keys.each do |key|
|
|
631
|
+
applied(data[key], bodies, *scope).each { |entry| @collected[key] << tagged(in_file(entry, shift), label, hook: in_hook?(entry, hooks)) }
|
|
110
632
|
end
|
|
111
|
-
expand_called(tree, data, own_lines, label).each do |key, entries|
|
|
112
|
-
entries.each { |entry| @collected[key] << tagged(entry, label) }
|
|
633
|
+
expand_called(tree, data, [ own_lines, inner ], label, keys, [ path, shift ]).each do |key, entries|
|
|
634
|
+
entries.each { |entry| @collected[key] << tagged(in_file(entry, shift), label) }
|
|
113
635
|
end
|
|
114
636
|
|
|
115
|
-
|
|
116
|
-
|
|
637
|
+
joined = applied(data[:mixins], bodies, *scope, keep_called: true).filter_map do |mixin|
|
|
638
|
+
added = SingletonLookup.added_in(label, mixin[:location], bodies, blocks, evals)
|
|
639
|
+
[ mixin, added ] if SingletonLookup.joins?(mixin, !added.nil?)
|
|
640
|
+
end
|
|
641
|
+
hooked_mixins = memo([ :hook_mixins, source_key, label, macro ]) { hook_mixins(tree, label, macro) }
|
|
642
|
+
joined.concat(hooked_mixins.map { |mixin| [ mixin, SingletonLookup.in_hook(label) ] })
|
|
643
|
+
joined.sort_by.with_index { |(mixin, _), index| [ mixin[:location].to_i, index ] }.each do |mixin, added|
|
|
644
|
+
outer = @inside
|
|
645
|
+
@inside = [ added, mixin[:location] ] if added
|
|
646
|
+
walk([ dependency(mixin, macro, concern && !added) ], label, depth - 1, path)
|
|
647
|
+
@inside = outer
|
|
648
|
+
end
|
|
117
649
|
# ActiveSupport::Concern runs a concern's dependencies before it, so
|
|
118
650
|
# the walk's post-order is the order the class receives them.
|
|
119
|
-
@placement[label] = [ @top, @placement.size ]
|
|
651
|
+
@placement[label] = [ @top, @placement.size, path ]
|
|
120
652
|
end
|
|
121
653
|
end
|
|
122
654
|
|
|
@@ -150,12 +682,47 @@ module RailsAiContext
|
|
|
150
682
|
RailsAiContext.debug_fail(e, nil, label: "nested module #{name} in #{file}")
|
|
151
683
|
end
|
|
152
684
|
|
|
685
|
+
# The first candidate Ruby would find, a base module or a file.
|
|
686
|
+
def named(name, within)
|
|
687
|
+
found = memo([ :named, @dirs, name, within ]) { ConcernPaths.find_named(@root, name, within: within, dirs: @dirs) }
|
|
688
|
+
candidates = ConcernPaths.candidate_names(name, within)
|
|
689
|
+
extra = candidates.find { |candidate| @paths.key?(candidate) }
|
|
690
|
+
return found unless extra && (found.nil? || candidates.index(extra) <= candidates.index(found.first))
|
|
691
|
+
|
|
692
|
+
[ extra, @paths[extra] ]
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# A plugin's lib is on the load path, so a module it names can sit under the
|
|
696
|
+
# namer's own directory by its constant path, required by a glob.
|
|
697
|
+
def beside(file, name, within)
|
|
698
|
+
return nil unless file
|
|
699
|
+
|
|
700
|
+
memo([ :beside, file, name, within ]) do
|
|
701
|
+
dir = File.dirname(file)
|
|
702
|
+
candidates = ConcernPaths.candidate_names(name, within)
|
|
703
|
+
candidate, path = candidates.map { |each| [ each, File.join(dir, "#{each.underscore}.rb") ] }.find { |_, each| File.file?(each) }
|
|
704
|
+
# The file can sit under a shorter path than the constant it declares.
|
|
705
|
+
declared = path && Introspectors::DeclaredConstant.named(SafeFile.read(path), candidate)
|
|
706
|
+
[ candidates.include?(declared) ? declared : candidate, path ] if path
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# `Outer::Inner` with no file of its own, from Outer's file, which Zeitwerk loads it with.
|
|
711
|
+
def in_outer_file(name, within)
|
|
712
|
+
memo([ :outer, name, within ]) do
|
|
713
|
+
candidate, file = ConcernPaths.outer_named(@root, name, within: within, dirs: @dirs)
|
|
714
|
+
found = file && nested_module(file, candidate, nil)
|
|
715
|
+
[ file, found ] if found
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
153
719
|
def introspect_nested(file, (qualified, node))
|
|
154
|
-
memo("#{file}##{qualified}") { Introspectors::SourceIntrospector.walk_source(node.slice, @listeners) }
|
|
720
|
+
memo([ :walk, @listeners, "#{file}##{qualified}" ]) { Introspectors::SourceIntrospector.walk_source(node.slice, @listeners) }
|
|
155
721
|
end
|
|
156
722
|
|
|
157
723
|
def introspect(path)
|
|
158
|
-
|
|
724
|
+
# Keyed by the listener map too: callers reading other macros may share one cache.
|
|
725
|
+
memo([ :walk, @listeners, path ]) { read(path) }
|
|
159
726
|
end
|
|
160
727
|
|
|
161
728
|
# A concern too big or unreadable costs its own declarations, not the
|
|
@@ -199,7 +766,7 @@ module RailsAiContext
|
|
|
199
766
|
line = entry.is_a?(Hash) && entry[:location]
|
|
200
767
|
next false if line && own_lines && !own_lines.cover?(line)
|
|
201
768
|
|
|
202
|
-
enclosing =
|
|
769
|
+
enclosing = ConcernMacros.enclosing(bodies, line)
|
|
203
770
|
next true if enclosing.nil? && hooks.any? { |range| range.cover?(line) }
|
|
204
771
|
next !extended && inner.none? { |range| range.cover?(line) } if enclosing.nil?
|
|
205
772
|
next keep_called if calls?(enclosing.last)
|
|
@@ -210,62 +777,84 @@ module RailsAiContext
|
|
|
210
777
|
end
|
|
211
778
|
|
|
212
779
|
# Each called method is read again with that call's literal arguments.
|
|
213
|
-
def expand_called(tree, data, own_lines, label)
|
|
780
|
+
def expand_called(tree, data, (own_lines, inner), label, keys, (file, shift))
|
|
214
781
|
found = Hash.new { |hash, key| hash[key] = [] }
|
|
782
|
+
# A method declaring none of the keys expands to nothing; `:expanded` marks every call read.
|
|
783
|
+
declared = keys.include?(:expanded) ? nil : keys.flat_map { |key| Array(data[key]) }.filter_map { |entry| entry[:location] if entry.is_a?(Hash) }
|
|
215
784
|
Array(data[:methods]).each do |method|
|
|
216
785
|
name = method[:name].to_s
|
|
217
|
-
next unless
|
|
786
|
+
next unless method[:location]
|
|
787
|
+
next if declared && declared.none? { |line| (method[:location]..method[:end_location].to_i).cover?(line) }
|
|
788
|
+
next unless calls?(name)
|
|
218
789
|
next if method[:scope] == :class && ConcernMembership::MIXIN_HOOKS.include?(name)
|
|
219
790
|
next if own_lines && !own_lines.cover?(method[:location])
|
|
220
791
|
|
|
792
|
+
# The module around a nested one reads the nested one's defs too; the nested one's own walk wins.
|
|
793
|
+
at = [ file, method[:location] + shift ]
|
|
794
|
+
nested_def = inner.any? { |range| range.cover?(method[:location]) }
|
|
795
|
+
@own_defs << at unless nested_def
|
|
796
|
+
|
|
221
797
|
definition = Introspectors::AstWalk.each(tree).find do |node|
|
|
222
798
|
node.is_a?(Prism::DefNode) && node.name.to_s == name && node.location.start_line == method[:location]
|
|
223
799
|
end
|
|
224
800
|
next unless definition
|
|
225
801
|
|
|
226
|
-
call_sites.fetch(name)
|
|
227
|
-
|
|
228
|
-
# `:conditional` and `:foreign` come back as keys too, when asked for.
|
|
229
|
-
expansion.each { |key, entries| found[key].concat(Array(entries)) if @keys.include?(key) }
|
|
230
|
-
# The call site now reads as what the method declares; a caller that
|
|
231
|
-
# read the call itself as a declaration (`validates_translation` as a
|
|
232
|
-
# validation) drops that reading by its line.
|
|
233
|
-
found[:expanded] << { method: name, line: call.location.start_line } if call && @keys.include?(:expanded)
|
|
234
|
-
rescue StandardError => e
|
|
235
|
-
# One call the expansion cannot read costs that method, named as unread.
|
|
236
|
-
owner = Array(method[:owner]).join("::")
|
|
237
|
-
@unresolved |= [ "#{owner.empty? ? label : owner}##{name}" ]
|
|
238
|
-
RailsAiContext.debug_fail(e, nil, label: "called method expansion of #{name}")
|
|
802
|
+
expansion, read = ConcernMacros.expand_calls(definition, call_sites.fetch(name), keys, @listeners, includer: @includer_reads) do |entry, call|
|
|
803
|
+
[ at_call(entry, call, [ label, method[:location] ]) ]
|
|
239
804
|
end
|
|
805
|
+
expansion.each { |key, entries| found[key].concat(nested_def ? entries.map { |entry| entry.is_a?(Hash) ? entry.merge(nested_def: at) : entry } : entries) }
|
|
806
|
+
next if read
|
|
807
|
+
|
|
808
|
+
owner = Array(method[:owner]).join("::")
|
|
809
|
+
@unresolved |= [ "#{owner.empty? ? label : owner}##{name}" ]
|
|
240
810
|
end
|
|
241
811
|
found
|
|
242
812
|
end
|
|
243
813
|
|
|
244
|
-
def
|
|
814
|
+
def own_node(tree, name)
|
|
245
815
|
short = name.to_s.split("::").last.to_s
|
|
246
|
-
|
|
816
|
+
Introspectors::DeclaredConstant.definition(Introspectors::AstWalk.each(tree).select do |node|
|
|
247
817
|
constant_node?(node) && node.constant_path.slice.split("::").last.casecmp?(short)
|
|
248
|
-
end
|
|
249
|
-
|
|
818
|
+
end)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def own_and_nested_ranges(tree, name)
|
|
822
|
+
own = own_node(tree, name)
|
|
823
|
+
return [ nil, [], false ] unless own
|
|
250
824
|
|
|
251
825
|
# A class nested deeper sits inside one of these ranges already.
|
|
252
826
|
ranges = Introspectors::AstWalk.each(own).filter_map do |node|
|
|
253
827
|
node.location.start_line..node.location.end_line if !node.equal?(own) && constant_node?(node)
|
|
254
828
|
end
|
|
255
|
-
[ own.location.start_line..own.location.end_line, ranges ]
|
|
829
|
+
[ own.location.start_line..own.location.end_line, ranges, own.body && SingletonLookup.concern?(own) ]
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# A Concern keeps an include or prepend written in its body as a dependency, and mixes it into
|
|
833
|
+
# the class the way the class mixes in the Concern.
|
|
834
|
+
def dependency(mixin, macro, in_concern_body)
|
|
835
|
+
mixable = %i[include prepend]
|
|
836
|
+
return mixin unless in_concern_body && mixable.include?(macro) && mixable.include?(mixin[:macro] || :include)
|
|
837
|
+
|
|
838
|
+
mixin.merge(macro: macro)
|
|
256
839
|
end
|
|
257
840
|
|
|
258
841
|
def constant_node?(node)
|
|
259
842
|
node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode)
|
|
260
843
|
end
|
|
261
844
|
|
|
262
|
-
# Receiverless calls in `included do` outside any method
|
|
263
|
-
#
|
|
845
|
+
# Receiverless calls in `included do` outside any method are the includer's;
|
|
846
|
+
# the ids of a plain mixin hook's calls come back apart, as it reruns per include.
|
|
847
|
+
# Also the lines of those blocks, of the `base.class_eval` blocks in hooks, and of the
|
|
848
|
+
# Concern blocks this way of mixing in does not run, and the ids of a hook's reads of its base.
|
|
264
849
|
def included_block_calls(tree, name, macro = :include)
|
|
265
850
|
short = name.to_s.split("::").last.to_s
|
|
266
851
|
block = ConcernMembership::CONCERN_BLOCKS[macro]
|
|
267
|
-
runs = ConcernMembership::HOOKS_BY_MACRO.fetch(macro, [])
|
|
268
852
|
found = {}
|
|
853
|
+
hooked = {}
|
|
854
|
+
ranges = []
|
|
855
|
+
evals = []
|
|
856
|
+
unrun = []
|
|
857
|
+
bases = []
|
|
269
858
|
visit = lambda do |node, owner|
|
|
270
859
|
case node
|
|
271
860
|
when Prism::ClassNode, Prism::ModuleNode
|
|
@@ -273,39 +862,40 @@ module RailsAiContext
|
|
|
273
862
|
when Prism::CallNode
|
|
274
863
|
if node.name == block && node.receiver.nil? && node.block && owner.to_s.casecmp?(short)
|
|
275
864
|
Introspectors::SourceIntrospector.calls_outside_methods(node.block, found)
|
|
865
|
+
ranges << (node.location.start_line..node.location.end_line)
|
|
866
|
+
return
|
|
867
|
+
end
|
|
868
|
+
if node.receiver.nil? && node.block && ConcernMembership::CONCERN_BLOCKS.value?(node.name) && owner.to_s.casecmp?(short)
|
|
869
|
+
unrun << (node.location.start_line..node.location.end_line)
|
|
276
870
|
return
|
|
277
871
|
end
|
|
278
872
|
when Prism::DefNode
|
|
279
|
-
return hook_calls(node,
|
|
873
|
+
return hook_calls(node, hooked, evals, bases) if SingletonLookup.hook?(node, macro) && owner.to_s.casecmp?(short)
|
|
280
874
|
end
|
|
281
875
|
node.child_nodes.compact.each { |child| visit.call(child, owner) }
|
|
282
876
|
end
|
|
283
877
|
visit.call(tree, nil)
|
|
284
|
-
found
|
|
878
|
+
[ Run.merge_calls(found, hooked), hooked.values.flatten.map(&:__id__), ranges, evals, unrun, bases ]
|
|
285
879
|
rescue StandardError => e
|
|
286
|
-
RailsAiContext.debug_fail(e, {}, label: "included block calls of #{name}")
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def mixin_hook?(node, runs)
|
|
291
|
-
node.receiver.is_a?(Prism::SelfNode) && runs.include?(node.name.to_s)
|
|
880
|
+
RailsAiContext.debug_fail(e, [ {}, [], [], [], [], [] ], label: "included block calls of #{name}")
|
|
292
881
|
end
|
|
293
882
|
|
|
294
883
|
# A hook runs in the includer: its receiverless calls (inside
|
|
295
884
|
# `base.class_eval`) and its calls sent to the includer (`base.x`) are
|
|
296
885
|
# calls the includer makes.
|
|
297
|
-
def hook_calls(node, found)
|
|
886
|
+
def hook_calls(node, found, evals, bases = [])
|
|
298
887
|
return unless node.body
|
|
299
888
|
|
|
300
889
|
Introspectors::SourceIntrospector.calls_outside_methods(node.body, found)
|
|
301
|
-
|
|
302
|
-
return unless
|
|
890
|
+
base = SingletonLookup.base_reader(node)
|
|
891
|
+
return unless base
|
|
303
892
|
|
|
304
893
|
Introspectors::AstWalk.each(node.body).each do |call|
|
|
305
|
-
|
|
306
|
-
next unless call.
|
|
894
|
+
bases << call.__id__ if base.call(call)
|
|
895
|
+
next unless call.is_a?(Prism::CallNode) && base.call(call.receiver)
|
|
307
896
|
|
|
308
897
|
(found[call.name.to_s] ||= []) << call
|
|
898
|
+
evals << (call.location.start_line..call.location.end_line) if EVALS.include?(call.name) && call.block
|
|
309
899
|
end
|
|
310
900
|
end
|
|
311
901
|
|
|
@@ -323,7 +913,7 @@ module RailsAiContext
|
|
|
323
913
|
# Method name => its call sites; nil stands for a call whose arguments
|
|
324
914
|
# are not known (a caller that names the methods only).
|
|
325
915
|
def call_sites
|
|
326
|
-
@call_sites ||= Run.merge_calls({}, @calls&.
|
|
916
|
+
@call_sites ||= Run.merge_calls(Run.merge_calls({}, @calls&.sites_by_name), @known).transform_values { |sites| sites.uniq(&:__id__) }
|
|
327
917
|
end
|
|
328
918
|
|
|
329
919
|
# Folds `more` - a Hash of call sites, or bare names - into `into`.
|
|
@@ -336,13 +926,94 @@ module RailsAiContext
|
|
|
336
926
|
into
|
|
337
927
|
end
|
|
338
928
|
|
|
339
|
-
|
|
340
|
-
|
|
929
|
+
# A nested module's walk counts lines from its own slice; the entry carries the file's.
|
|
930
|
+
def in_file(entry, shift)
|
|
931
|
+
return entry unless shift.positive? && entry.is_a?(Hash) && entry[:location]
|
|
932
|
+
|
|
933
|
+
ConcernMacros.moved(entry) { |line| line + shift }
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# A mixin hook runs again for a subclass that includes the module again; a Concern's block does not.
|
|
937
|
+
def tagged(entry, concern_name, hook: false)
|
|
938
|
+
return entry unless entry.is_a?(Hash)
|
|
939
|
+
|
|
940
|
+
hook ? entry.merge(from_concern: concern_name, hook: true) : entry.merge(from_concern: concern_name)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# The call and the body it runs travel with the entry; the class places
|
|
944
|
+
# it once every walk is known.
|
|
945
|
+
def at_call(entry, call, definer)
|
|
946
|
+
call && entry.is_a?(Hash) ? entry.merge(site: call, definer: definer) : entry
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def module_defs(tree, label, macro)
|
|
950
|
+
SingletonLookup.module_defs(own_node(tree, label), label, macro)
|
|
951
|
+
rescue StandardError => e
|
|
952
|
+
RailsAiContext.debug_fail(e, SingletonLookup.module_defs(nil, label, macro), label: "class methods of #{label}")
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def hook_mixins(tree, label, macro)
|
|
956
|
+
SingletonLookup.hook_mixins(own_node(tree, label), label, macro)
|
|
957
|
+
rescue StandardError => e
|
|
958
|
+
RailsAiContext.debug_fail(e, [], label: "hook mixins of #{label}")
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def in_hook?(entry, hooks)
|
|
962
|
+
line = entry.is_a?(Hash) && entry[:location]
|
|
963
|
+
line && hooks.any? { |range| range.cover?(line) }
|
|
341
964
|
end
|
|
342
965
|
end
|
|
343
966
|
|
|
344
967
|
module_function
|
|
345
968
|
|
|
969
|
+
# What the method `definition` declares at each call in `sites`, by key, each entry placed by the block
|
|
970
|
+
# ([entry, call] in, entries out), with the calls it read as `:expanded`; false second when a call was not.
|
|
971
|
+
def expand_calls(definition, sites, keys, listeners, includer: nil)
|
|
972
|
+
found = Hash.new { |hash, key| hash[key] = [] }
|
|
973
|
+
read = true
|
|
974
|
+
sites.each do |call|
|
|
975
|
+
# `:conditional` and `:foreign` come back as keys too, when asked for.
|
|
976
|
+
Introspectors::CallSiteExpansion.entries(definition, call, listeners, includer: includer).each do |key, entries|
|
|
977
|
+
found[key].concat(Array(entries).flat_map { |entry| yield entry, call }) if keys.include?(key)
|
|
978
|
+
end
|
|
979
|
+
# The call now reads as what the method declares; a caller that read the call itself as a
|
|
980
|
+
# declaration (`validates_translation` as a validation) drops that reading by its line.
|
|
981
|
+
found[:expanded].concat(yield({ method: definition.name.to_s, line: call.location.start_line }, call)) if call && keys.include?(:expanded)
|
|
982
|
+
rescue StandardError => e
|
|
983
|
+
# One call the expansion cannot read costs that call.
|
|
984
|
+
read = false
|
|
985
|
+
RailsAiContext.debug_fail(e, nil, label: "called method expansion of #{definition.name}")
|
|
986
|
+
end
|
|
987
|
+
[ found, read ]
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# [line, order, at, entry] for each entry a concern's block declares: at the `include` reaching that
|
|
991
|
+
# concern, then by the concern's place in Ruby's order, then by `at` (the block yields it).
|
|
992
|
+
def at_includes(entries, placement, mixins)
|
|
993
|
+
line_of = Array(mixins).reverse.to_h { |mixin| [ mixin[:name], mixin[:location].to_i ] }
|
|
994
|
+
entries.map do |entry|
|
|
995
|
+
top, order = placement[entry[:from_concern]]
|
|
996
|
+
[ line_of[top].to_i, order.to_i, yield(entry), entry ]
|
|
997
|
+
end
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
# The entries of [line, order, at, entry] tuples in the order Rails adds them; a tie keeps its place.
|
|
1001
|
+
def in_include_order(placed)
|
|
1002
|
+
placed.each_with_index.sort_by { |(line, order, at, _), index| [ line, order, at, index ] }.map { |(_, _, _, entry), _| entry }
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# `entry` with every line it carries mapped through the block.
|
|
1006
|
+
def moved(entry)
|
|
1007
|
+
moved = entry.merge(location: yield(entry[:location]))
|
|
1008
|
+
moved[:proc_lines] = entry[:proc_lines].map { |line| yield(line) } if entry[:proc_lines].is_a?(Array)
|
|
1009
|
+
moved
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
# The innermost of `bodies`, [range, name] pairs, around `line`.
|
|
1013
|
+
def enclosing(bodies, line)
|
|
1014
|
+
line && bodies.select { |range, _| range.cover?(line) }.min_by { |range, _| range.size }
|
|
1015
|
+
end
|
|
1016
|
+
|
|
346
1017
|
# @param root [String] application root
|
|
347
1018
|
# @param mixins [Array<Hash>] MixinsListener records
|
|
348
1019
|
# @param keys [Array<Symbol>] payload keys to collect
|
|
@@ -350,7 +1021,7 @@ module RailsAiContext
|
|
|
350
1021
|
# resolves to this one's concerns directory
|
|
351
1022
|
# @param within [String, nil] the enclosing constant of the class, for a
|
|
352
1023
|
# namespace-relative `include`
|
|
353
|
-
# @param calls [
|
|
1024
|
+
# @param calls [SingletonLookup, nil] the class methods the including class
|
|
354
1025
|
# calls in its body; nil counts none
|
|
355
1026
|
# @param cache [Hash, nil] a caller-owned store keyed by concern file, so
|
|
356
1027
|
# one run walks a file once however many classes include it. The caller
|
|
@@ -361,23 +1032,31 @@ module RailsAiContext
|
|
|
361
1032
|
# the kind from outside its file, read from the file they name
|
|
362
1033
|
# @param listeners [Hash] the listener map each concern file is walked
|
|
363
1034
|
# with; it must carry `mixins` for the walk to follow nested concerns
|
|
364
|
-
# @return [
|
|
365
|
-
# collected entries per key, the names whose file could not be read,
|
|
366
|
-
# names `excluded_concerns` hid that the walk would otherwise have
|
|
367
|
-
# the methods `included` blocks call with their call sites, and for
|
|
368
|
-
# concern read the top-level mixin that reached it
|
|
369
|
-
# order Ruby adds them
|
|
370
|
-
# back because nothing it knew of calls them
|
|
1035
|
+
# @return [Collected] its fields, in order:
|
|
1036
|
+
# the collected entries per key, the names whose file could not be read,
|
|
1037
|
+
# the names `excluded_concerns` hid that the walk would otherwise have
|
|
1038
|
+
# read, the methods `included` blocks call with their call sites, and for
|
|
1039
|
+
# each concern read the top-level mixin that reached it, its place in
|
|
1040
|
+
# the order Ruby adds them and its file, the methods whose declarations the walk held
|
|
1041
|
+
# back because nothing it knew of calls them, the concern each `included`
|
|
1042
|
+
# block call site belongs to and whether it is a plain hook, by the site's
|
|
1043
|
+
# object id, and for each module read [label, macro, the class methods it gives
|
|
1044
|
+
# (`SingletonLookup.module_defs`), and [added, line] when code a module runs later adds it
|
|
1045
|
+
# (`SingletonLookup.added_in`)]
|
|
371
1046
|
def collect(root, mixins, keys:, prefer: nil, within: nil, cache: nil, calls: nil,
|
|
372
1047
|
listeners: Introspectors::SourceIntrospector::LISTENER_MAP, extra: [], file: nil)
|
|
373
|
-
|
|
374
|
-
|
|
1048
|
+
# A module the class extends itself with gives it class methods, though no ancestor.
|
|
1049
|
+
walked = Array(mixins).select { |mixin| SingletonLookup.joins?(mixin, true) } +
|
|
1050
|
+
extra.map { |mixin| { name: mixin.name, macro: mixin.macro, path: mixin.path } }
|
|
1051
|
+
return Collected.new({}, [], [], {}, {}, Set.new, {}, []) if walked.empty?
|
|
375
1052
|
|
|
376
1053
|
# Most walks never look at the class's calls, so a base's walk is the
|
|
377
1054
|
# same for every subclass: kept in the caller's per-run cache.
|
|
378
1055
|
memo_key = cache && [ :collect, root.to_s, mixins, keys, prefer, within, listeners, extra, file ]
|
|
1056
|
+
# A walk is the class's own only where it asked about a method the class itself calls.
|
|
1057
|
+
own_calls = memo_key ? Run.merge_calls({}, calls&.sites_by_name).keys.to_set : Set.new
|
|
379
1058
|
if memo_key && (consulted, result = cache[memo_key])
|
|
380
|
-
return fresh(result)
|
|
1059
|
+
return fresh(result) unless consulted.intersect?(own_calls)
|
|
381
1060
|
end
|
|
382
1061
|
|
|
383
1062
|
# Resolved once per call and held by the run: the configured paths
|
|
@@ -385,32 +1064,33 @@ module RailsAiContext
|
|
|
385
1064
|
# hook.
|
|
386
1065
|
dirs = ConcernPaths.ordered_dirs(root.to_s, prefer)
|
|
387
1066
|
run = Run.new(root.to_s, dirs, keys, cache, listeners, calls, extra, file)
|
|
388
|
-
walked = Array(mixins).select { |mixin| mixin[:ancestor] } + extra.map { |mixin| { name: mixin.name, macro: mixin.macro } }
|
|
389
1067
|
run.walk(walked, within, MAX_DEPTH)
|
|
390
|
-
|
|
391
|
-
# An `included do` can call a method
|
|
392
|
-
#
|
|
1068
|
+
consulted = run.consulted.dup
|
|
1069
|
+
# An `included do` can call a method a concern read before the block did, so walk
|
|
1070
|
+
# again with the known calls until no method the walk asked about has a new call.
|
|
1071
|
+
known = {}
|
|
393
1072
|
MAX_DEPTH.times do
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
depends_on_calls = true
|
|
1073
|
+
asked = run.skipped_methods | run.consulted
|
|
1074
|
+
break if run.included_calls.none? { |name, sites| asked.include?(name) && (sites.map(&:__id__) - Array(known[name]).map(&:__id__)).any? }
|
|
397
1075
|
|
|
398
1076
|
known = run.included_calls
|
|
399
|
-
run = Run.new(root.to_s, dirs, keys, cache, listeners,
|
|
400
|
-
extra, file)
|
|
1077
|
+
run = Run.new(root.to_s, dirs, keys, cache, listeners, calls, extra, file, known: known)
|
|
401
1078
|
run.walk(walked, within, MAX_DEPTH)
|
|
1079
|
+
consulted.merge(run.consulted)
|
|
402
1080
|
end
|
|
403
1081
|
|
|
404
|
-
result =
|
|
405
|
-
|
|
1082
|
+
result = Collected.new(run.collected, run.unresolved, run.hidden, run.included_calls, run.placement, run.skipped_methods,
|
|
1083
|
+
run.block_sites, run.mixins)
|
|
1084
|
+
# The repeat walks follow the concerns' own included calls, the same for every class.
|
|
1085
|
+
cache[memo_key] = [ consulted, fresh(result) ] if memo_key && !consulted.intersect?(own_calls)
|
|
406
1086
|
result
|
|
407
1087
|
end
|
|
408
1088
|
|
|
409
1089
|
# A copy a caller may change without changing the cached walk.
|
|
410
1090
|
def fresh(result)
|
|
411
|
-
collected
|
|
412
|
-
|
|
413
|
-
|
|
1091
|
+
Collected.new(result.collected.transform_values { |entries| entries.map { |entry| entry.is_a?(Hash) ? entry.dup : entry } },
|
|
1092
|
+
result.unread.dup, result.hidden.dup, result.included_calls.transform_values(&:dup), result.placement.dup,
|
|
1093
|
+
result.skipped.dup, result.block_sites.dup, result.mixins.dup)
|
|
414
1094
|
end
|
|
415
1095
|
private_class_method :fresh
|
|
416
1096
|
end
|