rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "rule_walk"
6
+ require_relative "../../scope/resolution_chain"
6
7
  require_relative "../../source/constant_path"
7
8
  require_relative "../../source/node_children"
8
9
 
@@ -37,36 +38,54 @@ module Rigor
37
38
  # `def` that sits directly in a class / module body, never one nested in another `def`. On the
38
39
  # shared full DFS that prune becomes the `:inside_def` gate; the enclosing class / module name stack
39
40
  # the legacy walk threaded as `qualified_prefix` is now `context.qualified_prefix`.
40
- NODE_CLASSES = [Prism::DefNode].freeze
41
+ #
42
+ # ADR-125 — the walk also visits class-body `CallNode`s (a `def` body is gated off), to record the
43
+ # macro calls whose literal arguments name a method (`before_action :load_roles`).
44
+ NODE_CLASSES = [Prism::DefNode, Prism::CallNode].freeze
41
45
  RULE_WALK_GATES = %i[inside_def detached_ivar_facet].freeze
42
46
 
43
- # Returns `Hash[class_name (String) => Hash[ivar_name (Symbol) => Array<{node:, type:, declared:}>]]`.
44
- # Empty when the tree has no qualifying writes. `declared` is the translated type of the class's RBS
45
- # declaration of the ivar — an `@x: T` member or the slot an `attr_*` member implies, own or
46
- # inherited — and nil when the RBS declares none.
47
+ # Returns `Hash[class_name (String) => Hash[ivar_name (Symbol) => Array<{node:, type:, declared:,
48
+ # method_name:, visibility:, per_invocation:, action:}>]]`. Empty when the tree has no qualifying writes.
49
+ # `declared` is the translated type of the class's RBS declaration of the ivar — an `@x: T` member or
50
+ # the slot an `attr_*` member implies, own or inherited — and nil when the RBS declares none.
51
+ #
52
+ # ADR-125 — `method_name` is the enclosing `def`'s name and `visibility` its instance visibility
53
+ # (`:public` / `:protected` / `:private`, or nil when the discovery table has no settled answer).
54
+ # `per_invocation` is whether the class is, or descends from, a class a loaded plugin declares in
55
+ # `per_invocation_classes:`. `action` is whether the enclosing method is one of that class's entry
56
+ # points: public, and named by none of the declaring plugin's `per_invocation_non_action_macros:` in a
57
+ # class body of this file (`before_action :load_roles`, `helper_method :current_roles`).
47
58
  def initialize(scope_index)
48
59
  @scope_index = scope_index
49
60
  @accumulator = {}
61
+ @per_invocation = {}
62
+ @macro_arguments = {}
50
63
  end
51
64
 
52
65
  # Legacy single-collector walk — kept as the oracle the ADR-53 Track B equivalence harness compares
53
66
  # {RuleWalk} against; deleted when Track B completes.
54
67
  def collect(root)
55
68
  walk(root, [])
56
- @accumulator.transform_values(&:freeze).freeze
69
+ results
57
70
  end
58
71
 
59
72
  # {RuleWalk} entry point: the legacy walk's per-`def` logic, invoked at each class-/module-body `def`
60
73
  # under the shared traversal contract. `collect_def_writes`' verbatim body reads the enclosing class
61
74
  # prefix from `context.qualified_prefix`.
62
- def visit(def_node, context)
63
- collect_def_writes(def_node, context.qualified_prefix)
75
+ def visit(node, context)
76
+ if node.is_a?(Prism::CallNode)
77
+ record_macro_call(node, context.qualified_prefix)
78
+ else
79
+ collect_def_writes(node, context.qualified_prefix)
80
+ end
64
81
  end
65
82
 
66
83
  # The accumulated result, frozen the same way `#collect` returns it — used by {RuleWalk}-driven
67
84
  # callers after the walk completes.
68
85
  def results
69
- @accumulator.transform_values(&:freeze).freeze
86
+ @accumulator.to_h do |class_name, writes_by_ivar|
87
+ [class_name, writes_by_ivar.transform_values { |writes| with_actions(class_name, writes) }.freeze]
88
+ end.freeze
70
89
  end
71
90
 
72
91
  private
@@ -87,29 +106,71 @@ module Rigor
87
106
  when Prism::DefNode
88
107
  collect_def_writes(node, qualified_prefix)
89
108
  return
