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.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. 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
- attr_reader :unresolved, :hidden, :included_calls, :skipped_methods, :placement
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
- {}.merge(@collected)
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, and whether
26
- # the class calls any of them: a walk it calls none of answers the same
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 calls_any_consulted?
31
- @consulted.any? { |name| called.include?(name) }
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
- if names.first.is_a?(Hash)
67
- names.each { |mixin| @macros[mixin[:name]] ||= mixin[:macro] }
68
- names = names.map { |mixin| mixin[:name] }.uniq
69
- end
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
- nested = file && nested_module(file, name, within)
78
- path =
79
- if nested then file
80
- elsif @paths.key?(name) then @paths[name]
81
- else ConcernPaths.find_file(@root, name, within: within, dirs: @dirs)
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?(name)
604
+ @hidden << name if path && !@paths.key?(label) && !singleton
88
605
  next
89
606
  end
90
607
 
91
- data = nested ? introspect_nested(file, nested) : path && introspect(path)
608
+ data = nested ? introspect_nested(source, nested) : path && introspect(path)
92
609
  if data.nil?
93
- @unresolved << name
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
- macro = @macros[name] || :include
101
- source_key = nested ? "#{file}##{nested.first}" : path
102
- Run.merge_calls(@included_calls,
103
- memo([ :included_calls, source_key, label, macro ]) { included_block_calls(tree, label, macro) })
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
- extended = @extended.include?(name)
107
- scope = [ extended, inner, own_lines, hooks ]
108
- @keys.each do |key|
109
- applied(data[key], bodies, *scope).each { |entry| @collected[key] << tagged(entry, label) }
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
- mixins = applied(data[:mixins], bodies, *scope, keep_called: true)
116
- walk(mixins.select { |mixin| mixin[:ancestor] }, label, depth - 1, path)
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
- memo(path) { read(path) }
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 = line && bodies.select { |range, _| range.cover?(line) }.min_by { |range, _| range.size }
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 calls?(name) && method[:location]
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).each do |call|
227
- expansion = Introspectors::CallSiteExpansion.entries(definition, call, @listeners)
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 own_and_nested_ranges(tree, name)
814
+ def own_node(tree, name)
245
815
  short = name.to_s.split("::").last.to_s
246
- own = Introspectors::AstWalk.each(tree).find do |node|
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
- return [ nil, [] ] unless own
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 run in the includer's
263
- # class, so the includer calls them.
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, found) if mixin_hook?(node, runs) && owner.to_s.casecmp?(short)
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
- param = node.parameters&.requireds&.first
302
- return unless param.respond_to?(:name)
890
+ base = SingletonLookup.base_reader(node)
891
+ return unless base
303
892
 
304
893
  Introspectors::AstWalk.each(node.body).each do |call|
305
- next unless call.is_a?(Prism::CallNode) && call.receiver.is_a?(Prism::LocalVariableReadNode)
306
- next unless call.receiver.name == param.name
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&.call).transform_values { |sites| sites.uniq(&:__id__) }
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
- def tagged(entry, concern_name)
340
- entry.is_a?(Hash) ? entry.merge(from_concern: concern_name) : entry
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 [#call, nil] answers the class methods the including class
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 [Array(Hash, Array<String>, Array<String>, Hash, Hash, Set)] the
365
- # collected entries per key, the names whose file could not be read, the
366
- # names `excluded_concerns` hid that the walk would otherwise have read,
367
- # the methods `included` blocks call with their call sites, and for each
368
- # concern read the top-level mixin that reached it and its place in the
369
- # order Ruby adds them, and the methods whose declarations the walk held
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
- names = ConcernMembership.mixin_names(mixins) | extra.map(&:name)
374
- return [ {}, [], [], {}, {}, Set.new ] if names.empty?
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) if consulted.empty? || !consulted.intersect?(Run.merge_calls({}, calls&.call).keys.to_set)
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
- depends_on_calls = run.calls_any_consulted?
391
- # An `included do` can call a method whose macros an earlier concern held back,
392
- # so walk again with the known calls until nothing held back is called.
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
- break unless run.skipped_methods.intersect?(run.included_calls.keys.to_set)
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, -> { Run.merge_calls(Run.merge_calls({}, calls&.call), known) },
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 = [ run.collected, run.unresolved, run.hidden, run.included_calls, run.placement, run.skipped_methods ]
405
- cache[memo_key] = [ run.consulted, fresh(result) ] if memo_key && !depends_on_calls
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, unresolved, hidden, included, placement, skipped = result
412
- [ collected.transform_values { |entries| entries.map { |entry| entry.is_a?(Hash) ? entry.dup : entry } },
413
- unresolved.dup, hidden.dup, included.transform_values(&:dup), placement.dup, skipped.dup ]
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