rigortype 0.4.7 → 0.4.8

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 (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../run_scoped_slots"
3
+ require_relative "../typing_state"
4
+ require_relative "../memo_audit"
4
5
 
5
6
  module Rigor
6
7
  class Scope
@@ -92,6 +93,39 @@ module Rigor
92
93
  # when the entry is interned.
93
94
  Entry = Data.define(:name, :side, :raw, :candidates, :superclass_edge, :last_segment, :owner) do
94
95
  def external? = name.nil?
96
+
97
+ # Issue #3156 — the BODY-LESS class or module the project declares that an external entry names, or nil: the
98
+ # first of its spellings that is a `discovered_classes` key, read in Ruby's lookup order. Such a declaration
99
+ # records no method, superclass or include (`module PMod; end`), which is why the chain holds it as external.
100
+ # The owner-qualified spelling (#1698) leads only on a mixin edge: Ruby evaluates a superclass expression
101
+ # outside the class's own body, so `class Leaf < Base` never names `Leaf::Base`. Any spelling up to the match
102
+ # that RBS alone knows, that a project constant write binds (`AMid = ::Other` in `NS`), or that names a class
103
+ # with a body leaves nil, since Ruby may resolve the constant there or the chain should have held it. It files
104
+ # the entry's negative class and constant edges, which a declaration or write of the name appearing or
105
+ # vanishing fires.
106
+ def declared_name(scope)
107
+ spelling = raw.to_s
108
+ Analysis::DependencyRecorder.read_last_segment(:class, spelling)
109
+ Analysis::DependencyRecorder.read_last_segment(:constant, spelling)
110
+ own = owner && !superclass_edge && !spelling.start_with?("::")
111
+ (own ? ["#{owner}::#{spelling}", *candidates] : candidates).uniq.each do |candidate|
112
+ return nil if constant_written?(scope, candidate)
113
+ return (scope.known_user_class?(candidate) ? nil : candidate) if scope.discovered_classes.key?(candidate)
114
+ return nil if Rigor::Reflection.rbs_class_known?(candidate, scope: scope)
115
+ end
116
+ nil
117
+ end
118
+
119
+ private
120
+
121
+ # Whether a project constant write other than a class declaration binds `name` exactly (the census keeps a
122
+ # write as spelled, a `*::` wildcard for one it cannot place), or a literal `const_set` in this file does
123
+ # (`LastLine::SelfEvidence#const_set_spellings`; the census files none).
124
+ def constant_written?(scope, name)
125
+ (scope.bound_constant_names(name) + scope.shadowing_constant_names(name)).any? do |written|
126
+ written == name || written.start_with?("*::")
127
+ end || scope.discovery.implicit_self_evidence&.const_set_spellings&.include?(name) || false
128
+ end
95
129
  end
96
130
 
97
131
  # How many project entries a chain may hold before it is cut (ADR-41 WD4). The breadth-first walks
@@ -104,9 +138,20 @@ module Rigor
104
138
  # the multi-file rule fired (two or more files declare it and it has two or more edges on that side).
105
139
  # Interned per `[node, kind]` in the flavor's bucket, so equal marks are one object. Kind `:extend_hook`
106
140
  # (#1730, listing `"*"`) marks a node a module it extends may mix into through a hook; it is not an order mark
107
- # ({#order_marks}).
141
+ # ({#order_marks}). Kind `:include_hook` (#1592, listing `"*"` and `"?"`) marks a class object a module it
142
+ # includes or prepends may extend, through a hook, with a module no table names; nor is it. Kind `:hook_order`
143
+ # (#1592, listing nothing) marks a class object whose own `extend`s and hook-extended modules have no known
144
+ # order between them: the fork it counts already settles the chain, and the mark tells the typing read why.
145
+ # Kind `:singleton_prepend` (#2998, listing nothing) marks a class object with singleton-side prepends of its
146
+ # own, which master's order does not place, and kind `:prepend_order` (#2998, listing nothing) one whose
147
+ # singleton-side prepends have no known order among themselves; neither is an order mark. Kind `:unnamed_prepend`
148
+ # (#3020, listing nothing) marks a class object with a singleton-side prepend of a module no table names, which
149
+ # is not an order mark either.
108
150
  Mark = Data.define(:node, :kind, :listed, :multi_file)
109
151
 
152
+ # The mark kinds that are not order marks.
153
+ HOOK_MARK_KINDS = %i[extend_hook include_hook hook_order singleton_prepend prepend_order unnamed_prepend].freeze
154
+
110
155
  NO_MARKS = [].freeze
111
156
 
112
157
  attr_reader :root, :side, :entries, :level_starts, :level_classes, :flavor
@@ -133,6 +178,13 @@ module Rigor
133
178
  # "no definer" from "a definer past the cut", which is budget uncertainty, not absence.
134
179
  def truncated? = @truncated
135
180
 
181
+ # ADR-129 G2 — the chain's content, for the memo audit: a recomputation builds a new chain object, which
182
+ # equals the stored one field for field (its retro world included) when the memo's key holds every input.
183
+ def audit_signature
184
+ [@root, @side, @entries, @level_starts, @level_classes, @levels_end, @truncated, @forks, @marks, @flavor,
185
+ @retro&.audit_signature]
186
+ end
187
+
136
188
  # How many forks the replay met while this chain was built, counted across every module sub-chain it drew
137
189
  # on (a memoised sub-chain hands its own count up): each entry that reached the chain by a second route.
138
190
  # An over-count only ever sends a reader to master's answer.
@@ -149,13 +201,57 @@ module Rigor
149
201
  # those readers to master's order would answer a constant lookup breadth-first, which is wrong for the
150
202
  # #1567 shape on every class that extends such a module.
151
203
  def order_marks
152
- @memo[:order_marks] ||= @marks.reject { |mark| mark.kind == :extend_hook }.freeze
204
+ @memo[:order_marks] ||= @marks.reject { |mark| HOOK_MARK_KINDS.include?(mark.kind) }.freeze
153
205
  end
154
206
 
155
207
  # True when a class on the chain records a mixin the tables cannot name (`"*"`: an `include` of a
156
208
  # non-constant, or a call the walk cannot record), so the chain may hold a module it does not list.
157
209
  def wildcard_mixin? = @marks.any? { |mark| mark.listed.include?(Relevance::WILDCARD) }
158
210
 
211
+ # Issue #2991 — true when a class on the chain mixes in a module the tables do not record under any name
212
+ # (`include(*MODS)`, `include helper`, a hook's `base.include M`, an unrecorded `self.include M`): the chain's SET
213
+ # of modules is unknown, not only its order. Every such mark is also a {#wildcard_mixin?} one; `include M;
214
+ # prepend M` taints the order alone.
215
+ def unnamed_mixin? = @marks.any? { |mark| mark.listed.include?(Relevance::UNNAMED) }
216
+
217
+ # Issue #1592 — whether a class object on the chain has hook-extended modules whose order against its own
218
+ # `extend`s, or among themselves, is not a fact ({Builder#singleton_mixins}).
219
+ def hook_order_marked? = @marks.any? { |mark| mark.kind == :hook_order }
220
+
221
+ # Issue #2998 — whether a class object on the chain has singleton-side prepends of its own
222
+ # ({Builder#own_singleton_prepends}), which master's order does not place.
223
+ def singleton_prepend_marked? = @marks.any? { |mark| mark.kind == :singleton_prepend }
224
+
225
+ # Issue #2998 — the class objects on the chain whose prepend region has no known order
226
+ # ({Builder#order_prepends}).
227
+ def prepend_order_nodes
228
+ @memo[:prepend_order_nodes] ||= @marks.filter_map { |mark| mark.node if mark.kind == :prepend_order }.freeze
229
+ end
230
+
231
+ # Issue #3020 — the class objects on the chain with a singleton-side prepend of a module no table names
232
+ # ({Builder#own_singleton_prepends}).
233
+ def unnamed_prepend_nodes
234
+ @memo[:unnamed_prepend_nodes] ||= @marks.filter_map { |mark| mark.node if mark.kind == :unnamed_prepend }.freeze
235
+ end
236
+
237
+ # Issue #3020 — the class objects on the chain whose own singleton side lists a module no table names
238
+ # (`extend(*MODS)`, `class << self; include(*MODS)`, `singleton_class.prepend(*MODS)`: the `:extend` order mark
239
+ # listing {Relevance::UNNAMED}).
240
+ def unnamed_extend_nodes
241
+ @memo[:unnamed_extend_nodes] ||= @marks.filter_map do |mark|
242
+ mark.node if mark.kind == :extend && mark.listed.include?(Relevance::UNNAMED)
243
+ end.freeze
244
+ end
245
+
246
+ # The chain's retro world ({#settle}), or nil when it has none.
247
+ def retro_world = @retro
248
+
249
+ # Issue #1592 — the class objects on a singleton chain that a module they include or prepend may extend, through
250
+ # a hook, with a module no table names: that module sits right after the class object's own entry.
251
+ def hook_open_nodes
252
+ @memo[:hook_open_nodes] ||= @marks.filter_map { |mark| mark.node if mark.kind == :include_hook }.freeze
253
+ end
254
+
159
255
  # The ONE decision every first-definer reader makes: does `answer`, read off this chain, stand, or does
160
256
  # the reader answer what the walk this chain replaced answered ({MasterOrder})? Returns `:chain` or
161
257
  # `:master`.
@@ -224,18 +320,30 @@ module Rigor
224
320
  # it does not head the part searched (its prepends, or its own edges when the search starts past it,
225
321
  # placed the rest). The entry that answers records none of its own: its method edge is the caller's
226
322
  # `Scope#user_def_for`. A read that finds nothing has passed every entry.
323
+ #
324
+ # Issue #2998 — on a singleton chain a NEW file reopening a class object's class can prepend to its singleton
325
+ # (`class << self; prepend P`), ahead of everything the class object's level holds, which no class edge of the
326
+ # declaring files sees. So each class object the search passes, and one that answers, also files the name edge
327
+ # `mark:<last segment>` ({#record_prepend_regions}), which `IncrementalSession#mark_affected` fires when a
328
+ # `[ENVELOPE_SINGLETON_PREPEND_KIND, …]` entry (a diffed class mark) appears on or vanishes from the class, and
329
+ # not on a plain reopening. A class object behind the answer is filed through {#settle}'s filing, and so is every
330
+ # one on a read that finds nothing.
227
331
  def search(scope, start = 0, stop = nil, side: nil)
228
332
  recording = Analysis::DependencyRecorder.active?
229
333
  stop ||= @entries.size
230
334
  if recording
231
335
  record_head(scope, start, side)
232
336
  record_extend_hooks(scope)
337
+ record_include_hooks(scope)
233
338
  end
234
339
  index = start
235
340
  while index < stop
236
341
  entry = @entries[index]
237
342
  answer = side.nil? || entry.side == side ? yield(entry) : nil
238
- return answer if answer
343
+ if answer
344
+ record_prepend_regions(start, index) if recording
345
+ return answer
346
+ end
239
347
 
240
348
  ResolutionChain.record_entry(scope, entry, side) if recording
241
349
  index += 1
@@ -243,6 +351,19 @@ module Rigor
243
351
  nil
244
352
  end
245
353
 
354
+ # The `mark:` name edges of the class objects on a singleton chain from `start` through `last`.
355
+ def record_prepend_regions(start, last)
356
+ return unless @side == :singleton
357
+
358
+ (start..last).each do |index|
359
+ entry = @entries[index]
360
+ next unless entry.side == :singleton && !entry.external?
361
+
362
+ Analysis::DependencyRecorder.read_last_segment(:mark, entry.name)
363
+ end
364
+ end
365
+ private :record_prepend_regions
366
+
246
367
  # Records the ADR-46 class edge of the root and of every project entry ahead of `stop` (on `side`, when
247
368
  # given; every entry when `stop` is nil) — the dependency of a read that consulted those positions.
248
369
  def record(scope, stop = nil, side: nil)
@@ -251,6 +372,21 @@ module Rigor
251
372
  ResolutionChain.record_class(scope, @root)
252
373
  (stop || @entries.size).times { |index| ResolutionChain.record_entry(scope, @entries[index], side) }
253
374
  record_extend_hooks(scope)
375
+ record_include_hooks(scope)
376
+ end
377
+
378
+ # Issue #2689 — the negative edge a method-walk MISS owes each EXTERNAL entry: `class:<last segment>` of the name
379
+ # as written. An external entry is an ancestor the project declares with no methods (the usual empty
380
+ # `ApplicationRecord`) or not at all yet, so the walk looked nothing up there; a later file that declares it
381
+ # with the method turns the miss, and the declaration appearing is the one event that file fires. A core or gem
382
+ # ancestor (`StandardError`, `Comparable`) files its key too, which fires only on a file that declares a
383
+ # constant of that name. Recorded only on a miss: a hit's answer cannot be turned by an entry gaining a method.
384
+ def record_external_absence
385
+ return unless Analysis::DependencyRecorder.active?
386
+
387
+ @entries.each do |entry|
388
+ Analysis::DependencyRecorder.read_last_segment(:class, entry.raw.to_s) if entry.external?
389
+ end
254
390
  end
255
391
 
256
392
  # What a {#settle} verdict adds to what the search already filed. The search files the root and every entry
@@ -279,6 +415,7 @@ module Rigor
279
415
  Analysis::DependencyRecorder.file_chain_once(self, start, scope.discovery) { file_beyond(scope, start) }
280
416
  record_hooks(scope)
281
417
  record_extend_hooks(scope)
418
+ record_include_hooks(scope)
282
419
  end
283
420
  private :record_beyond
284
421
 
@@ -325,7 +462,7 @@ module Rigor
325
462
  end
326
463
  private :record_hooks
327
464
 
328
- # #1730 — the edges of the {Builder#extend_hook_taint?} decision: an instance chain's project entries may carry
465
+ # #1730 — the edges of the {Builder#extend_hook_taint} decision: an instance chain's project entries may carry
329
466
  # a `"*"` mark because a module one of them `extend`s has a hook that mixes into the extender, and the
330
467
  # module is often declared in a file the chain records nothing of. Each such module the decision read files
331
468
  # its class edges (an edit to its hook moves its declaration signature) and the negative method edges of
@@ -352,6 +489,168 @@ module Rigor
352
489
  end
353
490
  private :record_extend_hooks
354
491
 
492
+ # #1592 — the edges of the {Builder#hook_extensions} read: a singleton chain draws, for every class on it, the
493
+ # modules that class's direct mixins' hooks extend it with, and the mixins are declared in files the chain's own
494
+ # entries record nothing of. Each resolved mixin files its class edges and the negative `mark:` edge on its name,
495
+ # which an edit adding or dropping a hook-extend entry fires; every mixin, and every module a hook extends by
496
+ # name, files the negative class edge on its last segment, which a NEW file declaring or reopening it fires.
497
+ # Filed once per consumer and chain.
498
+ def record_include_hooks(scope)
499
+ return unless @side == :singleton
500
+
501
+ edges = (@memo[:include_hook_edges] ||= ResolutionChain.include_hook_edges(scope, @flavor, @level_classes))
502
+ return if edges.empty?
503
+
504
+ Analysis::DependencyRecorder.file_chain_once(self, :include_hooks, scope.discovery) do
505
+ edges.each do |kind, name|
506
+ ResolutionChain.record_class(scope, name) if kind == :mixin
507
+ Analysis::DependencyRecorder.read_last_segment(:mark, name) if kind == :mixin
508
+ Analysis::DependencyRecorder.read_last_segment(:class, name)
509
+ end
510
+ end
511
+ end
512
+ private :record_include_hooks
513
+
514
+ # `[[:mixin, resolved name] | [:spelling, raw], …]`: the direct mixins of every class on a singleton chain, and
515
+ # the modules the resolved ones' hooks extend by name ({Builder#hook_extensions}).
516
+ def self.include_hook_edges(scope, flavor, classes)
517
+ resolver = resolver_for(scope, flavor)
518
+ discovery = scope.discovery
519
+ classes.each_with_object([]) do |klass, out|
520
+ next if klass.nil?
521
+
522
+ (discovery.discovered_includes[klass] || EMPTY_NAMES).uniq.each do |raw|
523
+ mixin = resolver.resolve_declared(klass, raw)
524
+ next out << [:spelling, raw].freeze if mixin.nil?
525
+
526
+ out << [:mixin, mixin].freeze
527
+ hook_extend_keys(discovery, mixin).each { |key| out << [:spelling, key[1]].freeze }
528
+ concern_dependency_edges(discovery, resolver, mixin, out)
529
+ end
530
+ end.uniq.freeze
531
+ end
532
+
533
+ # Issue #2992 — the edges of a concern mixin's dependency read ({.concern_dependencies}): every module its
534
+ # dependency entries name, a concern or not yet one (a file that adds `extend ActiveSupport::Concern` to it
535
+ # turns it into one), as a mixin, with the modules its hooks extend by name; a spelling no module answers files
536
+ # its negative class edge. A concern among them is followed further.
537
+ def self.concern_dependency_edges(discovery, resolver, mixin, out, seen = [mixin])
538
+ return unless concern_bucket?(discovery, mixin)
539
+
540
+ dependency_spellings(discovery, mixin).each do |raw|
541
+ dependency = resolver.resolve_declared(mixin, raw)
542
+ next out << [:spelling, raw].freeze if dependency.nil?
543
+ next if seen.include?(dependency)
544
+
545
+ seen << dependency
546
+ out << [:mixin, dependency].freeze
547
+ hook_extend_keys(discovery, dependency).each { |key| out << [:spelling, key[1]].freeze }
548
+ concern_dependency_edges(discovery, resolver, dependency, out, seen)
549
+ end
550
+ end
551
+ private_class_method :concern_dependency_edges
552
+
553
+ # The `[ENVELOPE_CONCERN_DEPENDENCY_KIND, written]` spellings of a module's bucket, nearest first (a later
554
+ # statement's ahead of an earlier one's).
555
+ def self.dependency_spellings(discovery, name)
556
+ bucket = discovery.discovered_parameter_envelopes[name]
557
+ return EMPTY_NAMES if bucket.nil?
558
+
559
+ bucket.each_key.filter_map do |key|
560
+ key[1] if key.is_a?(Array) && key.first == DiscoveryIndex::ENVELOPE_CONCERN_DEPENDENCY_KIND
561
+ end.reverse.uniq
562
+ end
563
+
564
+ # Issue #2992 — the concerns a concern `mixin` provably takes as dependencies (its
565
+ # `[ENVELOPE_CONCERN_DEPENDENCY_KIND, written]` entries: an `include` or `prepend` after its own `extend
566
+ # ActiveSupport::Concern`), at any depth through concerns, nearest first, without `mixin`:
567
+ # `Concern#append_features` records each as a dependency of `mixin` and includes it into `mixin`'s final
568
+ # includer, so the includer's singleton chain gains what each one's hooks extend it with. Empty when `mixin` is
569
+ # no concern. A dependency the walk meets twice is listed once, at its nearest place.
570
+ def self.concern_dependencies(discovery, resolver, mixin, seen = [mixin])
571
+ return EMPTY_NAMES unless concern_bucket?(discovery, mixin)
572
+
573
+ out = []
574
+ dependency_spellings(discovery, mixin).each do |raw|
575
+ dependency = resolver.resolve_declared(mixin, raw)
576
+ next if dependency.nil? || seen.include?(dependency) || !concern_bucket?(discovery, dependency)
577
+
578
+ seen << dependency
579
+ out << dependency
580
+ out.concat(concern_dependencies(discovery, resolver, dependency, seen))
581
+ end
582
+ out
583
+ end
584
+
585
+ # The entry an `extend ActiveSupport::Concern` module records ({Inference::ScopeIndexer}).
586
+ CONCERN_KEY = [DiscoveryIndex::ENVELOPE_HOOK_EXTEND_KIND, "ClassMethods", :const_get].freeze
587
+ private_constant :CONCERN_KEY
588
+
589
+ def self.concern_bucket?(discovery, name)
590
+ bucket = discovery.discovered_parameter_envelopes[name]
591
+ !bucket.nil? && bucket.key?(CONCERN_KEY)
592
+ end
593
+ private_class_method :concern_bucket?
594
+
595
+ # Issue #2997 — whether `owner.const_get(raw)` may find the project's `candidate` (`"#{owner}::#{raw}"`): a
596
+ # `class_methods do … end` block's `def`s are filed on `Owner::ClassMethods` in every body of the module, but
597
+ # Ruby has that module only when `owner` is a concern, so one that no `module` declaration writes counts only
598
+ # for a concern `owner`.
599
+ def self.const_get_candidate?(discovery, owner, candidate)
600
+ concern_bucket?(discovery, owner) || discovery.discovered_classes.key?(candidate)
601
+ end
602
+
603
+ # Issue #3005 — whether `name` is a module a concern block's `def self.x`s are filed on (`Owner::<included>`).
604
+ def self.hook_block_module?(name)
605
+ DiscoveryIndex::HOOK_BLOCK_SEGMENTS.any? { |segment| name.end_with?("::#{segment}") }
606
+ end
607
+
608
+ # Issue #3005 — whether a hook-block module on the level of the class object at `index` defines `method_name`:
609
+ # Ruby defines it on that class object itself when the block runs, so it and the class's own `def self.x`
610
+ # replace each other in an order the tables do not hold (the `include` against the `def`).
611
+ def hook_block_rival?(scope, index, method_name)
612
+ level = @level_starts.rindex { |start| start <= index }
613
+ return false if level.nil? || level >= level_count
614
+
615
+ level_entries(level).any? do |entry|
616
+ !entry.external? && entry.side == :instance && ResolutionChain.hook_block_module?(entry.name) &&
617
+ !scope.user_def_for(entry.name, method_name).nil?
618
+ end
619
+ end
620
+
621
+ GATED_HOOKS = DiscoveryIndex::CONCERN_BLOCK_HOOKS.invert.freeze
622
+ private_constant :GATED_HOOKS
623
+
624
+ # Issue #3005 — the hook an entry of `owner`'s bucket naming `hook` runs on: a gated concern-block hook
625
+ # ({DiscoveryIndex::CONCERN_BLOCK_HOOKS}) is its plain hook when `owner` is a concern and nil otherwise; any
626
+ # other hook is itself.
627
+ def self.effective_hook(discovery, owner, hook)
628
+ plain = GATED_HOOKS[hook]
629
+ return hook if plain.nil?
630
+
631
+ concern_bucket?(discovery, owner) ? plain : nil
632
+ end
633
+
634
+ HOOK_KEY_SLOTS = {
635
+ DiscoveryIndex::ENVELOPE_HOOK_EXTEND_KIND => 2, DiscoveryIndex::ENVELOPE_HOOK_EXTEND_OPEN_KIND => 1,
636
+ DiscoveryIndex::ENVELOPE_HOOK_DEF_KIND => 1
637
+ }.freeze
638
+ private_constant :HOOK_KEY_SLOTS
639
+
640
+ # The hook a hook-extend, open or def envelope key names (`:const_get` for a concern's), or nil for another key.
641
+ def self.hook_of(key)
642
+ slot = key.is_a?(Array) ? HOOK_KEY_SLOTS[key.first] : nil
643
+ slot && key[slot]
644
+ end
645
+
646
+ # The `[ENVELOPE_HOOK_EXTEND_KIND, written, hook]` keys of a module's envelope bucket, in recording order.
647
+ def self.hook_extend_keys(discovery, name)
648
+ bucket = discovery.discovered_parameter_envelopes[name]
649
+ return EMPTY_NAMES if bucket.nil?
650
+
651
+ bucket.each_key.select { |key| key.is_a?(Array) && key.first == DiscoveryIndex::ENVELOPE_HOOK_EXTEND_KIND }
652
+ end
653
+
355
654
  # The hooks that let a module `extend`ed onto N reshape N's INSTANCE side (#1730): its singleton `extended` /
356
655
  # `extend_object` run against N at the `extend` and may mix into N; its instance `included` /
357
656
  # `append_features` / `prepended` / `prepend_features` / `inherited` become N's own singleton hooks and run
@@ -359,11 +658,44 @@ module Rigor
359
658
  SINGLETON_EXTEND_HOOKS = %i[extended extend_object].freeze
360
659
  INSTANCE_EXTEND_HOOKS = %i[included append_features prepended prepend_features inherited].freeze
361
660
 
362
- @extend_hook_keys = {}
661
+ # ADR-129 WD1 — three interning Memos, not run-scoped: a value is a frozen Array of frozen Strings built from the
662
+ # name and frozen constants alone, so an entry is fixed once written and holds nothing of a run. Each hit goes
663
+ # through `MemoAudit.check`, and an audit recomputation bypasses the table.
664
+ EXTEND_HOOK_KEYS = TypingState.memo(
665
+ :resolution_chain_extend_hook_keys,
666
+ audit: "extend hook keys",
667
+ reason: "keyed on the module name; a frozen Array of frozen Strings built from the name and the extend-hook " \
668
+ "constants alone"
669
+ )
670
+ HOOK_KEYS = TypingState.memo(
671
+ :resolution_chain_hook_keys,
672
+ audit: "hook keys",
673
+ reason: "keyed on the class name; a frozen Array of frozen Strings built from the name and " \
674
+ "Relevance::HOOKS alone"
675
+ )
676
+ EXTERNAL_KEYS = TypingState.memo(
677
+ :resolution_chain_external_keys,
678
+ audit: "external keys",
679
+ reason: "keyed on the class name; HOOK_KEYS' answer plus one frozen String built from the name"
680
+ )
681
+ private_constant :EXTEND_HOOK_KEYS, :HOOK_KEYS, :EXTERNAL_KEYS
363
682
 
364
683
  # The frozen negative method keys of `name`'s {SINGLETON_EXTEND_HOOKS} and {INSTANCE_EXTEND_HOOKS}.
365
684
  def self.extend_hook_keys(name)
366
- @extend_hook_keys[name] ||= (
685
+ return build_extend_hook_keys(name) if Inference::MemoAudit.enabled? && Inference::MemoAudit.suspended?
686
+
687
+ state = TypingState.current
688
+ table = (state[EXTEND_HOOK_KEYS] ||= {})
689
+ stored = table[name]
690
+ if stored.nil?
691
+ return table[name] = Inference::MemoAudit.key_audit("extend hook keys") { build_extend_hook_keys(name) }
692
+ end
693
+
694
+ Inference::MemoAudit.check("extend hook keys", stored, name) { build_extend_hook_keys(name) }
695
+ end
696
+
697
+ def self.build_extend_hook_keys(name)
698
+ (
367
699
  SINGLETON_EXTEND_HOOKS.map { |hook| "method:#{name}.#{hook}".freeze } +
368
700
  INSTANCE_EXTEND_HOOKS.map { |hook| "method:#{name}##{hook}".freeze }
369
701
  ).freeze
@@ -387,7 +719,7 @@ module Rigor
387
719
  end
388
720
 
389
721
  # `[[spelling, resolved name or nil, candidate names], …]`: every module the project entries of an instance chain
390
- # `extend`, as {Builder#extend_hook_taint?} resolved them.
722
+ # `extend`, as {Builder#extend_hook_taint} resolved them.
391
723
  def self.extend_targets(scope, flavor, entries)
392
724
  resolver = resolver_for(scope, flavor)
393
725
  extends = scope.discovery.discovered_extends
@@ -401,19 +733,37 @@ module Rigor
401
733
  end.uniq.freeze
402
734
  end
403
735
 
404
- @hook_keys = {}
405
- @external_keys = {}
406
-
407
736
  # The frozen `method:Name.hook` negative keys of `name`'s {Relevance::HOOKS}, built once per name.
408
737
  def self.hook_keys(name)
409
- @hook_keys[name] ||= Relevance::HOOKS.map { |hook| "method:#{name}.#{hook}".freeze }.freeze
738
+ return build_hook_keys(name) if Inference::MemoAudit.enabled? && Inference::MemoAudit.suspended?
739
+
740
+ state = TypingState.current
741
+ table = (state[HOOK_KEYS] ||= {})
742
+ stored = table[name]
743
+ return table[name] = Inference::MemoAudit.key_audit("hook keys") { build_hook_keys(name) } if stored.nil?
744
+
745
+ Inference::MemoAudit.check("hook keys", stored, name) { build_hook_keys(name) }
410
746
  end
411
747
 
748
+ def self.build_hook_keys(name) = Relevance::HOOKS.map { |hook| "method:#{name}.#{hook}".freeze }.freeze
749
+
412
750
  # {hook_keys} plus the `mixin:<name>` key an external entry files for its unpositioned mixins (#1661).
413
751
  def self.external_keys(name)
414
- @external_keys[name] ||= (hook_keys(name) + ["mixin:#{name}".freeze]).freeze
752
+ return build_external_keys(name) if Inference::MemoAudit.enabled? && Inference::MemoAudit.suspended?
753
+
754
+ state = TypingState.current
755
+ table = (state[EXTERNAL_KEYS] ||= {})
756
+ stored = table[name]
757
+ if stored.nil?
758
+ return table[name] = Inference::MemoAudit.key_audit("external keys") { build_external_keys(name) }
759
+ end
760
+
761
+ Inference::MemoAudit.check("external keys", stored, name) { build_external_keys(name) }
415
762
  end
416
763
 
764
+ def self.build_external_keys(name) = (hook_keys(name) + ["mixin:#{name}".freeze]).freeze
765
+ private_class_method :build_extend_hook_keys, :build_hook_keys, :build_external_keys
766
+
417
767
  def record_head(scope, start, side)
418
768
  ResolutionChain.record_class(scope, @root) unless start.zero? && @entries.first&.name == @root
419
769
  start.times { |index| ResolutionChain.record_entry(scope, @entries[index], side) }
@@ -445,6 +795,25 @@ module Rigor
445
795
  @entries.index { |entry| entry.name == name && entry.side == side }
446
796
  end
447
797
 
798
+ # Issue #2979 — the position of the first entry after `name`'s own that is a true ancestor of it, or nil when
799
+ # `name` has no entry on `side`. The entries of the names bound to the same class object
800
+ # (`K = Struct.new("Named", …)`) follow `name`'s own directly, and a `def` under one of them is a redefinition
801
+ # of the class's method, not an override, so a reader of "the entries after the class" starts past them.
802
+ def ancestors_start(scope, name, side = @side)
803
+ own = index_of(name, side)
804
+ return nil if own.nil?
805
+
806
+ twins = DiscoveryIndex.same_class_names(scope.discovery.discovered_parameter_envelopes[name])
807
+ start = own + 1
808
+ start += 1 while twins_entry?(@entries[start], twins, side)
809
+ start
810
+ end
811
+
812
+ def twins_entry?(entry, twins, side)
813
+ !twins.empty? && !entry.nil? && entry.side == side && twins.include?(entry.name)
814
+ end
815
+ private :twins_entry?
816
+
448
817
  # A LEVEL is one superclass step: the class (or its singleton) with the modules inserted around it,
449
818
  # before the next superclass's entries begin. `level_classes[k]` is that class, or nil for a level that
450
819
  # is an external superclass. Only COMPLETE levels are counted — a level the {LIMIT} cut in half is not.
@@ -496,8 +865,21 @@ module Rigor
496
865
  end
497
866
  private :compute_levels
498
867
 
499
- MEMO_KEY = RunScopedSlots.register(:__rigor_resolution_chain__)
500
- private_constant :MEMO_KEY
868
+ # ADR-129 WD1 — a run-scoped Memo: one `[discovery, flavors]` slot keyed on the discovery index's identity, the
869
+ # only input a chain reads.
870
+ #
871
+ # ADR-129 G2 — every table of the slot's flavor buckets is audited on a hit under `RIGOR_MEMO_AUDIT=1`, and an
872
+ # audit recomputation reads a fresh, unstored bucket ({.flavor_bucket}), so it reads nothing stored.
873
+ MEMO = TypingState.memo(
874
+ :resolution_chain,
875
+ run_scoped: true,
876
+ audit: ["resolution chain", "resolution chain name", "resolution chain relevance",
877
+ "resolution chain master order", "resolution chain linearisation", "resolution chain mark",
878
+ "resolution chain entry", "resolution chain external entry", "singleton hook verdict"],
879
+ reason: "one slot keyed on the discovery index's identity; a chain is a pure function of that frozen index " \
880
+ "and the flavor"
881
+ )
882
+ private_constant :MEMO
501
883
 
502
884
  FLAVORS = %i[methods constants arity].freeze
503
885
 
@@ -509,17 +891,40 @@ module Rigor
509
891
 
510
892
  bucket = flavor_bucket(scope.discovery, flavor)
511
893
  chains = bucket[side]
512
- chains[class_name] || (chains[class_name] = Builder.new(scope, flavor, bucket).chain(class_name, side))
894
+ cached = chains[class_name]
895
+ if cached.nil?
896
+ return chains[class_name] = Inference::MemoAudit.key_audit("resolution chain") do
897
+ Builder.new(scope, flavor, bucket).chain(class_name, side)
898
+ end
899
+ end
900
+ return cached unless Inference::MemoAudit.enabled?
901
+
902
+ Inference::MemoAudit.check("resolution chain", cached.audit_signature, class_name, side) do
903
+ fresh = Analysis::DependencyRecorder.withhold do
904
+ Builder.new(scope, flavor, flavor_bucket(scope.discovery, flavor)).chain(class_name, side)
905
+ end
906
+ fresh.first.audit_signature
907
+ end
908
+ cached
513
909
  end
514
910
 
911
+ # The flavor's bucket of memo tables. While an audit recomputation runs ({Inference::MemoAudit.suspended?})
912
+ # every call answers a fresh bucket that is never stored, so the recomputation neither reads nor writes the
913
+ # slot.
515
914
  def self.flavor_bucket(discovery, flavor)
516
- slot = Thread.current[MEMO_KEY]
915
+ return new_bucket if Inference::MemoAudit.enabled? && Inference::MemoAudit.suspended?
916
+
917
+ state = TypingState.current
918
+ slot = state[MEMO]
517
919
  unless slot && slot[0].equal?(discovery)
518
920
  slot = [discovery, {}]
519
- Thread.current[MEMO_KEY] = slot
921
+ state[MEMO] = slot
520
922
  end
521
- slot[1][flavor] ||= { instance: {}, singleton: {}, names: {}, interned: {}, externals: {}, master: {},
522
- marks: {} }
923
+ slot[1][flavor] ||= new_bucket
924
+ end
925
+
926
+ def self.new_bucket
927
+ { instance: {}, singleton: {}, names: {}, interned: {}, externals: {}, master: {}, marks: {} }
523
928
  end
524
929
 
525
930
  # Which project class an as-written ancestor name denotes, under one flavor — shared by the chain and
@@ -536,9 +941,13 @@ module Rigor
536
941
  # ambiguous spelling names (`:arity` only), or nil.
537
942
  def resolve(owner, raw)
538
943
  by_owner = (@names[owner] ||= {})
539
- return by_owner[raw] if by_owner.key?(raw)
944
+ unless by_owner.key?(raw)
945
+ return by_owner[raw] = Inference::MemoAudit.key_audit("resolution chain name") { compute(owner, raw) }
946
+ end
540
947
 
541
- by_owner[raw] = compute(owner, raw)
948
+ Inference::MemoAudit.check("resolution chain name", by_owner[raw], owner, raw) do
949
+ Analysis::DependencyRecorder.withhold { compute(owner, raw) }.first
950
+ end
542
951
  end
543
952
 
544
953
  # {#resolve}, with an ambiguous spelling read as unresolved.
@@ -547,8 +956,28 @@ module Rigor
547
956
  resolved.is_a?(String) ? resolved : nil
548
957
  end
549
958
 
959
+ # Issue #1592 — {#resolve_one}, or else the first candidate the project declares at all: a module holding only
960
+ # hooks (`included do extend M end`) is no project class under any flavor, yet its hooks run.
961
+ # The search stops at the first candidate that exists in any form: one RBS or a gem declares and the project's
962
+ # source does not has no hooks the tables know, so it answers nil.
963
+ #
964
+ # Issue #3015 — a module whose bucket holds the concern entry counts too: its body runs `extend
965
+ # ActiveSupport::Concern`, so the project declares it. A one-file index has no `discovered_class_sources`, and
966
+ # would otherwise drop a concern whose only defs sit in its blocks from an includer's chain.
967
+ def resolve_declared(owner, raw)
968
+ candidates(owner, raw).each do |candidate|
969
+ return candidate if project?(candidate) || @discovery.discovered_class_sources.key?(candidate) ||
970
+ @discovery.discovered_parameter_envelopes[candidate]&.key?(CONCERN_KEY)
971
+ return nil if rbs_known?(candidate)
972
+ end
973
+ nil
974
+ end
975
+
550
976
  def candidates(owner, raw) = @scope.ancestor_name_candidates(owner, raw)
551
977
 
978
+ # Whether `name`, already qualified, is a project class under this flavor.
979
+ def project_name?(name) = project?(name)
980
+
552
981
  private
553
982
 
554
983
  def compute(owner, raw)
@@ -559,6 +988,12 @@ module Rigor
559
988
  alternatives.empty? ? nil : alternatives
560
989
  end
561
990
 
991
+ def rbs_known?(name)
992
+ Rigor::Reflection.rbs_class_known?(name, scope: @scope)
993
+ rescue StandardError
994
+ false
995
+ end
996
+
562
997
  def project?(name)
563
998
  case @flavor
564
999
  when :constants
@@ -574,7 +1009,7 @@ module Rigor
574
1009
  end
575
1010
  private_constant :Resolver
576
1011
 
577
- private_class_method :flavor_bucket
1012
+ private_class_method :flavor_bucket, :new_bucket
578
1013
 
579
1014
  # {Relevance}'s verdict memo, one table per flavor bucket.
580
1015
  def self.relevance_memo(scope, flavor)
@@ -718,9 +1153,16 @@ module Rigor
718
1153
  def self.master_memo(scope, flavor, key)
719
1154
  bucket = flavor_bucket(scope.discovery, flavor)
720
1155
  memo = bucket[:master]
721
- return memo[key] if memo.key?(key)
1156
+ unless memo.key?(key)
1157
+ return memo[key] = Inference::MemoAudit.key_audit("resolution chain master order") do
1158
+ yield(Resolver.new(scope, flavor, bucket))
1159
+ end
1160
+ end
722
1161
 
723
- memo[key] = yield(Resolver.new(scope, flavor, bucket))
1162
+ Inference::MemoAudit.check("resolution chain master order", memo[key], flavor, key) do
1163
+ fresh = flavor_bucket(scope.discovery, flavor)
1164
+ Analysis::DependencyRecorder.withhold { yield(Resolver.new(scope, flavor, fresh)) }.first
1165
+ end
724
1166
  end
725
1167
 
726
1168
  # The linearisation itself, in one world: Ruby's skip rule (`retro: false`), or every skipped insertion
@@ -770,7 +1212,7 @@ module Rigor
770
1212
  # The chain for `root`, carrying its retro world when the skip rule skipped anything on the way.
771
1213
  def chain(root, side)
772
1214
  entries, starts, classes, forks, marks, retro_ok = lin_for(root, side)
773
- order_marked = marks.any? { |mark| mark.kind != :extend_hook }
1215
+ order_marked = marks.any? { |mark| !HOOK_MARK_KINDS.include?(mark.kind) }
774
1216
  retro = build_retro(root, side) if forks == 1 && retro_ok && !order_marked && !@retro
775
1217
  cut_at = cut_position(entries)
776
1218
  return whole_chain(root, side, entries, starts, classes, [forks, marks], retro) if cut_at.nil?
@@ -829,14 +1271,10 @@ module Rigor
829
1271
  memoised(@retro ? :lin_singleton_retro : :lin_singleton, name, depth) { compute_singleton(name, depth) }
830
1272
  end
831
1273
 
832
- def memoised(table, name, depth)
1274
+ def memoised(table, name, depth, &)
833
1275
  memo = (@bucket[table] ||= {})
834
1276
  cached = memo[name]
835
- if cached
836
- # A memoised chain's forks are the asker's too.
837
- @frames.last&.absorb(cached[3], cached[4], cached[5])
838
- return cached
839
- end
1277
+ return absorb_cached(audited_lin(cached, table, name)) if cached
840
1278
 
841
1279
  # A cycle (`class Foo < Foo` resolved inside its own namespace) or a hierarchy deeper than the budget
842
1280
  # contributes nothing past that point, and the chains computed while it did are not memoised: they
@@ -851,7 +1289,7 @@ module Rigor
851
1289
  frame = Frame.new(0, false, NO_MARKS, true)
852
1290
  @frames.push(frame)
853
1291
  stack.push(name)
854
- entries, starts, classes = yield
1292
+ entries, starts, classes = Inference::MemoAudit.key_audit("resolution chain linearisation", &)
855
1293
  stack.pop
856
1294
  @frames.pop
857
1295
  lin = [entries, starts, classes, frame.forks, frame.marks, frame.retro_ok].freeze
@@ -860,6 +1298,29 @@ module Rigor
860
1298
  lin
861
1299
  end
862
1300
 
1301
+ # A memoised linearisation rebuilt for the audit by a Builder over a fresh bucket. A memoised one met no
1302
+ # cycle and stayed inside the depth budget, so its depth does not change it; a retro world that fit
1303
+ # {LIMIT} once fits it again.
1304
+ # A memoised chain's forks are the asker's too.
1305
+ def absorb_cached(cached)
1306
+ @frames.last&.absorb(cached[3], cached[4], cached[5])
1307
+ cached
1308
+ end
1309
+
1310
+ def audited_lin(cached, table, name)
1311
+ Inference::MemoAudit.check("resolution chain linearisation", cached, table, name) { fresh_lin(table, name) }
1312
+ end
1313
+
1314
+ SINGLETON_LIN_TABLES = %i[lin_singleton lin_singleton_retro].freeze
1315
+ private_constant :SINGLETON_LIN_TABLES
1316
+
1317
+ def fresh_lin(table, name)
1318
+ side = SINGLETON_LIN_TABLES.include?(table) ? :singleton : :instance
1319
+ builder = Builder.new(@scope, @flavor, ResolutionChain.send(:flavor_bucket, @discovery, @flavor),
1320
+ retro: @retro)
1321
+ Analysis::DependencyRecorder.withhold { builder.send(:lin_for, name, side) }.first
1322
+ end
1323
+
863
1324
  def hand_up(frame)
864
1325
  parent = @frames.last
865
1326
  return if parent.nil?
@@ -869,9 +1330,11 @@ module Rigor
869
1330
  end
870
1331
 
871
1332
  def compute_instance(name, depth)
872
- # `discovered_includes` already carries the prepended names, so it counts every instance-side edge once.
873
- mark_unsettled(name, :include, (@discovery.discovered_includes[name] || EMPTY).size)
874
- @frames.last.add_marks(mark_for(name, :extend_hook, WILDCARD_LIST, false)) if extend_hook_taint?(name)
1333
+ twins = same_class_twins(name)
1334
+ mark_instance_node(name, :include)
1335
+ twins.each { |owner| mark_instance_node(owner, :include) }
1336
+ return same_class_instance(name, twins, depth) unless twins.empty?
1337
+
875
1338
  entries = [project_entry(name, :instance)]
876
1339
  super_lin = superclass_lin(name, :instance, depth)
877
1340
  entries.concat(super_lin[0])
@@ -883,12 +1346,333 @@ module Rigor
883
1346
  end
884
1347
 
885
1348
  def compute_singleton(name, depth)
886
- mark_unsettled(name, :extend, (@discovery.discovered_extends[name] || EMPTY).size)
1349
+ twins = same_class_twins(name)
1350
+ own = @discovery.discovered_extends[name] || EMPTY
1351
+ own_prepends = own_singleton_prepends(name, own)
1352
+ mark_unsettled(name, :extend, singleton_order_edges(own, own_prepends))
1353
+ twins.each { |owner| mark_unsettled(owner, :extend, (@discovery.discovered_extends[owner] || EMPTY).size) }
1354
+ return same_class_singleton(name, twins, depth) unless twins.empty?
1355
+
887
1356
  entries = [project_entry(name, :singleton)]
888
1357
  super_lin = superclass_lin(name, :singleton, depth)
889
1358
  entries.concat(super_lin[0])
890
- extends = @discovery.discovered_extends[name] || EMPTY
891
- super_start = include_all(entries, name, extends, EMPTY, depth, [0, 1])
1359
+ own_extends = own_prepends.empty? ? own : own - own_prepends
1360
+ extends, prepends = singleton_mixins(name, own_extends, own_prepends)
1361
+ boundary = prepends.empty? ? CLASS_FIRST : prepend_all(entries, name, prepends, depth)
1362
+ super_start = include_all(entries, name, extends, EMPTY, depth, boundary)
1363
+ finish(entries, name, super_lin, super_start)
1364
+ end
1365
+
1366
+ # #1592 — the class's own `extend`s and the modules its direct mixins' hooks extend it with
1367
+ # ({#hook_extensions}), nearest first. Ruby inserts each where its statement ran, and the tables keep the
1368
+ # `extend` and `include` statements apart, so with both kinds present the two blocks' order is a fork: the
1369
+ # chain puts the own `extend`s nearer (the usual layout writes them after the `include`s) and the retro world
1370
+ # the hooks' modules, and a reader that the two worlds answer alike stands, since then only one block can
1371
+ # answer the name. An order the hooks do not fix, or an include whose own position is not a fact, is two
1372
+ # forks, which settle to master. Answers `[extends, prepends]`: a concern's `ClassMethods` on a prepend edge is
1373
+ # prepended to the class object (`Concern#prepend_features` runs `base.singleton_class.prepend`), so it sits
1374
+ # ahead of the class object's own entry.
1375
+ #
1376
+ # Issue #2998 — `own_prepends` are the class's own singleton-side prepends ({#own_singleton_prepends}), which
1377
+ # sit ahead of the class object too. An `extend` always sits behind the class object and a prepend always
1378
+ # ahead of it, so only two prepends can trade places ({#order_prepends}).
1379
+ def singleton_mixins(name, extends, own_prepends)
1380
+ plan = hook_extensions(name)
1381
+ if plan.open || unnamed_instance_mixin?(name)
1382
+ @frames.last.add_marks(mark_for(name, :include_hook, UNNAMED_LIST, false))
1383
+ end
1384
+ fork_hook_order(name, 2) unless plan.ordered || (plan.extends.empty? && plan.prepends.empty?)
1385
+ prepends = order_prepends(name, own_prepends, plan.prepends)
1386
+ hooked = plan.extends
1387
+ return [extends, prepends] if hooked.empty?
1388
+ return [hooked, prepends] if extends.empty?
1389
+
1390
+ fork_hook_order(name, 1)
1391
+ [@retro ? hooked + extends : extends + hooked, prepends]
1392
+ end
1393
+
1394
+ # The class object's prepends, nearest first, once its own are placed against its hooks'. Where their order
1395
+ # is not a fact (own and hook prepends both, or two own ones from two files or with an edge whose position is
1396
+ # not a fact), the chain counts two forks, which settle to master, and the class takes a `:prepend_order`
1397
+ # mark: the typing read declines a definer from its prepend region, since neither order is Ruby's for sure.
1398
+ def order_prepends(name, own, hooked)
1399
+ return hooked if own.empty?
1400
+
1401
+ if !hooked.empty? || (own.size > 1 && !own_prepends_ordered?(name))
1402
+ @frames.last.forks += 2
1403
+ @frames.last.add_marks(mark_for(name, :prepend_order, nil, false))
1404
+ end
1405
+ own + hooked
1406
+ end
1407
+
1408
+ def own_prepends_ordered?(name)
1409
+ !multi_file?(name) && (@discovery.unpositioned_mixins[name]&.dig(:extend) || EMPTY).empty?
1410
+ end
1411
+
1412
+ # Issue #2998 — the spellings of `name`'s singleton edges (`discovered_extends`) that are prepends: a
1413
+ # `class << self; prepend P` or a `singleton_class.prepend P` the walk filed as a
1414
+ # `[ENVELOPE_SINGLETON_PREPEND_KIND, "P"]` entry. They keep the table's order, nearest first, which is the
1415
+ # prepends' own: a later `prepend` sits ahead of an earlier one. A class with any takes the `:singleton_prepend`
1416
+ # mark, which tells the typing read that master's order, which knows no singleton prepend, may answer
1417
+ # otherwise.
1418
+ #
1419
+ # Issue #3020 — a prepend whose module no table names (`class << self; prepend(*MODS)`,
1420
+ # `singleton_class.prepend(*MODS)`) files the `"?"` entry instead, which no edge spells: the class takes the
1421
+ # `:unnamed_prepend` mark, and the typing read declines a definer at or behind the class object.
1422
+ def own_singleton_prepends(name, own)
1423
+ bucket = @discovery.discovered_parameter_envelopes[name]
1424
+ return EMPTY if bucket.nil?
1425
+
1426
+ @frames.last.add_marks(mark_for(name, :unnamed_prepend, nil, false)) if bucket.key?(UNNAMED_PREPEND_KEY)
1427
+ return EMPTY if own.empty?
1428
+
1429
+ prepends = own.select { |raw| bucket.key?([DiscoveryIndex::ENVELOPE_SINGLETON_PREPEND_KIND, raw]) }
1430
+ return EMPTY if prepends.empty?
1431
+
1432
+ @frames.last.add_marks(mark_for(name, :singleton_prepend, nil, false))
1433
+ prepends
1434
+ end
1435
+
1436
+ # The edge count the multi-file rule weighs: a prepend sits ahead of the class object and an `extend` behind
1437
+ # it whatever the load order, so only edges of one kind can trade places across files.
1438
+ def singleton_order_edges(own, own_prepends)
1439
+ return own.size if own_prepends.empty?
1440
+
1441
+ [own_prepends.size, own.size - own_prepends.size].max
1442
+ end
1443
+
1444
+ def fork_hook_order(name, weight)
1445
+ @frames.last.forks += weight
1446
+ @frames.last.add_marks(mark_for(name, :hook_order, nil, false))
1447
+ end
1448
+
1449
+ # The {HookPlan} for a class: the `[owner, spelling]` pairs of the modules the hooks of its direct
1450
+ # mixins extend it with, nearest first (a later include's ahead of an earlier one's, and within one mixin the
1451
+ # hook's own extends ahead of a concern's `ClassMethods`, which `append_features` extends before the
1452
+ # `included` block runs); the concern `ClassMethods` its prepend edges prepend to its singleton; whether one of
1453
+ # those hooks may extend it with a module no entry names; and whether the order is a fact. A concern includer
1454
+ # gets nothing from a concern: `Concern#append_features` records it as a dependency instead (#2992).
1455
+ def hook_extensions(name)
1456
+ includes = @discovery.discovered_includes[name] || EMPTY
1457
+ return NO_HOOKS if includes.empty?
1458
+
1459
+ listed = @discovery.unpositioned_mixins[name]&.dig(:include) || EMPTY
1460
+ plan = HookPlan.new([], [], false, true)
1461
+ each_hooked_mixin(name, includes) do |raw, mixin, hooks, loose|
1462
+ unpositioned = loose || listed.include?(raw) || listed.include?(WILDCARD_LIST.first)
1463
+ merge_targets(plan, hook_targets(mixin, hooks), unpositioned)
1464
+ plan.open ||= uncertain_dependencies?(mixin)
1465
+ end
1466
+ # Two files' includes run in load order.
1467
+ plan.ordered &&= plan.extends.size + plan.prepends.size < 2 || !multi_file?(name)
1468
+ plan
1469
+ end
1470
+
1471
+ # Adds one mixin's targets to the class's plan; an include whose own position is not a fact (`unpositioned`)
1472
+ # leaves its modules' order unknown.
1473
+ def merge_targets(plan, targets, unpositioned)
1474
+ plan.open ||= targets.open
1475
+ return if targets.extends.empty? && targets.prepends.empty?
1476
+
1477
+ plan.ordered &&= targets.ordered && !unpositioned
1478
+ plan.extends.concat(targets.extends)
1479
+ plan.prepends.concat(targets.prepends)
1480
+ end
1481
+
1482
+ # Issue #2994 — whether a module no table names may be mixed into `name`'s instance side by a call whose target
1483
+ # is `name` itself, so that its `included` / `prepended` hook runs with `name` as `base` and may extend the
1484
+ # class object with anything: `name` lists {Relevance::UNNAMED} on its own instance side (`include(*MODS)`,
1485
+ # `include helper`), or a module it includes does (a hook's `base.include(*MODS)`; that module's own
1486
+ # `include(*MODS)` is read so too). A superclass's own mark is read when its singleton chain is built. The
1487
+ # entries this reads are the class's own sites and its direct mixins', which a singleton chain's readers file
1488
+ # already. An extended module's hook that splats a module in already declines the read on another route.
1489
+ def unnamed_instance_mixin?(name)
1490
+ return true if lists_unnamed?(name)
1491
+
1492
+ (@discovery.discovered_includes[name] || EMPTY).uniq.any? do |raw|
1493
+ mixin = @resolver.resolve_declared(name, raw)
1494
+ !mixin.nil? && lists_unnamed?(mixin)
1495
+ end
1496
+ end
1497
+
1498
+ def lists_unnamed?(name)
1499
+ listed = @discovery.unpositioned_mixins[name]&.dig(:include)
1500
+ !listed.nil? && listed.include?(UNNAMED_LIST.last)
1501
+ end
1502
+
1503
+ HookPlan = Struct.new(:extends, :prepends, :open, :ordered)
1504
+ private_constant :HookPlan
1505
+
1506
+ # Yields each direct mixin of `name`, nearest first, as `[spelling, resolved, hooks]`: the hooks its edges
1507
+ # run, `:included` for an `include` and `:prepended` for a `prepend`.
1508
+ #
1509
+ # Issue #2992 — a concern mixin's dependencies follow it, each on the same edge
1510
+ # ({ResolutionChain.concern_dependencies}): `Concern#append_features` includes them into `name` ahead of the
1511
+ # concern itself, so their modules sit farther than the concern's. `spelling` is the mixin's for each, and
1512
+ # `loose` says a dependency's place is not a fact: `Concern` puts a prepended dependency ahead of the included
1513
+ # ones, which the tables list the other way, and two files of the concern add theirs in load order.
1514
+ def each_hooked_mixin(name, includes)
1515
+ prepends = @discovery.discovered_prepends[name] || EMPTY
1516
+ concern = concern?(name)
1517
+ includes.uniq.each do |raw|
1518
+ mixin = @resolver.resolve_declared(name, raw)
1519
+ next if mixin.nil? || (concern && concern?(mixin))
1520
+
1521
+ hooks = []
1522
+ hooks << :prepended if prepends.include?(raw)
1523
+ hooks << :included if includes.count(raw) > prepends.count(raw)
1524
+ yield raw, mixin, hooks, false
1525
+ dependencies = ResolutionChain.concern_dependencies(@discovery, @resolver, mixin)
1526
+ loose = dependencies.size > 1 && dependencies.any? { |member| loose_dependencies?(member, mixin) }
1527
+ dependencies.each { |dependency| yield raw, dependency, hooks, loose }
1528
+ end
1529
+ end
1530
+
1531
+ # Issue #2992 — whether a concern declared in two files includes a concern its dependency entries do not name:
1532
+ # written in a reopening without the `extend ActiveSupport::Concern`, that `include` is a dependency when the
1533
+ # reopening loads after the `extend` and an ordinary include of the concern itself when it loads first, so the
1534
+ # includer may or may not be extended with that concern's modules.
1535
+ def uncertain_dependencies?(mixin)
1536
+ return false unless concern?(mixin) && multi_file?(mixin)
1537
+
1538
+ spellings = ResolutionChain.dependency_spellings(@discovery, mixin)
1539
+ (@discovery.discovered_includes[mixin] || EMPTY).any? do |raw|
1540
+ next false if spellings.include?(raw)
1541
+
1542
+ dependency = @resolver.resolve_declared(mixin, raw)
1543
+ !dependency.nil? && concern?(dependency)
1544
+ end
1545
+ end
1546
+
1547
+ # Whether the concern `owner` or its dependency `member` prepends a module or is declared in two files.
1548
+ def loose_dependencies?(member, owner)
1549
+ [member, owner].any? { |name| multi_file?(name) || !(@discovery.discovered_prepends[name] || EMPTY).empty? }
1550
+ end
1551
+
1552
+ NO_HOOKS = HookPlan.new(EMPTY, EMPTY, false, true).freeze
1553
+ private_constant :NO_HOOKS
1554
+
1555
+ def concern?(name)
1556
+ bucket = @discovery.discovered_parameter_envelopes[name]
1557
+ !bucket.nil? && bucket.key?(CONCERN_KEY)
1558
+ end
1559
+
1560
+ # One mixin's hook-extended modules for the edges `hooks` runs, as {#hook_extensions} answers them.
1561
+ # A hook defined twice (`ENVELOPE_HOOK_DEF_KIND`) is open: which definition Ruby keeps is load order.
1562
+ #
1563
+ # Issue #3005 — a hook block's `def self.x`s (`<included>`) sit on the class object itself, ahead of every
1564
+ # module the block extends it with, so that module goes first and does not make the order unknown.
1565
+ def hook_targets(mixin, hooks)
1566
+ named, open = hook_keys(mixin, hooks)
1567
+ blocks, modules = named.partition { |raw| DiscoveryIndex::HOOK_BLOCK_SEGMENTS.include?(raw) }
1568
+ pairs = (blocks + modules).map { |raw| hook_target_pair(mixin, raw) }
1569
+ targets = HookPlan.new(pairs, [], open, modules.size <= 1)
1570
+ found = concern?(mixin) && const_get_target(mixin, "ClassMethods")
1571
+ (hooks.include?(:prepended) ? targets.prepends : targets.extends) << [mixin, "::#{found}"] if found
1572
+ targets
1573
+ end
1574
+
1575
+ # `[named, open]`: the spellings the mixin's hooks for `hooks` extend with, and whether one of them is open
1576
+ # or defined twice.
1577
+ def hook_keys(mixin, hooks)
1578
+ named = []
1579
+ open = false
1580
+ defs = Hash.new(0)
1581
+ (@discovery.discovered_parameter_envelopes[mixin] || EMPTY_BUCKET).each_key do |key|
1582
+ next unless hooks.include?(ResolutionChain.effective_hook(@discovery, mixin, ResolutionChain.hook_of(key)))
1583
+
1584
+ case key.first
1585
+ when DiscoveryIndex::ENVELOPE_HOOK_EXTEND_KIND then named << key[1]
1586
+ when DiscoveryIndex::ENVELOPE_HOOK_EXTEND_OPEN_KIND then open = true
1587
+ else defs[key[1]] += 1
1588
+ end
1589
+ end
1590
+ [named.uniq, open || defs.each_value.any? { |count| count > 1 }]
1591
+ end
1592
+
1593
+ EMPTY_BUCKET = {}.freeze
1594
+ private_constant :EMPTY_BUCKET
1595
+
1596
+ # The module a hook's constant names, written in the mixin's body: `Mixin::Raw` first (the mixin is the
1597
+ # innermost cref there), then the names its header's nesting gives. A name no project module answers stays
1598
+ # the mixin's spelling, an external entry.
1599
+ def hook_target_pair(mixin, raw)
1600
+ own = "#{mixin}::#{raw}"
1601
+ return [mixin, "::#{own}"] if !raw.start_with?("::") && @resolver.project_name?(own)
1602
+
1603
+ [mixin, raw]
1604
+ end
1605
+
1606
+ # `mixin.const_get(raw)`, as `Concern#append_features` looks `ClassMethods` up: `Name::Raw` for each entry of
1607
+ # the mixin's own instance chain, then a top-level `Raw`; nil when no project module answers, which Concern
1608
+ # then does not extend.
1609
+ #
1610
+ # Issue #2992 — a concern the mixin depends on is no ancestor of it ({ResolutionChain.concern_dependencies}),
1611
+ # so its `ClassMethods` is not found here: it reaches the includer as that dependency's own.
1612
+ def const_get_target(mixin, raw)
1613
+ chain = ResolutionChain.for(@scope, mixin, :instance, @flavor)
1614
+ dependencies = ResolutionChain.concern_dependencies(@discovery, @resolver, mixin)
1615
+ chain.entries.each do |entry|
1616
+ next if dependencies.include?(entry.name)
1617
+
1618
+ names = entry.external? ? entry.candidates : [entry.name]
1619
+ found = names.map { |owner| [owner, "#{owner}::#{raw}"] }.find do |owner, candidate|
1620
+ @resolver.project_name?(candidate) && ResolutionChain.const_get_candidate?(@discovery, owner, candidate)
1621
+ end&.last
1622
+ return found if found
1623
+ end
1624
+ @resolver.project_name?(raw) ? raw : nil
1625
+ end
1626
+
1627
+ # The instance-side marks of one node; `kind` is the side's mixin kind, `:include`. `discovered_includes`
1628
+ # already carries the prepended names, so it counts every instance-side edge once.
1629
+ def mark_instance_node(name, kind)
1630
+ mark_unsettled(name, kind, (@discovery.discovered_includes[name] || EMPTY).size)
1631
+ hook = extend_hook_taint(name)
1632
+ @frames.last.add_marks(mark_for(name, :extend_hook, hook, false)) if hook
1633
+ end
1634
+
1635
+ # Issue #2979 — the names `name`'s envelope bucket binds to the same class object (`K = Struct.new("Named",
1636
+ # …)` makes `K` and `Struct::Named` one class), without `name`; empty, and allocating nothing, for nearly every
1637
+ # class. The chain of either name heads with every name's entry, so a reader asks each name's own tables where
1638
+ # Ruby asks the one class's; nothing is copied between the names. Only the direct binding counts: two
1639
+ # `Struct.new("Named", …)` writes make two classes in Ruby, and neither constant reaches the other's tables
1640
+ # through `Struct::Named`. {ResolutionChain#ancestors_start} tells a reader where the true ancestors begin.
1641
+ def same_class_twins(name)
1642
+ twins = DiscoveryIndex.same_class_names(@discovery.discovered_parameter_envelopes[name])
1643
+ twins.include?(name) ? twins - [name] : twins
1644
+ end
1645
+
1646
+ # The instance side of a class with twins: every name's prepends ahead of the class, then every name's
1647
+ # includes after the LAST name, so the names stay ahead of every module either one includes, as the one class
1648
+ # heads its own ancestors. The superclass edge stays `name`'s own: a Struct's class has none the tables
1649
+ # record, under either name.
1650
+ def same_class_instance(name, twins, depth)
1651
+ names = [name, *twins].uniq
1652
+ entries = names.map { |owner| project_entry(owner, :instance) }
1653
+ super_lin = superclass_lin(name, :instance, depth)
1654
+ entries.concat(super_lin[0])
1655
+ boundary = [0, names.size]
1656
+ names.each do |owner|
1657
+ boundary = prepend_all(entries, owner, @discovery.discovered_prepends[owner] || EMPTY, depth, boundary)
1658
+ end
1659
+ super_start = names.reduce(boundary[1]) do |start, owner|
1660
+ include_all(entries, owner, @discovery.discovered_includes[owner] || EMPTY,
1661
+ @discovery.discovered_prepends[owner] || EMPTY, depth, [boundary[0] + names.size - 1, start])
1662
+ end
1663
+ finish(entries, name, super_lin, super_start)
1664
+ end
1665
+
1666
+ # The singleton twin of {#same_class_instance}: every name's class-object entry, then every name's extends.
1667
+ def same_class_singleton(name, twins, depth)
1668
+ names = [name, *twins].uniq
1669
+ entries = names.map { |owner| project_entry(owner, :singleton) }
1670
+ super_lin = superclass_lin(name, :singleton, depth)
1671
+ entries.concat(super_lin[0])
1672
+ super_start = names.reduce(names.size) do |start, owner|
1673
+ include_all(entries, owner, @discovery.discovered_extends[owner] || EMPTY, EMPTY, depth,
1674
+ [names.size - 1, start])
1675
+ end
892
1676
  finish(entries, name, super_lin, super_start)
893
1677
  end
894
1678
 
@@ -910,24 +1694,53 @@ module Rigor
910
1694
  # The one-element frozen array carrying the {Mark} of `[name, kind]`, interned in the bucket.
911
1695
  def mark_for(name, kind, listed, multi)
912
1696
  by_node = (@bucket[:marks][kind] ||= {})
913
- by_node[name] ||= [ResolutionChain::Mark.new(name, kind, listed.nil? ? EMPTY : listed.to_a.freeze,
914
- multi)].freeze
1697
+ cached = by_node[name]
1698
+ if cached.nil?
1699
+ return by_node[name] = Inference::MemoAudit.key_audit("resolution chain mark") do
1700
+ new_mark(name, kind, listed, multi)
1701
+ end
1702
+ end
1703
+ return cached unless Inference::MemoAudit.enabled?
1704
+
1705
+ # The failure names the whole key: the bucket's flavor, the mark's kind, the node.
1706
+ Inference::MemoAudit.check("resolution chain mark", cached, [@flavor, kind], name) do
1707
+ new_mark(name, kind, listed, multi)
1708
+ end
915
1709
  end
916
1710
 
917
- WILDCARD_LIST = ["*"].freeze # Relevance::WILDCARD, which loads after this file
918
- private_constant :WILDCARD_LIST
1711
+ def new_mark(name, kind, listed, multi)
1712
+ [ResolutionChain::Mark.new(name, kind, listed.nil? ? EMPTY : listed.to_a.freeze, multi)].freeze
1713
+ end
1714
+
1715
+ # The `[origin, super_start]` of a one-name chain: the class at 0, its superclass's entries from 1.
1716
+ CLASS_FIRST = [0, 1].freeze
1717
+ private_constant :CLASS_FIRST
919
1718
 
920
1719
  # #1730 — whether a module `name` extends has a hook that may mix into `name`'s instance side or its
921
1720
  # includers' ({ResolutionChain.extend_reshapes_instance?}). Ruby runs it at the `extend`, so the mixin is
922
1721
  # not a fact of the tables and `name`'s instance side is unpositioned. A spelling no project module
923
1722
  # answers taints nothing: the project cannot see its hooks.
924
- def extend_hook_taint?(name)
925
- (@discovery.discovered_extends[name] || EMPTY).any? do |raw|
1723
+ #
1724
+ # The mark's list: nil when no extended module taints, else {UNNAMED_LIST} when one of those modules lists `"?"`
1725
+ # (#2991), {WILDCARD_LIST} otherwise. Only the marker carries over: a name such a module lists is spelled
1726
+ # relative to that module, not to the extender.
1727
+ def extend_hook_taint(name)
1728
+ found = nil
1729
+ (@discovery.discovered_extends[name] || EMPTY).each do |raw|
926
1730
  resolved = @resolver.resolve_one(name, raw)
927
- !resolved.nil? && ResolutionChain.extend_reshapes_instance?(@scope, resolved)
1731
+ next if resolved.nil? || !ResolutionChain.extend_reshapes_instance?(@scope, resolved)
1732
+ return UNNAMED_LIST if @discovery.unpositioned_mixins[resolved][:include].include?(UNNAMED_LIST.last)
1733
+
1734
+ found = WILDCARD_LIST
928
1735
  end
1736
+ found
929
1737
  end
930
1738
 
1739
+ WILDCARD_LIST = ["*"].freeze # Relevance::WILDCARD, which loads after this file
1740
+ UNNAMED_LIST = ["*", "?"].freeze # with Relevance::UNNAMED
1741
+ UNNAMED_PREPEND_KEY = [DiscoveryIndex::ENVELOPE_SINGLETON_PREPEND_KIND, "?"].freeze # with Relevance::UNNAMED
1742
+ private_constant :WILDCARD_LIST, :UNNAMED_LIST, :UNNAMED_PREPEND_KEY
1743
+
931
1744
  def multi_file?(name)
932
1745
  sites = @discovery.discovered_class_sources[name]
933
1746
  !sites.nil? && sites.size >= 2
@@ -972,14 +1785,15 @@ module Rigor
972
1785
  # module's own includes may have run before or after), and the tables record `include M; prepend M`
973
1786
  # exactly as a plain `prepend M`. A direct prepend of the module itself is always inserted, in Ruby and
974
1787
  # here, so it forks nothing.
975
- def prepend_all(entries, owner, prepends, depth)
976
- origin = 0
977
- super_start = 1
1788
+ def prepend_all(entries, owner, prepends, depth, boundary = CLASS_FIRST)
1789
+ origin, super_start = boundary
978
1790
  # The table holds every statement, nearest first: a same-owner repeat is a no-op in Ruby (the first
979
1791
  # statement wins), so it is dropped here rather than met as a fork.
980
1792
  prepends.reverse.uniq.each do |raw|
981
- modules = Array(resolve(owner, raw))
982
- each_mixin_chain(owner, raw, depth) do |sub|
1793
+ # A hook's module comes as `[owner, spelling]`, as in {#include_all}.
1794
+ from, raw = raw.is_a?(Array) ? raw : [owner, raw]
1795
+ modules = Array(resolve(from, raw))
1796
+ each_mixin_chain(from, raw, depth) do |sub|
983
1797
  point = -1
984
1798
  sub.each do |entry|
985
1799
  found = entries.index(entry)
@@ -1010,7 +1824,9 @@ module Rigor
1010
1824
  mixins.reverse_each do |raw|
1011
1825
  next if skip.include?(raw)
1012
1826
 
1013
- each_mixin_chain(owner, raw, depth) do |sub|
1827
+ # A hook's module comes as `[owner, spelling]`: the mixin whose body wrote it resolves it.
1828
+ from, raw = raw.is_a?(Array) ? raw : [owner, raw]
1829
+ each_mixin_chain(from, raw, depth) do |sub|
1014
1830
  point = origin
1015
1831
  sub.each_with_index do |entry, index|
1016
1832
  found = @retro ? nil : entries.index(entry)
@@ -1051,14 +1867,43 @@ module Rigor
1051
1867
 
1052
1868
  def project_entry(name, side)
1053
1869
  by_side = (@bucket[:interned][side] ||= {})
1054
- by_side[name] ||= Entry.new(name, side, nil, nil, false, name.to_s.split("::").last.freeze, nil)
1870
+ cached = by_side[name]
1871
+ if cached.nil?
1872
+ return by_side[name] = Inference::MemoAudit.key_audit("resolution chain entry") do
1873
+ new_project_entry(name, side)
1874
+ end
1875
+ end
1876
+ return cached unless Inference::MemoAudit.enabled?
1877
+
1878
+ Inference::MemoAudit.check("resolution chain entry", cached, [@flavor, side], name) do
1879
+ new_project_entry(name, side)
1880
+ end
1881
+ end
1882
+
1883
+ def new_project_entry(name, side)
1884
+ Entry.new(name, side, nil, nil, false, name.to_s.split("::").last.freeze, nil)
1055
1885
  end
1056
1886
 
1057
1887
  def external_entry(owner, raw, side, superclass_edge)
1058
1888
  by_edge = ((@bucket[:externals][side] ||= {})[superclass_edge] ||= {})
1059
1889
  by_owner = (by_edge[owner] ||= {})
1060
- by_owner[raw] ||= Entry.new(nil, side, raw, @scope.ancestor_name_candidates(owner, raw).freeze,
1061
- superclass_edge, nil, owner)
1890
+ cached = by_owner[raw]
1891
+ if cached.nil?
1892
+ return by_owner[raw] = Inference::MemoAudit.key_audit("resolution chain external entry") do
1893
+ new_external_entry(owner, raw, side, superclass_edge)
1894
+ end
1895
+ end
1896
+ return cached unless Inference::MemoAudit.enabled?
1897
+
1898
+ # The failure names the whole key: flavor, side, superclass edge, owner and spelling.
1899
+ Inference::MemoAudit.check("resolution chain external entry", cached, [@flavor, side, superclass_edge],
1900
+ [owner, raw]) do
1901
+ Analysis::DependencyRecorder.withhold { new_external_entry(owner, raw, side, superclass_edge) }.first
1902
+ end
1903
+ end
1904
+
1905
+ def new_external_entry(owner, raw, side, superclass_edge)
1906
+ Entry.new(nil, side, raw, @scope.ancestor_name_candidates(owner, raw).freeze, superclass_edge, nil, owner)
1062
1907
  end
1063
1908
  end
1064
1909
  private_constant :Builder