109
+ when Prism::CallNode
110
+ record_macro_call(node, qualified_prefix)
90
111
  end
91
112
 
92
113
  node.rigor_each_child { |child| walk(child, qualified_prefix) }
93
114
  end
94
115
 
116
+ # A receiverless (or `self.`) call in a class body, with the method names its literal Symbol / String
117
+ # arguments spell. Kept for every such call: which macro names count is the registry's answer, read
118
+ # once per class when the writes are finalised.
119
+ def record_macro_call(call_node, qualified_prefix)
120
+ return if qualified_prefix.empty?
121
+ return unless call_node.receiver.nil? || call_node.receiver.is_a?(Prism::SelfNode)
122
+
123
+ names = (call_node.arguments&.arguments || []).filter_map { |argument| literal_method_name(argument) }
124
+ return if names.empty?
125
+
126
+ ((@macro_arguments[qualified_prefix.join("::")] ||= {})[call_node.name] ||= []).concat(names)
127
+ end
128
+
129
+ # A Symbol / String literal's name, or the name of a `def` passed inline (`helper_method def
130
+ # current_user … end`). A literal with bytes no Symbol can hold (`before_action "\xff"`, #1821) names no
131
+ # method, so it answers nil.
132
+ def literal_method_name(argument)
133
+ return argument.name if argument.is_a?(Prism::DefNode) && argument.receiver.nil?
134
+ return nil unless argument.is_a?(Prism::SymbolNode) || argument.is_a?(Prism::StringNode)
135
+
136
+ text = argument.unescaped
137
+ text.valid_encoding? ? text.to_sym : nil
138
+ end
139
+
140
+ # Each write with `action` set (see {#initialize}). A frozen copy, so `#results` stays repeatable.
141
+ def with_actions(class_name, writes)
142
+ macros = @per_invocation[class_name]
143
+ excluded = macros ? non_action_names(class_name, macros) : nil
144
+ writes.map do |write|
145
+ action = !excluded.nil? && write[:visibility] == :public && !excluded.include?(write[:method_name])
146
+ write.merge(action: action).freeze
147
+ end.freeze
148
+ end
149
+
150
+ def non_action_names(class_name, macros)
151
+ (@macro_arguments[class_name] || {}).each_with_object(Set.new) do |(macro, names), set|
152
+ set.merge(names) if macros.include?(macro)
153
+ end
154
+ end
155
+
95
156
  def collect_def_writes(def_node, qualified_prefix)
96
157
  return if def_node.body.nil? || qualified_prefix.empty?
97
158
  return if def_node.receiver.is_a?(Prism::SelfNode)
98
159
 
99
160
  class_name = qualified_prefix.join("::")
100
- gather_writes(def_node.body, class_name)
161
+ gather_writes(def_node.body, class_name, def_node.name)
101
162
  end
102
163
 
103
- def gather_writes(node, class_name)
164
+ def gather_writes(node, class_name, method_name)
104
165
  return unless node.is_a?(Prism::Node)
105
166
 
106
- record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
167
+ record_write(node, class_name, method_name) if node.is_a?(Prism::InstanceVariableWriteNode)
107
168
  return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
108
169
 
109
- node.rigor_each_child { |child| gather_writes(child, class_name) }
170
+ node.rigor_each_child { |child| gather_writes(child, class_name, method_name) }
110
171
  end
111
172
 
112
- def record_write(node, class_name)
173
+ def record_write(node, class_name, method_name)
113
174
  scope = @scope_index[node]
114
175
  return if scope.nil?
115
176
 
@@ -117,7 +178,73 @@ module Rigor
117
178
  declared = declared_ivar(scope, class_name, node.name)
118
179
  @accumulator[class_name] ||= {}
119
180
  @accumulator[class_name][node.name] ||= []
120
- @accumulator[class_name][node.name] << { node: node, type: rvalue_type, declared: declared }
181
+ @accumulator[class_name][node.name] << {
182
+ node: node, type: rvalue_type, declared: declared, method_name: method_name,
183
+ visibility: method_visibility(scope, class_name, method_name),
184
+ per_invocation: !per_invocation_macros(scope, class_name).nil?
185
+ }
186
+ end
187
+
188
+ # The enclosing `def`'s instance visibility, or nil when it is not a settled fact: a slot the
189
+ # discovery table does not hold, or one a later `define_method` / alias / unplaceable write contests
190
+ # (#2013). `initialize` and the other lifecycle hooks are private whatever the table says.
191
+ def method_visibility(scope, class_name, method_name)
192
+ return :private if CheckRules::IMPLICITLY_PRIVATE_METHODS.include?(method_name)
193
+ return nil if scope.discovery.contested?(:discovered_method_visibilities, [class_name, method_name])
194
+
195
+ scope.discovered_method_visibility(class_name, method_name)
196
+ end
197
+
198
+ # ADR-125 — when `class_name` is, or descends from, a class a loaded plugin declares per-invocation, the
199
+ # non-action macros its declaring manifests list (a Set, possibly empty); nil otherwise. Read off the
200
+ # class's instance {Scope::ResolutionChain}, so a project superclass chain
201
+ # (`UsersController < ApplicationController < ActionController::Base`) is followed as Ruby resolves it.
202
+ # A project entry matches by name. An external superclass matches through the first of its candidate
203
+ # spellings the environment knows, by name or by an RBS-known `:equal` / `:subclass` ordering. When the
204
+ # environment knows none of them (rigor-actionpack leaves `ActionController::Base` undeclared on
205
+ # purpose), a candidate that is literally a declared name matches, unless some candidate names a class
206
+ # the project itself declares: then the spelling may resolve there instead. An ambiguous spelling (no
207
+ # candidates) and a truncated chain answer nil: the carve-out only ever withholds a finding, so an
208
+ # unproved descent keeps the rule firing. Memoised per class.
209
+ def per_invocation_macros(scope, class_name)
210
+ return @per_invocation[class_name] if @per_invocation.key?(class_name)
211
+
212
+ @per_invocation[class_name] = matched_per_invocation_macros(scope, class_name)
213
+ end
214
+
215
+ def matched_per_invocation_macros(scope, class_name)
216
+ environment = scope.environment
217
+ registry = environment&.plugin_registry
218
+ return nil if registry.nil? || registry.per_invocation_classes.empty?
219
+
220
+ chain = Scope::ResolutionChain.for(scope, class_name, :instance, :methods)
221
+ return nil if chain.truncated?
222
+
223
+ declared = chain.search(scope) { |entry| matched_declarations(entry, registry, scope) }
224
+ declared&.each_with_object(Set.new) { |name, set| set.merge(registry.per_invocation_non_action_macros(name)) }
225
+ end
226
+
227
+ # The declared per-invocation names `entry` matches, or nil (so {ResolutionChain#search} moves on).
228
+ def matched_declarations(entry, registry, scope)
229
+ return (registry.per_invocation_class?(entry.name.to_s) ? [entry.name.to_s] : nil) unless entry.external?
230
+ return nil unless entry.superclass_edge
231
+
232
+ environment = scope.environment
233
+ names = entry.candidates.map { |name| name.to_s.delete_prefix("::") }
234
+ known = names.find { |name| environment.class_known?(name) }
235
+ return literal_declarations(names, registry, scope) if known.nil?
236
+
237
+ matched = registry.per_invocation_classes.select do |declared|
238
+ %i[equal subclass].include?(environment.class_ordering(known, declared))
239
+ end
240
+ matched.empty? ? nil : matched
241
+ end
242
+
243
+ def literal_declarations(names, registry, scope)
244
+ return nil if names.any? { |name| scope.discovered_classes.key?(name) }
245
+
246
+ matched = names.select { |name| registry.per_invocation_class?(name) }
247
+ matched.empty? ? nil : matched
121
248
  end
122
249
 
123
250
  def declared_ivar(scope, class_name, ivar_name)
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../../inference/scope_indexer"
6
6
  require_relative "../../source/node_children"
7
+ require_relative "rebinding_block_callees"
7
8
 
8
9
  module Rigor
9
10
  module Analysis
@@ -17,6 +18,13 @@ module Rigor
17
18
  # - **Singleton defs on locals.** `def o.m` defines `m` on the one object `o` holds. The local's type
18
19
  # is not changed; `o.m` is simply not reported within the scope the `def` is written in (the
19
20
  # enclosing `def`, `class` / `module` body, or file — a block shares its enclosing scope's locals).
21
+ # Issue #1722 — the other spellings that give that one object methods make the local opaque for every
22
+ # name in the same scope: `o.extend(M)`, `o.define_singleton_method(...)` (only the literal name it
23
+ # defines, when it names one), any call on `o.singleton_class` (`class_eval { def m }`, `define_method`,
24
+ # `include`), `class << o`, and an `o.instance_eval` / `o.instance_exec` that is given a String or whose
25
+ # block writes a `def` or calls `extend` / `define_singleton_method`. Where in the scope the call sits,
26
+ # and whether it runs, are not read: a call under an `if` may run. A local assigned `o.clone` shares
27
+ # what `o` has (`clone` copies the singleton class, `dup` does not).
20
28
  #
21
29
  # Built lazily: the walk runs the first time a would-fire call asks, so a file with no such call pays
22
30
  # one small object and nothing else.
@@ -34,22 +42,48 @@ module Rigor
34
42
 
35
43
  def refinements = (@refinements ||= Inference::InEffectRefinements.new(@root))
36
44
 
37
- # Does a `def <local>.<name>` for this call's local receiver and method name sit in the scope the call
38
- # is in?
45
+ # Issue #1817 — the file's block-passing calls, for `call.unresolved-toplevel`'s rebinding-callee decline.
46
+ def block_calls = (@block_calls ||= RebindingBlockCallees::Sites.new(@root))
47
+
48
+ # Does a `def <local>.<name>` for this call's local receiver and method name — or a call that gives the
49
+ # local's object methods Rigor does not enumerate (issue #1722) — sit in the scope the call is in?
39
50
  def singleton_local_def?(call_node)
40
51
  receiver = call_node.receiver
41
52
  return false unless receiver.is_a?(Prism::LocalVariableReadNode)
42
53
 
43
54
  build
44
- offset = call_node.location.start_offset
45
- @singleton_defs.any? do |local, method_name, start, stop|
46
- local == receiver.name && method_name == call_node.name && offset >= start && offset < stop &&
47
- !nested_scope_holds?(offset, start, stop)
48
- end
55
+ singleton_local_holds?(receiver.name, call_node.name, call_node.location.start_offset, CLONE_DEPTH)
49
56
  end
50
57
 
58
+ # The name an opening records when it may define any method on the local's object.
59
+ ANY_METHOD = Object.new.freeze
60
+ # How many `t = s.clone` links a lookup follows.
61
+ CLONE_DEPTH = 4
62
+ EVAL_CALLS = %i[instance_eval instance_exec].freeze
63
+ EVAL_BLOCK_CALLS = %i[extend define_singleton_method].freeze
64
+ private_constant :ANY_METHOD, :CLONE_DEPTH, :EVAL_CALLS, :EVAL_BLOCK_CALLS
65
+
51
66
  private
52
67
 
68
+ def singleton_local_holds?(local_name, method_name, offset, depth)
69
+ opened = in_scope?(@singleton_defs, offset) do |local, name|
70
+ local == local_name && (name.equal?(ANY_METHOD) || name == method_name)
71
+ end
72
+ return true if opened
73
+ return false if depth.zero?
74
+
75
+ @clones.any? do |target, source, start, stop|
76
+ target == local_name && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop) &&
77
+ singleton_local_holds?(source, method_name, offset, depth - 1)
78
+ end
79
+ end
80
+
81
+ def in_scope?(rows, offset)
82
+ rows.any? do |local, name, start, stop|
83
+ yield(local, name) && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop)
84
+ end
85
+ end
86
+
53
87
  # Is `offset` inside a local scope nested in the one `[start, stop)` spans? A `def` in the file's top level
54
88
  # does not see the top level's `o`.
55
89
  def nested_scope_holds?(offset, start, stop)
@@ -64,6 +98,7 @@ module Rigor
64
98
 
65
99
  @built = true
66
100
  @singleton_defs = []
101
+ @clones = []
67
102
  @scopes = []
68
103
  return if @root.nil?
69
104
 
@@ -78,11 +113,16 @@ module Rigor
78
113
  when Prism::ClassNode, Prism::ModuleNode
79
114
  return walk_children(node.body, scope_span(node))
80
115
  when Prism::SingletonClassNode
116
+ record_opened_local(node.expression, ANY_METHOD, locals)
81
117
  walk(node.expression, locals)
82
118
  return walk_children(node.body, scope_span(node))
83
119
  when Prism::DefNode
84
120
  record_singleton_def(node, locals)
85
121
  return walk_children(node.body, scope_span(node))
122
+ when Prism::CallNode
123
+ record_singleton_opening(node, locals)
124
+ when Prism::LocalVariableWriteNode
125
+ record_clone(node, locals)
86
126
  end
87
127
 
88
128
  walk_children(node, locals)
@@ -101,6 +141,74 @@ module Rigor
101
141
  @singleton_defs << [receiver.name, node.name, *locals]
102
142
  end
103
143
 
144
+ # Issue #1722 — a call that gives the object its receiver local holds singleton methods.
145
+ def record_singleton_opening(node, locals)
146
+ receiver = node.receiver
147
+ if singleton_class_of_local?(receiver)
148
+ record_opened_local(receiver.receiver, ANY_METHOD, locals)
149
+ elsif receiver.is_a?(Prism::LocalVariableReadNode)
150
+ name = opened_method_name(node)
151
+ record_opened_local(receiver, name, locals) if name
152
+ end
153
+ end
154
+
155
+ # `o.singleton_class`.
156
+ def singleton_class_of_local?(node)
157
+ node.is_a?(Prism::CallNode) && node.name == :singleton_class && node.arguments.nil? &&
158
+ node.receiver.is_a?(Prism::LocalVariableReadNode)
159
+ end
160
+
161
+ # The method name a call on a local defines on the local's object: the literal name given to
162
+ # `define_singleton_method`, {ANY_METHOD} where it may be any name, or nil when the call defines none.
163
+ def opened_method_name(node)
164
+ case node.name
165
+ when :extend then ANY_METHOD
166
+ when :define_singleton_method then literal_name(node.arguments&.arguments&.first) || ANY_METHOD
167
+ when *EVAL_CALLS then ANY_METHOD if eval_defines?(node)
168
+ end
169
+ end
170
+
171
+ def literal_name(node)
172
+ node.unescaped.to_sym if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
173
+ end
174
+
175
+ # An `instance_eval` given code as a String, or whose block writes a `def` or calls `extend` /
176
+ # `define_singleton_method` on its `self`.
177
+ def eval_defines?(node)
178
+ return true unless node.arguments.nil?
179
+
180
+ block = node.block
181
+ block.is_a?(Prism::BlockNode) && defines_on_self?(block.body)
182
+ end
183
+
184
+ def defines_on_self?(node)
185
+ return false if node.nil?
186
+ return true if node.is_a?(Prism::DefNode)
187
+ if node.is_a?(Prism::CallNode) && EVAL_BLOCK_CALLS.include?(node.name) &&
188
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
189
+ return true
190
+ end
191
+
192
+ found = false
193
+ node.rigor_each_child { |child| found ||= defines_on_self?(child) }
194
+ found
195
+ end
196
+
197
+ def record_opened_local(node, method_name, locals)
198
+ return unless node.is_a?(Prism::LocalVariableReadNode)
199
+
200
+ @singleton_defs << [node.name, method_name, *locals]
201
+ end
202
+
203
+ # `t = s.clone`: `clone` copies the singleton class, so `t` holds what `s` was given.
204
+ def record_clone(node, locals)
205
+ value = node.value
206
+ return unless value.is_a?(Prism::CallNode) && value.name == :clone
207
+ return unless value.receiver.is_a?(Prism::LocalVariableReadNode)
208
+
209
+ @clones << [node.name, value.receiver.name, *locals]
210
+ end
211
+
104
212
  # The span of a node that opens a local scope, recorded for {#nested_scope_holds?}.
105
213
  def scope_span(node)
106
214
  span = span_of(node)
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../reflection"
4
+ require_relative "../dependency_recorder"
5
+ require_relative "../../inference/singleton_hook_decline"
6
+ require_relative "../../inference/object_mixins"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # Issue #1722 — what the project may give an instance of an RBS-declared class beyond the class's RBS, asked
12
+ # by `call.undefined-method` once the RBS lookup and the project's own tables for the class itself have come
13
+ # back "absent". Three places the earlier probes do not look:
14
+ #
15
+ # - **A `method_missing` on an RBS ancestor.** `class Integer; def method_missing(*)` (or one on `Numeric`,
16
+ # `Comparable`, `Object`, `Kernel`, `BasicObject`, or a project module mixed into any of them) answers
17
+ # every name Ruby dispatches to an Integer. `respond_to_missing?` is not read: Ruby dispatches to
18
+ # `method_missing` whether or not it is defined.
19
+ # - **A project mixin of the class or an RBS ancestor.** `Integer.include(Ext)` in another file, or
20
+ # `Comparable.include(Ext)` / `module Comparable; def zzz`, gives every Integer `zzz`. The earlier probes
21
+ # read the class's own chain through `discovered_methods`, which holds no plain `def` written in another
22
+ # file than the one being checked; this reads the def table too for a project module, and walks the
23
+ # ancestors RBS lists after the class. A cross-file `def` on an RBS-declared class or module itself stays
24
+ # ADR-17's monkey-patch report ({.mixin_defines?}). A mixin with an open surface — a project module with a
25
+ # recorded `included` / `extended` / … hook, a `"*"` mixin or `ActiveSupport::Concern`, or one neither RBS nor
26
+ # the project declares — answers any name, except on `Object`, `Kernel` and `BasicObject`: there only a
27
+ # module that defines the name answers, #1697's reading (`Inference::ObjectMixins`) of a module mixed into
28
+ # every object.
29
+ # - **A project subclass** ({.subclass_may_define?}). A value typed by its class's name through a signature
30
+ # (`#: () -> String`) may be an instance of any subclass, so a method a project subclass defines may be
31
+ # the one that runs.
32
+ #
33
+ # Each ancestor's name is filed as an ADR-46 class dependency, so a file that later reopens one re-checks
34
+ # the call, and the subclass walk files a `subclass_of` edge per class it reads.
35
+ module RbsReceiverSurface
36
+ MISSING_HOOK = :method_missing
37
+ # The roots whose project mixins `Inference::ObjectMixins` answers for.
38
+ OBJECT_ROOTS = %w[Object Kernel BasicObject].to_set.freeze
39
+ private_constant :MISSING_HOOK, :OBJECT_ROOTS
40
+
41
+ module_function
42
+
43
+ # Whether the project may answer `method_name` on an instance of `class_name` through one of its RBS
44
+ # ancestors.
45
+ def ancestor_may_answer?(scope, class_name, method_name)
46
+ ancestor_names(scope, class_name).any? do |name|
47
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
48
+ next false unless scope.known_user_class?(name)
49
+
50
+ chain_answers?(scope, name, method_name, open_mixins: !OBJECT_ROOTS.include?(name))
51
+ end
52
+ end
53
+
54
+ # Whether the singleton side of `class_name` may reach a project `method_missing`: a `def self.method_missing`
55
+ # on the class or an RBS superclass, or an instance `method_missing` on `Class`, `Module`, `Object`,
56
+ # `Kernel` or `BasicObject` (a class object is an instance of each).
57
+ def singleton_hook?(scope, class_name)
58
+ loader = scope.environment&.rbs_loader
59
+ pairs = loader ? loader.singleton_ancestors_for(class_name.to_s) : []
60
+ pairs = [[:singleton, class_name.to_s]] if pairs.empty?
61
+ pairs.any? do |kind, name|
62
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
63
+ if kind == :singleton
64
+ !scope.singleton_def_for(name, MISSING_HOOK).nil? ||
65
+ scope.discovered_method?(name, MISSING_HOOK, :singleton)
66
+ else
67
+ scope.known_user_class?(name) && hook_on_chain?(scope, name)
68
+ end
69
+ end
70
+ end
71
+
72
+ # Issue #2094 — the singleton side's twin of {.ancestor_may_answer?}: a module the class or an RBS superclass
73
+ # `extend`s (in its body, as `Float.extend(Ext)`, or as `Float.singleton_class.include(Ext)`) may answer
74
+ # `method_name` on the class object, read as {.mixin_defines?} / {.external_may_answer?} read an instance-side
75
+ # mixin. Only each level's own singleton segment is read: the class's further singleton ancestors are the
76
+ # RBS superclasses this walk visits in turn, and `Class` / `Module` are {.singleton_hook?}'s.
77
+ def singleton_mixin_may_answer?(scope, class_name, method_name)
78
+ singleton_class_names(scope, class_name).any? do |name|
79
+ DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
80
+
81
+ extends_answer?(scope, name, method_name, open_mixins: !OBJECT_ROOTS.include?(name))
82
+ end
83
+ end
84
+
85
+ def singleton_class_names(scope, class_name)
86
+ loader = scope.environment&.rbs_loader
87
+ pairs = loader ? loader.singleton_ancestors_for(class_name.to_s) : []
88
+ names = pairs.filter_map { |kind, name| name if kind == :singleton }
89
+ names.empty? ? [class_name.to_s] : names
90
+ end
91
+
92
+ def extends_answer?(scope, name, method_name, open_mixins:)
93
+ chain = Scope::ResolutionChain.for(scope, name, :singleton, :methods)
94
+ return true if chain.truncated?
95
+ # A mixin call the indexer could not record (`Integer.extend(m)`, `Symbol.singleton_class.send(:include,
96
+ # M)`) leaves the side tainted: some module the tables do not name may answer any name.
97
+ return true if open_mixins && chain.wildcard_mixin?
98
+
99
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
100
+ entries.any? do |entry|
101
+ if entry.external?
102
+ external_may_answer?(scope, entry, method_name, open_mixins)
103
+ else
104
+ entry.name != name && mixin_defines?(scope, entry, method_name, open_mixins)
105
+ end
106
+ end
107
+ end
108
+
109
+ # Whether a project subclass of `class_name`, at any depth, defines `method_name` (through its own
110
+ # ancestry, which reaches its project mixins) or a `method_missing`. Its parent is read as written and
111
+ # resolved the way the class header resolves it: the first project class the spelling can denote, else the
112
+ # top-level name, which is the RBS class.
113
+ def subclass_may_define?(scope, class_name, method_name)
114
+ each_subclass(scope, class_name.to_s) do |subclass|
115
+ return true if project_defines?(scope, subclass, method_name) ||
116
+ project_defines?(scope, subclass, MISSING_HOOK) ||
117
+ !scope.user_def_through_ancestors(subclass, method_name).first.nil? ||
118
+ scope.discovered_method_through_ancestors?(subclass, method_name, :instance)
119
+ end
120
+ false
121
+ end
122
+
123
+ def ancestor_names(scope, class_name)
124
+ loader = scope.environment&.rbs_loader
125
+ names = loader ? loader.ancestor_names_for(class_name.to_s) : []
126
+ names.empty? ? [class_name.to_s] : names
127
+ end
128
+
129
+ # A search of `name`'s project chain for an entry that may answer: a project entry that defines
130
+ # `method_name` or a `method_missing`, or an external mixin RBS declares `method_name` on. When
131
+ # `open_mixins` (every ancestor but `Object`, `Kernel` and `BasicObject`, #1697), also a mixin whose surface
132
+ # is open: a project module SingletonHookDecline reads as hook-capable, or an external neither RBS nor the
133
+ # project declares. A chain cut at its budget may hold an answer past the cut.
134
+ def chain_answers?(scope, name, method_name, open_mixins:)
135
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
136
+ found = chain.search(scope, side: :instance) do |entry|
137
+ if entry.external?
138
+ external_may_answer?(scope, entry, method_name, open_mixins)
139
+ else
140
+ project_defines?(scope, entry.name, MISSING_HOOK) ||
141
+ mixin_defines?(scope, entry, method_name, open_mixins)
142
+ end
143
+ end
144
+ found || chain.truncated?
145
+ end
146
+
147
+ # The singleton side's question of an instance chain: does a project entry on it define `method_missing`?
148
+ def hook_on_chain?(scope, name)
149
+ chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
150
+ found = chain.search(scope, side: :instance) do |entry|
151
+ !entry.external? && project_defines?(scope, entry.name, MISSING_HOOK)
152
+ end
153
+ found || chain.truncated?
154
+ end
155
+
156
+ def project_defines?(scope, owner, method_name)
157
+ !scope.user_def_for(owner, method_name).nil? || scope.discovered_method?(owner, method_name, :instance)
158
+ end
159
+
160
+ # ADR-17 — a `def` the project writes on a class or module RBS declares (`class String; def x`, `module
161
+ # Comparable; def x`) in another file than the call is a monkey-patch Rigor does not apply cross-file, and the
162
+ # rule reports it with the `pre_eval:` advice, so on such an entry only what `discovered_methods` carries
163
+ # answers. A project module's own `def` answers from any file.
164
+ #
165
+ # When `open_mixins`, a project module {Inference::SingletonHookDecline} reads as hook-capable — one that
166
+ # records an `included` / `extended` / … hook (`def self.included(base) = base.class_eval { def x }`), lists
167
+ # a `"*"` mixin, or extends `ActiveSupport::Concern` — may answer any name.
168
+ def mixin_defines?(scope, entry, method_name, open_mixins)
169
+ owner = entry.name
170
+ if Rigor::Reflection.rbs_class_known?(owner, scope: scope)
171
+ scope.discovered_method?(owner, method_name, :instance)
172
+ else
173
+ project_defines?(scope, owner, method_name) || macro_names?(scope, owner, method_name) ||
174
+ (open_mixins && (Inference::SingletonHookDecline.entry_hook_capable?(scope, entry) ||
175
+ open_extend?(scope, owner)))
176
+ end
177
+ end
178
+
179
+ # Issue #2094 — a class-body macro of the module names the method ({Inference::ObjectMixins.macro_named?}).
180
+ # Answers the named method only, so it holds on `Object`, `Kernel` and `BasicObject` too, where a module
181
+ # that defines the name answers.
182
+ def macro_names?(scope, owner, method_name)
183
+ Inference::ObjectMixins.macro_named?(scope, owner, method_name)
184
+ end
185
+
186
+ # Issue #2094 — the module `extend`s a module whose surface is open: one neither RBS nor the project declares
187
+ # (a gem's DSL with no signatures), or a project module {Inference::SingletonHookDecline} reads as
188
+ # hook-capable. Any macro the module's body calls may come from it and define any name, and its `extended`
189
+ # hook may too, so the module answers any name, the reading an undeclared `include` already gets. A chain cut
190
+ # at its budget may hold such a module past the cut.
191
+ def open_extend?(scope, owner)
192
+ chain = Scope::ResolutionChain.for(scope, owner, :singleton, :methods)
193
+ return true if chain.truncated?
194
+
195
+ entries = chain.level_count.zero? ? chain.entries : chain.level_entries(0)
196
+ entries.any? { |extended| Inference::SingletonHookDecline.entry_hook_capable?(scope, extended) }
197
+ end
198
+
199
+ # An external the project declares (a module with no `def`, which the chain does not hold as a project entry)
200
+ # is known, and answers through RBS, or through what its own body's macros and `extend`s may define (issue
201
+ # #2094: {.macro_names?} and {.open_extend?}, asked of each candidate the project declares).
202
+ def external_may_answer?(scope, entry, method_name, open_mixins)
203
+ candidates = entry.candidates
204
+ return open_mixins if candidates.empty?
205
+ return true if candidates.any? do |candidate|
206
+ Rigor::Reflection.instance_method_definition(candidate, method_name, scope: scope)
207
+ end
208
+ return true if candidates.any? do |candidate|
209
+ macro_names?(scope, candidate, method_name) || (open_mixins && open_extend?(scope, candidate))
210
+ end
211
+
212
+ open_mixins && candidates.none? { |candidate| declared?(scope, candidate) }
213
+ end
214
+
215
+ def declared?(scope, name)
216
+ scope.discovered_classes.key?(name) || Rigor::Reflection.rbs_class_known?(name, scope: scope)
217
+ end
218
+
219
+ def each_subclass(scope, class_name)
220
+ children = children_by_parent(scope)
221
+ queue = children.fetch(class_name, []).dup
222
+ seen = {}
223
+ record_subclasses_read(class_name)
224
+ until queue.empty?
225
+ subclass = queue.shift
226
+ next if seen[subclass]
227
+
228
+ seen[subclass] = true
229
+ record_subclasses_read(subclass)
230
+ yield subclass
231
+ queue.concat(children.fetch(subclass, []))
232
+ end
233
+ end
234
+
235
+ def children_by_parent(scope)
236
+ scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
237
+ next if raw_parent.nil?
238
+
239
+ candidates = scope.ancestor_name_candidates(child, raw_parent.to_s)
240
+ parent = candidates.find { |name| scope.known_user_class?(name) } || candidates.last
241
+ (children[parent] ||= []) << child if parent
242
+ end
243
+ end
244
+
245
+ def record_subclasses_read(class_name)
246
+ return unless DependencyRecorder.active?
247
+
248
+ DependencyRecorder.read_last_segment(:subclass_of, class_name)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end