rigortype 0.4.0 → 0.4.2

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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -0,0 +1,1032 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Scope
5
+ # ADR-24 (amended for #1567, #1568, #1570 and #1571) — Ruby's method resolution order for one project class or
6
+ # module, built from the discovery tables. Internal: `Scope`'s readers, the check rules and `Reflection`
7
+ # read it, and it is not part of the plugin surface `Scope` exposes. This is the ONE place that walks
8
+ # `discovered_includes`,
9
+ # `discovered_prepends`, `discovered_superclasses` and `discovered_extends` transitively to answer "which
10
+ # definer does Ruby call": `Scope#user_def_through_ancestors` and its siblings, the override rules, the
11
+ # constant ladder's ancestor rung and `call.wrong-arity`'s level walk all read it, and
12
+ # `spec/rigor/scope/ancestry_walker_detection_spec.rb` fails on a new method that walks the tables itself.
13
+ #
14
+ # The walks it replaced were breadth-first, which is not Ruby's order: `class C < Base; include A` with `A`
15
+ # including `M` reaches `Base` (depth 1) before `M` (depth 2), while `C.ancestors` is `[C, A, M, Base]`.
16
+ # The chain instead replays CRuby's own insertion rule (`include_modules_at` in `class.c`) over the tables:
17
+ #
18
+ # - A class's chain is the class followed by its superclass's chain; a module has no superclass.
19
+ # - Each `prepend`, in statement order, inserts the module's own chain directly before the class, skipping a
20
+ # module already in the class's prepend region (and ONLY there — `class C < B; prepend W` where `B`
21
+ # includes `W` gives `[W, C, B, W]`).
22
+ # - Each `include`, in statement order (`include M, N` is `include N; include M`), inserts the module's own
23
+ # chain directly after the class, skipping a module already ANYWHERE in the chain — the superclass's
24
+ # included and prepended modules too (#1570: `class C < Base; include M` where `Base` includes `M` is a
25
+ # no-op). A skipped module found between the insertion point and the superclass moves the insertion point
26
+ # past it, which is how `include M; include Z` with `Z` including `M` gives `[C, Z, M]`.
27
+ # - The singleton side is the class object's singleton, its `extend`s and `class << self; include`s
28
+ # inserted by the include rule (each module expanded to its instance chain), then the superclass's
29
+ # singleton chain.
30
+ #
31
+ # The insertion rule is Ruby's for the order the statements RAN in, and the tables hold only their final
32
+ # state. The chain therefore replays every edge once, eagerly, and counts a FORK wherever Ruby's own order
33
+ # could have gone another way. The single-route argument (`class.c`): Ruby skips an insertion only when the
34
+ # module is already present, and that takes a second route to it through the tables. So where the replay
35
+ # inserts every entry exactly once and no owner on the chain is unsettled (below), Ruby's first-occurrence
36
+ # ancestor order equals the replay's under every interleaving of the statements. A fork is a place where a
37
+ # module reached a chain by a second route: an `include` skipped because the chain carries the module, a
38
+ # prepend skipped or an entry of a prepended module's own chain already carried by the class. The ONE
39
+ # decision every first-definer reader makes is {#settle}: with no fork the chain stands; with exactly one
40
+ # include-side fork, at or after the class, on the last entry of the sub-chain being inserted, Ruby has
41
+ # exactly two worlds (the skip made, or the insertion made — `class Base; include M; end` after `class C <
42
+ # Base; include M` keeps both copies, `[C, M, Base, M]`), and the chain stands only where that retro world
43
+ # gives the reader the same answer; with any other fork or two or more the worlds are more than two and
44
+ # the chain never stands. Where it does not stand, a reader answers what the walk this chain replaced
45
+ # answered ({MasterOrder}): the tables cannot say which world ran, and a disagreement is no reason to
46
+ # answer anything new. That leaves #1570's redundant `include` with the answer master gave, since the two
47
+ # worlds disagree there; ADR-119's arity decision point is where it gets fixed.
48
+ #
49
+ # Two things the tables do not record, so the chain cannot reproduce them:
50
+ #
51
+ # - The interleaving of a body's `include` and `prepend` statements, and whether a class both includes and
52
+ # prepends one module (`include M; prepend M` is `[M, C, M]` in Ruby; the tables record it as a plain
53
+ # `prepend M`). Each kind keeps its statement order and the chain processes a body's prepends before its
54
+ # includes. Only the trailing duplicate is ever missing, so a first-occurrence reader is unaffected
55
+ # until a later include propagates into the prepended module (`module M0; prepend M1; end`, `class C;
56
+ # include M0; end`, then `module M0; include M1; end` and `module M1; include M3; end` is `[C, M1, M0,
57
+ # M1, M3]` in Ruby and `[C, M1, M3, M0]` here), which is why an entry of a prepended module's own chain
58
+ # that the class already carries is a fork. The same holds through a module's own includes (`class C2;
59
+ # include A; prepend M` with `A` including `M` is `[M, C2, A, M]` in Ruby and `[M, C2, A]` here).
60
+ # CRuby also propagates a later prepend into the includers of a prepended module and leaves a trailing
61
+ # duplicate (`module M0; prepend M3; end; class Base; prepend M0; end; module M0; prepend M4; end` is
62
+ # `[M4, M3, M0, M4, Base]`); the first-occurrence order is the chain's, and the duplicate is not modelled.
63
+ # - `class << self; prepend P`, which the extends table records (and the extends fold copies) as an
64
+ # `extend`: the chain places `P` after the singleton, where Ruby places it before.
65
+ #
66
+ # An ancestor the project does not declare (`Enumerable`, `< ActiveRecord::Base`) is an EXTERNAL entry at
67
+ # its Ruby position, carrying the name as written and the names that spelling can denote; its own RBS
68
+ # ancestry is not expanded. `Object`, `Kernel` and `BasicObject` are implicit after a chain whose last
69
+ # superclass the project declares. Which as-written names count as project classes is the reader's
70
+ # FLAVOR, because the three consumers asked three different questions before this chain existed and a bug
71
+ # fix does not unify them:
72
+ #
73
+ # - `:methods` — `Scope#known_user_class?`, the method walks' predicate since #723;
74
+ # - `:constants` — a key of `discovered_superclasses`, `discovered_includes` or `discovered_classes`, the
75
+ # constant ladder's (#354), which also admits a module that holds only constants;
76
+ # - `:arity` — `Scope#known_user_class?` or a `discovered_parameter_envelopes` key, `call.wrong-arity`'s
77
+ # (#992), which also expands a name a compact-header rename left ambiguous to BOTH classes it can name
78
+ # (#986): their mutual order is load order, and the arity rule's per-level agreement absorbs it.
79
+ #
80
+ # Building a chain records nothing (ADR-46): it reads the tables raw and is memoised per discovery index.
81
+ # A READ records, through {#search} and {#record}.
82
+ class ResolutionChain
83
+ # One ancestor on a chain. A project entry names a class or module (`name`) on `side` `:instance` (its
84
+ # instance methods) or `:singleton` (the class object's singleton). An external entry has no `name`: it
85
+ # keeps the spelling (`raw`) and its candidate names (`candidates`, most qualified first, empty for an
86
+ # ambiguous spelling), and `superclass_edge` says it was reached through a superclass edge. `owner` names
87
+ # the class or module whose edge an external entry is (#1698: Ruby resolves an `include`'s argument with
88
+ # that body as the innermost cref, a namespace `candidates` leaves out); it is nil on a project entry.
89
+ # `last_segment` is the unqualified name a project entry files its negative class edge under, computed once
90
+ # when the entry is interned.
91
+ Entry = Data.define(:name, :side, :raw, :candidates, :superclass_edge, :last_segment, :owner) do
92
+ def external? = name.nil?
93
+ end
94
+
95
+ # How many project entries a chain may hold before it is cut (ADR-41 WD4). The breadth-first walks
96
+ # counted visited project nodes against the same number.
97
+ LIMIT = 100
98
+
99
+ # ADR-119 WD2 — what marked a chain unsettled, one per marking node and side: `node` is the class or
100
+ # module whose mixin edges' order is not a fact, `kind` the side read (`:include` or `:extend`), `listed`
101
+ # the entries `DiscoveryIndex#unpositioned_mixins` lists for it (`"*"` included), and `multi_file` whether
102
+ # the multi-file rule fired (two or more files declare it and it has two or more edges on that side).
103
+ # Interned per `[node, kind]` in the flavor's bucket, so equal marks are one object. Kind `:extend_hook`
104
+ # (#1730, listing `"*"`) marks a node a module it extends may mix into through a hook; it is not an order mark
105
+ # ({#order_marks}).
106
+ Mark = Data.define(:node, :kind, :listed, :multi_file)
107
+
108
+ NO_MARKS = [].freeze
109
+
110
+ attr_reader :root, :side, :entries, :level_starts, :level_classes, :flavor
111
+
112
+ # rubocop:disable-next Metrics/ParameterLists
113
+ def initialize(root:, side:, entries:, level_starts:, level_classes:, levels_end:, truncated:, forks: 0,
114
+ marks: NO_MARKS, flavor: :methods, retro: nil)
115
+ @root = root
116
+ @side = side
117
+ @entries = entries
118
+ @level_starts = level_starts
119
+ @level_classes = level_classes
120
+ @levels_end = levels_end
121
+ @truncated = truncated
122
+ @forks = forks
123
+ @marks = marks
124
+ @flavor = flavor
125
+ @retro = retro
126
+ @memo = {} # the chain is frozen; this table is not, and holds {#levels}
127
+ freeze
128
+ end
129
+
130
+ # True when the chain was cut at {LIMIT} project entries: a reader that found nothing on it cannot tell
131
+ # "no definer" from "a definer past the cut", which is budget uncertainty, not absence.
132
+ def truncated? = @truncated
133
+
134
+ # How many forks the replay met while this chain was built, counted across every module sub-chain it drew
135
+ # on (a memoised sub-chain hands its own count up): each entry that reached the chain by a second route.
136
+ # An over-count only ever sends a reader to master's answer.
137
+ attr_reader :forks
138
+ alias skip_count forks
139
+
140
+ # True when a class on the chain has mixin edges whose order the tables cannot vouch for
141
+ # (`DiscoveryIndex#unpositioned_mixins`, or a class declared in several files with several edges).
142
+ def unsettled? = !order_marks.empty?
143
+
144
+ # The marks that make the chain's ORDER doubtful. An `:extend_hook` mark (#1730) says only that an extended
145
+ # module's hook may have mixed something in that the chain does not list: it makes a candidate-set read
146
+ # `:unknown` and {#wildcard_mixin?} true, and leaves every other reader on the chain's own order. Sending
147
+ # those readers to master's order would answer a constant lookup breadth-first, which is wrong for the
148
+ # #1567 shape on every class that extends such a module.
149
+ def order_marks
150
+ @memo[:order_marks] ||= @marks.reject { |mark| mark.kind == :extend_hook }.freeze
151
+ end
152
+
153
+ # True when a class on the chain records a mixin the tables cannot name (`"*"`: an `include` of a
154
+ # non-constant, or a call the walk cannot record), so the chain may hold a module it does not list.
155
+ def wildcard_mixin? = @marks.any? { |mark| mark.listed.include?(Relevance::WILDCARD) }
156
+
157
+ # The ONE decision every first-definer reader makes: does `answer`, read off this chain, stand, or does
158
+ # the reader answer what the walk this chain replaced answered ({MasterOrder})? Returns `:chain` or
159
+ # `:master`.
160
+ #
161
+ # - Unsettled (a class on the chain has a mixin edge whose order is not a fact, or is declared in several
162
+ # files with several edges): master's answer, whatever the fork count, and the block is not called.
163
+ # - No fork: every entry was inserted once by a single route, so Ruby's first-occurrence order is the
164
+ # chain's under every interleaving, and the chain stands.
165
+ # - One fork that is an include-side skip at or after the class on the last entry of the sub-chain: two
166
+ # worlds (the skip made, the insertion made). The chain stands when the retro world gives the same
167
+ # answer, which the block computes from the chain it is handed; where they differ, or the retro world
168
+ # was too large to build, master's answer stands.
169
+ # - Any other fork, or two or more: more than two worlds, so no answer read off two of them is
170
+ # trustworthy. Master's answer stands, and the block is not called.
171
+ #
172
+ # ADR-119 WD2 — `unknown_for: name` is the candidate-set read's option (only {Inference::DefinerResolution}
173
+ # passes it): a `:master` verdict becomes `:unknown`, since a reader that can decline has no use for the
174
+ # replaced walk's answer, and a chain whose every mark is discharged for `name` (see {Relevance}) and that
175
+ # has no fork is read as settled. Without the option the verdict is never `:unknown`: the readers that
176
+ # compare it with `:master` would read a leaked `:unknown` as `:chain`.
177
+ def settle(scope, answer, owner: nil, unknown_for: nil, &)
178
+ # The verdict depends on EVERY node of the chain (a fork, an unpositioned edge, a second declaring file),
179
+ # and `:master` changes the answer, so the entries past the answer are dependencies whichever way it
180
+ # goes; `search` files only the entries ahead of an answer. `owner` names the entry that answered.
181
+ record_beyond(scope, owner, unknown_for.nil?)
182
+ return settle_unknown(scope, answer, unknown_for, &) unless unknown_for.nil?
183
+ return :master unless order_marks.empty?
184
+
185
+ case @forks
186
+ when 0 then :chain
187
+ when 1 then !@retro.nil? && answer == yield(@retro) ? :chain : :master
188
+ else :master
189
+ end
190
+ end
191
+
192
+ # {#settle} under `unknown_for:`: a mark that is not discharged for the name, a fork the marks overlap
193
+ # (relevance is argued for the fork-free case only), a second fork or a retro world that answers
194
+ # otherwise are all `:unknown`. The retro world is the one built when the chain had exactly one fork and
195
+ # no mark; a chain with a fork is never narrowed, so none is built on demand.
196
+ def settle_unknown(scope, answer, name)
197
+ return settle_marked(scope, name) unless @marks.empty?
198
+
199
+ case @forks
200
+ when 0 then :chain
201
+ when 1 then !@retro.nil? && answer == yield(@retro) ? :chain : :unknown
202
+ else :unknown
203
+ end
204
+ end
205
+
206
+ # A marked chain stands only with no fork and every mark discharged for the name.
207
+ def settle_marked(scope, name)
208
+ fork_free = @forks.zero?
209
+ return :unknown unless fork_free && @marks.all? { |mark| Relevance.discharged?(scope, self, mark, name) }
210
+
211
+ :chain
212
+ end
213
+ private :settle_marked
214
+ private :settle_unknown
215
+
216
+ # Walks the entries in Ruby's order from `start` up to (not including) `stop`, and returns the first
217
+ # truthy value the block gives for an entry, or nil. `side:` yields only the entries on that side.
218
+ #
219
+ # The ADR-46 contract is the one the breadth-first walks had, in Ruby's order and in the order they filed
220
+ # it: the answer depends on every project entry AHEAD of it — each one's ancestry edges decided where the
221
+ # next one sits — so each records a class edge as the search passes it, and so does the root, first, when
222
+ # it does not head the part searched (its prepends, or its own edges when the search starts past it,
223
+ # placed the rest). The entry that answers records none of its own: its method edge is the caller's
224
+ # `Scope#user_def_for`. A read that finds nothing has passed every entry.
225
+ def search(scope, start = 0, stop = nil, side: nil)
226
+ recording = Analysis::DependencyRecorder.active?
227
+ stop ||= @entries.size
228
+ if recording
229
+ record_head(scope, start, side)
230
+ record_extend_hooks(scope)
231
+ end
232
+ index = start
233
+ while index < stop
234
+ entry = @entries[index]
235
+ answer = side.nil? || entry.side == side ? yield(entry) : nil
236
+ return answer if answer
237
+
238
+ ResolutionChain.record_entry(scope, entry, side) if recording
239
+ index += 1
240
+ end
241
+ nil
242
+ end
243
+
244
+ # Records the ADR-46 class edge of the root and of every project entry ahead of `stop` (on `side`, when
245
+ # given; every entry when `stop` is nil) — the dependency of a read that consulted those positions.
246
+ def record(scope, stop = nil, side: nil)
247
+ return unless Analysis::DependencyRecorder.active?
248
+
249
+ ResolutionChain.record_class(scope, @root)
250
+ (stop || @entries.size).times { |index| ResolutionChain.record_entry(scope, @entries[index], side) }
251
+ record_extend_hooks(scope)
252
+ end
253
+
254
+ # What a {#settle} verdict adds to what the search already filed. The search files the root and every entry
255
+ # AHEAD of the answer; entries strictly after it matter only through the verdict (a fork or an unpositioned
256
+ # edge there sends the reader to master's answer), so they are filed here, and only where master's answer
257
+ # could differ from the chain's:
258
+ #
259
+ # - the answer is the root's own entry and nothing is prepended (the root heads its chain), and the verdict
260
+ # is `:chain` or `:master` (`master_only`): master's first candidate is the root too, so no verdict can
261
+ # move it and nothing more is filed. Under `unknown_for:` a mark or a `"*"` elsewhere on the chain turns
262
+ # it `:unknown`, so the rest of the chain is filed as for any other answer;
263
+ # - otherwise every entry after the answer (the whole chain when `owner` names none) files its class edge
264
+ # and, for a project entry, the negative class edge on its unqualified name, so a NEW file declaring or
265
+ # reopening it re-checks the reader. An external entry files only the sites of the names it can denote,
266
+ # and every external on the chain files its hook edges ({#record_hooks}, #1641).
267
+ #
268
+ # What is filed is a function of this chain, the entry the filing starts from and the discovery index the
269
+ # sites are read from, so a consumer that settles the same chain from the same entry again (the common case:
270
+ # one hierarchy read at every call site of a file) files it once ({Analysis::DependencyRecorder.file_chain_once},
271
+ # #1590). The recorded edges are the same; only the repeated filing is gone.
272
+ def record_beyond(scope, owner, master_only)
273
+ return unless Analysis::DependencyRecorder.active?
274
+ return if master_only && !owner.nil? && owner == @root && @entries.first&.name == @root
275
+
276
+ start = owner.nil? ? nil : @entries.index { |entry| entry.name == owner }
277
+ Analysis::DependencyRecorder.file_chain_once(self, start, scope.discovery) { file_beyond(scope, start) }
278
+ record_hooks(scope)
279
+ record_extend_hooks(scope)
280
+ end
281
+ private :record_beyond
282
+
283
+ def file_beyond(scope, start)
284
+ ResolutionChain.record_class(scope, @root) if start.nil?
285
+ index = start.nil? ? 0 : start + 1
286
+ while index < @entries.size
287
+ entry = @entries[index]
288
+ ResolutionChain.record_entry(scope, entry, nil)
289
+ Analysis::DependencyRecorder.read_missing(:class, entry.last_segment) unless entry.external?
290
+ index += 1
291
+ end
292
+ end
293
+ private :file_beyond
294
+
295
+ # #1641 — the hook edges of every EXTERNAL entry on the chain, ahead of the answer or past it. A project file
296
+ # that gives an external module a hook (`module Comparable; def self.included(base) = base.prepend(P); end`)
297
+ # can put a definer ahead of the answer wherever the module sits, and a candidate-set read declines on it
298
+ # (the hook's mixin lists `"*"` on the module). The entry's class edges are the sites of the names it can
299
+ # denote, which a NEW file is not among, and a negative class edge on its name would re-check every reader of
300
+ # `Kernel` or `Enumerable` on any reopening. So each candidate name files the negative method edge of every
301
+ # {Relevance::HOOKS} name on its singleton side (`method:Comparable.included`, the key a new `def
302
+ # self.included` satisfies); a file adding any other method to the module re-checks nothing, and once the
303
+ # module is declared its sites carry later edits and the removal. The edges are a function of the chain alone,
304
+ # so a consumer files them once per chain ({Analysis::DependencyRecorder.file_chain_once}, #1590). A hook
305
+ # written another way — an instance `def included` in a module extended onto the external, a
306
+ # `define_singleton_method(:included)` — satisfies no such key and is not seen.
307
+ def record_hooks(scope)
308
+ externals = (@memo[:externals] ||= @entries.select(&:external?).freeze)
309
+ return if externals.empty?
310
+
311
+ Analysis::DependencyRecorder.file_chain_once(self, :hooks, scope.discovery) do
312
+ externals.each do |entry|
313
+ entry.candidates.each { |name| Analysis::DependencyRecorder.read_keys(ResolutionChain.hook_keys(name)) }
314
+ end
315
+ end
316
+ end
317
+ private :record_hooks
318
+
319
+ # #1730 — the edges of the {Builder#extend_hook_taint?} decision: an instance chain's project entries may carry
320
+ # a `"*"` mark because a module one of them `extend`s has a hook that mixes into the extender, and the
321
+ # module is often declared in a file the chain records nothing of. Each such module the decision read files
322
+ # its class edges (an edit to its hook moves its declaration signature) and the negative method edges of
323
+ # the hooks the decision reads ({ResolutionChain.extend_hook_keys}), which a NEW file reopening the module
324
+ # to add a hook satisfies; a spelling no project module answers files the singleton hook keys of each name
325
+ # it can denote, as {#record_hooks} does. Filed once per consumer and chain.
326
+ def record_extend_hooks(scope)
327
+ return unless @side == :instance
328
+
329
+ targets = (@memo[:extend_targets] ||= ResolutionChain.extend_targets(scope, @flavor, @entries))
330
+ return if targets.empty?
331
+
332
+ Analysis::DependencyRecorder.file_chain_once(self, :extend_hooks, scope.discovery) do
333
+ targets.each do |raw, resolved, candidates|
334
+ Analysis::DependencyRecorder.read_last_segment(:class, raw)
335
+ if resolved
336
+ ResolutionChain.record_class(scope, resolved)
337
+ Analysis::DependencyRecorder.read_keys(ResolutionChain.extend_hook_keys(resolved))
338
+ else
339
+ candidates.each { |name| Analysis::DependencyRecorder.read_keys(ResolutionChain.hook_keys(name)) }
340
+ end
341
+ end
342
+ end
343
+ end
344
+ private :record_extend_hooks
345
+
346
+ # The hooks that let a module `extend`ed onto N reshape N's INSTANCE side (#1730): its singleton `extended` /
347
+ # `extend_object` run against N at the `extend` and may mix into N; its instance `included` /
348
+ # `append_features` / `prepended` / `prepend_features` / `inherited` become N's own singleton hooks and run
349
+ # against N's includers, prependers and subclasses, which the mark on N reaches through their chains.
350
+ SINGLETON_EXTEND_HOOKS = %i[extended extend_object].freeze
351
+ INSTANCE_EXTEND_HOOKS = %i[included append_features prepended prepend_features inherited].freeze
352
+
353
+ @extend_hook_keys = {}
354
+
355
+ # The frozen negative method keys of `name`'s {SINGLETON_EXTEND_HOOKS} and {INSTANCE_EXTEND_HOOKS}.
356
+ def self.extend_hook_keys(name)
357
+ @extend_hook_keys[name] ||= (
358
+ SINGLETON_EXTEND_HOOKS.map { |hook| "method:#{name}.#{hook}".freeze } +
359
+ INSTANCE_EXTEND_HOOKS.map { |hook| "method:#{name}##{hook}".freeze }
360
+ ).freeze
361
+ end
362
+
363
+ # Whether extending `name` may mix modules into the extender's instance side, or into its includers' (#1730):
364
+ # `name` records a mixin it cannot name on its own instance side (`"*"`, which a hook's `base.include X` or
365
+ # `base.class_eval { … }` lists on the hook's module) and defines one of the hooks that would run it there.
366
+ def self.extend_reshapes_instance?(scope, name)
367
+ listed = scope.discovery.unpositioned_mixins[name]&.dig(:include)
368
+ return false unless listed&.include?(Relevance::WILDCARD)
369
+
370
+ defines_any?(scope.discovered_singleton_def_nodes[name], SINGLETON_EXTEND_HOOKS) ||
371
+ defines_any?(scope.discovered_def_nodes[name], INSTANCE_EXTEND_HOOKS)
372
+ end
373
+
374
+ # Read off the def-node tables: the existence table does not carry a module's own instance hook once the
375
+ # extend fold has copied it onto the extender's singleton.
376
+ def self.defines_any?(defs, hooks)
377
+ !defs.nil? && hooks.any? { |hook| defs.key?(hook) }
378
+ end
379
+
380
+ # `[[spelling, resolved name or nil, candidate names], …]`: every module the project entries of an instance chain
381
+ # `extend`, as {Builder#extend_hook_taint?} resolved them.
382
+ def self.extend_targets(scope, flavor, entries)
383
+ resolver = resolver_for(scope, flavor)
384
+ extends = scope.discovery.discovered_extends
385
+ entries.each_with_object([]) do |entry, out|
386
+ next if entry.external? || entry.side != :instance
387
+
388
+ (extends[entry.name] || EMPTY_NAMES).each do |raw|
389
+ resolved = resolver.resolve_one(entry.name, raw)
390
+ out << [raw, resolved, resolved ? EMPTY_NAMES : resolver.candidates(entry.name, raw)].freeze
391
+ end
392
+ end.uniq.freeze
393
+ end
394
+
395
+ @hook_keys = {}
396
+
397
+ # The frozen `method:Name.hook` negative keys of `name`'s {Relevance::HOOKS}, built once per name.
398
+ def self.hook_keys(name)
399
+ @hook_keys[name] ||= Relevance::HOOKS.map { |hook| "method:#{name}.#{hook}".freeze }.freeze
400
+ end
401
+
402
+ def record_head(scope, start, side)
403
+ ResolutionChain.record_class(scope, @root) unless start.zero? && @entries.first&.name == @root
404
+ start.times { |index| ResolutionChain.record_entry(scope, @entries[index], side) }
405
+ end
406
+ private :record_head
407
+
408
+ # `Scope#record_class_dependency`'s edge, filed from outside `Scope`: every file that declares `name`.
409
+ def self.record_class(scope, name)
410
+ sites = scope.discovery.discovered_class_sources[name.to_s]
411
+ sites&.each { |site| Analysis::DependencyRecorder.read_site(site) }
412
+ end
413
+
414
+ def self.record_entry(scope, entry, side)
415
+ return if side && entry.side != side
416
+
417
+ if entry.external?
418
+ # An ancestor the project does not treat as a class may still be DECLARED (an empty module in another
419
+ # file), and a later edit there turns it into a project entry that moves the answer: file the sites
420
+ # of every name its spelling can denote.
421
+ entry.candidates.each { |candidate| record_class(scope, candidate) }
422
+ else
423
+ record_class(scope, entry.name)
424
+ end
425
+ end
426
+
427
+ # The position of `name`'s own entry on this chain's side, or nil. A module can appear twice (a prepend
428
+ # of a module the superclass includes); a class or the root cannot.
429
+ def index_of(name, side = @side)
430
+ @entries.index { |entry| entry.name == name && entry.side == side }
431
+ end
432
+
433
+ # A LEVEL is one superclass step: the class (or its singleton) with the modules inserted around it,
434
+ # before the next superclass's entries begin. `level_classes[k]` is that class, or nil for a level that
435
+ # is an external superclass. Only COMPLETE levels are counted — a level the {LIMIT} cut in half is not.
436
+ def level_count = @level_starts.size
437
+
438
+ def level_entries(index)
439
+ finish = @level_starts[index + 1] || @levels_end
440
+ @entries[@level_starts[index]...finish]
441
+ end
442
+
443
+ # Each complete level up to the first superclass the project does not declare, as `[class_name,
444
+ # modules, externals]`: the level's class, the project modules around it (each once), and the candidate
445
+ # lists of the ancestors in it the project does not declare — the shape `call.wrong-arity` reads.
446
+ def levels = (@memo[:levels] ||= compute_levels)
447
+
448
+ def compute_levels
449
+ out = []
450
+ level_count.times do |index|
451
+ class_name = @level_classes[index]
452
+ break if class_name.nil?
453
+
454
+ externals, entries = level_entries(index).partition(&:external?)
455
+ modules = entries.filter_map { |entry| entry.name unless entry.name == class_name && entry.side == @side }
456
+ out << [class_name, modules.uniq.freeze, externals.map(&:candidates).freeze].freeze
457
+ end
458
+ out.freeze
459
+ end
460
+ private :compute_levels
461
+
462
+ MEMO_KEY = :__rigor_resolution_chain__
463
+ private_constant :MEMO_KEY
464
+
465
+ FLAVORS = %i[methods constants arity].freeze
466
+
467
+ # The chain for `class_name` on `side` under `flavor`. Memoised in ONE thread-local slot keyed on the
468
+ # discovery index's identity — the same shape as `ExpressionTyper#class_graph_buckets`: the chain is a
469
+ # pure function of that frozen index, and a store keyed on it would pin every file's index for the run.
470
+ def self.for(scope, class_name, side, flavor)
471
+ raise ArgumentError, "unknown resolution-chain flavor #{flavor.inspect}" unless FLAVORS.include?(flavor)
472
+
473
+ bucket = flavor_bucket(scope.discovery, flavor)
474
+ chains = bucket[side]
475
+ chains[class_name] || (chains[class_name] = Builder.new(scope, flavor, bucket).chain(class_name, side))
476
+ end
477
+
478
+ def self.flavor_bucket(discovery, flavor)
479
+ slot = Thread.current[MEMO_KEY]
480
+ unless slot && slot[0].equal?(discovery)
481
+ slot = [discovery, {}]
482
+ Thread.current[MEMO_KEY] = slot
483
+ end
484
+ slot[1][flavor] ||= { instance: {}, singleton: {}, names: {}, interned: {}, externals: {}, master: {},
485
+ marks: {} }
486
+ end
487
+
488
+ # Which project class an as-written ancestor name denotes, under one flavor — shared by the chain and
489
+ # by {MasterOrder}, and memoised per owner and name in the flavor's bucket.
490
+ class Resolver
491
+ def initialize(scope, flavor, bucket)
492
+ @scope = scope
493
+ @flavor = flavor
494
+ @discovery = scope.discovery
495
+ @names = bucket[:names]
496
+ end
497
+
498
+ # The project class `raw` denotes from `owner`'s declaration header, an Array of the classes an
499
+ # ambiguous spelling names (`:arity` only), or nil.
500
+ def resolve(owner, raw)
501
+ by_owner = (@names[owner] ||= {})
502
+ return by_owner[raw] if by_owner.key?(raw)
503
+
504
+ by_owner[raw] = compute(owner, raw)
505
+ end
506
+
507
+ # {#resolve}, with an ambiguous spelling read as unresolved.
508
+ def resolve_one(owner, raw)
509
+ resolved = raw.nil? ? nil : resolve(owner, raw)
510
+ resolved.is_a?(String) ? resolved : nil
511
+ end
512
+
513
+ def candidates(owner, raw) = @scope.ancestor_name_candidates(owner, raw)
514
+
515
+ private
516
+
517
+ def compute(owner, raw)
518
+ found = candidates(owner, raw).find { |candidate| project?(candidate) }
519
+ return found if found || @flavor != :arity
520
+
521
+ alternatives = @scope.ambiguous_ancestor_resolutions(owner, raw)
522
+ alternatives.empty? ? nil : alternatives
523
+ end
524
+
525
+ def project?(name)
526
+ case @flavor
527
+ when :constants
528
+ @discovery.discovered_superclasses.key?(name) || @discovery.discovered_includes.key?(name) ||
529
+ @discovery.discovered_classes.key?(name)
530
+ when :arity
531
+ @scope.known_user_class?(name) ||
532
+ DiscoveryIndex.declares_surface?(@discovery.discovered_parameter_envelopes[name])
533
+ else
534
+ @scope.known_user_class?(name)
535
+ end
536
+ end
537
+ end
538
+ private_constant :Resolver
539
+
540
+ private_class_method :flavor_bucket
541
+
542
+ # {Relevance}'s verdict memo, one table per flavor bucket.
543
+ def self.relevance_memo(scope, flavor)
544
+ flavor_bucket(scope.discovery, flavor)[:relevance] ||= {}
545
+ end
546
+
547
+ # The singleton-side hook verdicts' memo (`Inference::DefinerResolution`'s positional decline, ADR-119 WD3),
548
+ # one table per flavor bucket.
549
+ def self.hook_memo(scope, flavor)
550
+ flavor_bucket(scope.discovery, flavor)[:hooks] ||= {}
551
+ end
552
+
553
+ # The name resolver of `flavor`, over the same memo the chain builder shares: {Relevance} resolves a
554
+ # mark's listed entry from the node that listed it, exactly as the chain did.
555
+ def self.resolver_for(scope, flavor)
556
+ Resolver.new(scope, flavor, flavor_bucket(scope.discovery, flavor))
557
+ end
558
+
559
+ # The orders the walks this chain replaced searched in, kept for the one case the chain cannot settle
560
+ # alone: where its two worlds put different definers first, a reader answers what master answered
561
+ # (ADR-24 § "Amendment 2026-09-28"), so no reader turns a disagreement into a new answer or a new
562
+ # silence. Each order is the replaced walk's, over the same tables and the same name resolution, and is
563
+ # memoised per root in the flavor's bucket. They are read so rarely — no chain on Rigor's own `lib`
564
+ # needs one, and on Mastodon no read did — that their dependency edges are simply every node listed.
565
+ module MasterOrder
566
+ module_function
567
+
568
+ # `Scope#user_def_through_ancestors`'s breadth-first search, prepend wedge and all: at each class,
569
+ # its prepends' sub-chains, then the class, then its includes and superclass are queued.
570
+ def definer_sequence(scope, root)
571
+ ResolutionChain.master_memo(scope, :methods, [:definers, root]) do |resolver|
572
+ out = []
573
+ queue = [root]
574
+ seen = {}
575
+ until queue.empty? || seen.size > LIMIT
576
+ current = queue.shift
577
+ next if current.nil? || seen[current]
578
+
579
+ seen[current] = true
580
+ wedge(scope, resolver, current, {}).each do |name|
581
+ next if seen[name]
582
+
583
+ seen[name] = true
584
+ out << name
585
+ end
586
+ out << current
587
+ direct_edges(scope, resolver, current).each { |name| queue.push(name) }
588
+ end
589
+ out.freeze
590
+ end
591
+ end
592
+
593
+ # The override rules' and the constant ladder's breadth-first order: every project ancestor after
594
+ # `root`, nearer first, includes and prepends ahead of the superclass at each class.
595
+ def breadth_first(scope, root, flavor)
596
+ ResolutionChain.master_memo(scope, flavor, [:breadth_first, root]) do |resolver|
597
+ out = []
598
+ queue = direct_edges(scope, resolver, root)
599
+ seen = { root => true }
600
+ until queue.empty? || out.size > LIMIT
601
+ current = queue.shift
602
+ next if seen[current]
603
+
604
+ seen[current] = true
605
+ out << current
606
+ queue.concat(direct_edges(scope, resolver, current))
607
+ end
608
+ out.freeze
609
+ end
610
+ end
611
+
612
+ # `Scope#external_ancestor_name_candidates`'s depth-first walk: the candidate lists of the ancestors
613
+ # the project does not declare, a resolved ancestor's own edges before the next sibling's.
614
+ def external_groups(scope, root, mixins)
615
+ ResolutionChain.master_memo(scope, :methods, [:externals, root, mixins]) do |resolver|
616
+ groups = []
617
+ collect_externals(scope, resolver, root, mixins, groups, {})
618
+ groups.freeze
619
+ end
620
+ end
621
+
622
+ def direct_edges(scope, resolver, name)
623
+ discovery = scope.discovery
624
+ raws = (discovery.discovered_includes[name] || EMPTY_NAMES) + [discovery.discovered_superclasses[name]]
625
+ raws.filter_map { |raw| resolver.resolve_one(name, raw) }
626
+ end
627
+
628
+ def wedge(scope, resolver, name, seen)
629
+ (scope.discovery.discovered_prepends[name] || EMPTY_NAMES).each_with_object([]) do |raw, out|
630
+ resolved = resolver.resolve_one(name, raw)
631
+ next if resolved.nil? || seen[resolved]
632
+
633
+ seen[resolved] = true
634
+ out.concat(subchain(scope, resolver, resolved, seen))
635
+ end
636
+ end
637
+
638
+ def subchain(scope, resolver, name, seen)
639
+ out = wedge(scope, resolver, name, seen)
640
+ out << name
641
+ direct_edges(scope, resolver, name).each do |resolved|
642
+ next if seen[resolved]
643
+
644
+ seen[resolved] = true
645
+ out.concat(subchain(scope, resolver, resolved, seen))
646
+ end
647
+ out
648
+ end
649
+
650
+ def collect_externals(scope, resolver, current, mixins, groups, seen)
651
+ return if seen[current] || seen.size > LIMIT
652
+
653
+ seen[current] = true
654
+ discovery = scope.discovery
655
+ raws = mixins ? (discovery.discovered_includes[current] || EMPTY_NAMES).dup : []
656
+ raws << discovery.discovered_superclasses[current]
657
+ raws.compact.each do |raw|
658
+ resolved = resolver.resolve_one(current, raw)
659
+ next groups << resolver.candidates(current, raw) if resolved.nil?
660
+
661
+ collect_externals(scope, resolver, resolved, mixins, groups, seen)
662
+ end
663
+ end
664
+ end
665
+
666
+ EMPTY_NAMES = [].freeze
667
+ private_constant :EMPTY_NAMES
668
+
669
+ # A class's direct project ancestors, as the breadth-first walks queued them: its includes and prepends in
670
+ # `includes_of` order (unless `mixins` is false), then its superclass, each resolved as the method walks
671
+ # resolve a name. `Scope#enqueue_ancestors`, kept for the plugin surface, reads this.
672
+ def self.direct_ancestors(scope, name, mixins)
673
+ resolver = Resolver.new(scope, :methods, flavor_bucket(scope.discovery, :methods))
674
+ return MasterOrder.direct_edges(scope, resolver, name) if mixins
675
+
676
+ resolved = resolver.resolve_one(name, scope.discovery.discovered_superclasses[name])
677
+ resolved ? [resolved] : EMPTY_NAMES
678
+ end
679
+
680
+ # {MasterOrder}'s memo: one value per key in the flavor's bucket.
681
+ def self.master_memo(scope, flavor, key)
682
+ bucket = flavor_bucket(scope.discovery, flavor)
683
+ memo = bucket[:master]
684
+ return memo[key] if memo.key?(key)
685
+
686
+ memo[key] = yield(Resolver.new(scope, flavor, bucket))
687
+ end
688
+
689
+ # The linearisation itself, in one world: Ruby's skip rule (`retro: false`), or every skipped insertion
690
+ # made anyway (`retro: true`). Each node's chain is memoised in the flavor's bucket per world (a module's
691
+ # chain is the same whichever class includes it) unless its computation met a cycle, whose answer depends
692
+ # on where the cycle was entered.
693
+ class Builder # rubocop:disable Metrics/ClassLength
694
+ EMPTY = [].freeze
695
+ RETRO_OVER_BUDGET = :rigor_retro_over_budget
696
+ EMPTY_LIN = [EMPTY, EMPTY, EMPTY, 0, NO_MARKS, true].freeze
697
+ private_constant :EMPTY, :EMPTY_LIN, :RETRO_OVER_BUDGET
698
+
699
+ # What one node's computation met, handed up to the computation that asked for it: how many forks it
700
+ # counted (see {ResolutionChain#settle}), the {Mark}s of the nodes on it whose mixin edges' order is not a
701
+ # fact, and whether it met a cycle or the depth budget (so it is not memoised).
702
+ Frame = Struct.new(:forks, :incomplete, :marks, :retro_ok) do
703
+ # ADR-119 WD2 — mark arrays are frozen and shared: the receiver keeps its array when the other is
704
+ # empty, adopts the other's when it is empty itself, and allocates a union only when both hold marks.
705
+ def absorb(forks, other, retro_ok)
706
+ self.forks += forks
707
+ add_marks(other)
708
+ self.retro_ok &&= retro_ok
709
+ end
710
+
711
+ def add_marks(other)
712
+ return if other.empty? || marks.equal?(other)
713
+
714
+ self.marks = marks.empty? ? other : (marks | other).freeze
715
+ end
716
+ end
717
+ private_constant :Frame
718
+
719
+ def initialize(scope, flavor, bucket, retro: false)
720
+ @scope = scope
721
+ @flavor = flavor
722
+ @bucket = bucket
723
+ @retro = retro
724
+ @discovery = scope.discovery
725
+ @resolver = Resolver.new(scope, flavor, bucket)
726
+ # Per memo table: a module's singleton chain reaches its OWN instance chain through `extend self`,
727
+ # which is not a cycle.
728
+ @stacks = {}
729
+ @frames = []
730
+ @deep = false
731
+ end
732
+
733
+ # The chain for `root`, carrying its retro world when the skip rule skipped anything on the way.
734
+ def chain(root, side)
735
+ entries, starts, classes, forks, marks, retro_ok = lin_for(root, side)
736
+ order_marked = marks.any? { |mark| mark.kind != :extend_hook }
737
+ retro = build_retro(root, side) if forks == 1 && retro_ok && !order_marked && !@retro
738
+ cut_at = cut_position(entries)
739
+ return whole_chain(root, side, entries, starts, classes, [forks, marks], retro) if cut_at.nil?
740
+
741
+ # Keep the prefix for first-definer reads, and only the levels that end inside it.
742
+ complete = starts.count { |start| start < cut_at }
743
+ complete -= 1 if (starts[complete] || entries.size) > cut_at
744
+ ResolutionChain.new(root: root, side: side, entries: entries.first(cut_at).freeze,
745
+ level_starts: starts.first(complete).freeze,
746
+ level_classes: classes.first(complete).freeze,
747
+ levels_end: complete.zero? ? 0 : (starts[complete] || cut_at), truncated: true,
748
+ forks: forks, marks: marks, flavor: @flavor, retro: retro)
749
+ end
750
+
751
+ private
752
+
753
+ def whole_chain(root, side, entries, starts, classes, (forks, marks), retro)
754
+ ResolutionChain.new(root: root, side: side, entries: entries, level_starts: starts,
755
+ level_classes: classes, levels_end: entries.size, truncated: @deep, forks: forks,
756
+ marks: marks, flavor: @flavor, retro: retro)
757
+ end
758
+
759
+ def lin_for(root, side) = side == :singleton ? singleton_lin(root, 0) : instance_lin(root, 0)
760
+
761
+ # The retro world, built only for a chain with exactly one skip (two or more settle to master without
762
+ # it) and abandoned — nil, which settles to master — once it holds more than {LIMIT} project entries.
763
+ # A retro world repeats every skipped module, so a deep diamond grows it geometrically where the
764
+ # chain itself stays small.
765
+ def build_retro(root, side)
766
+ catch(RETRO_OVER_BUDGET) { Builder.new(@scope, @flavor, @bucket, retro: true).chain(root, side) }
767
+ end
768
+
769
+ def guard_retro(entries)
770
+ return unless @retro && entries.size > LIMIT
771
+
772
+ throw RETRO_OVER_BUDGET if entries.count { |entry| !entry.external? } > LIMIT
773
+ end
774
+
775
+ # The index of the first project entry past {LIMIT}, or nil when the chain fits.
776
+ def cut_position(entries)
777
+ seen = 0
778
+ entries.each_with_index do |entry, index|
779
+ next if entry.external?
780
+
781
+ seen += 1
782
+ return index if seen > LIMIT
783
+ end
784
+ nil
785
+ end
786
+
787
+ def instance_lin(name, depth)
788
+ memoised(@retro ? :lin_instance_retro : :lin_instance, name, depth) { compute_instance(name, depth) }
789
+ end
790
+
791
+ def singleton_lin(name, depth)
792
+ memoised(@retro ? :lin_singleton_retro : :lin_singleton, name, depth) { compute_singleton(name, depth) }
793
+ end
794
+
795
+ def memoised(table, name, depth)
796
+ memo = (@bucket[table] ||= {})
797
+ cached = memo[name]
798
+ if cached
799
+ # A memoised chain's forks are the asker's too.
800
+ @frames.last&.absorb(cached[3], cached[4], cached[5])
801
+ return cached
802
+ end
803
+
804
+ # A cycle (`class Foo < Foo` resolved inside its own namespace) or a hierarchy deeper than the budget
805
+ # contributes nothing past that point, and the chains computed while it did are not memoised: they
806
+ # depend on where the walk entered.
807
+ stack = (@stacks[table] ||= [])
808
+ if stack.include?(name) || depth > LIMIT
809
+ @deep ||= depth > LIMIT
810
+ @frames.last&.incomplete = true
811
+ return EMPTY_LIN
812
+ end
813
+
814
+ frame = Frame.new(0, false, NO_MARKS, true)
815
+ @frames.push(frame)
816
+ stack.push(name)
817
+ entries, starts, classes = yield
818
+ stack.pop
819
+ @frames.pop
820
+ lin = [entries, starts, classes, frame.forks, frame.marks, frame.retro_ok].freeze
821
+ memo[name] = lin unless frame.incomplete
822
+ hand_up(frame)
823
+ lin
824
+ end
825
+
826
+ def hand_up(frame)
827
+ parent = @frames.last
828
+ return if parent.nil?
829
+
830
+ parent.absorb(frame.forks, frame.marks, frame.retro_ok)
831
+ parent.incomplete ||= frame.incomplete
832
+ end
833
+
834
+ def compute_instance(name, depth)
835
+ # `discovered_includes` already carries the prepended names, so it counts every instance-side edge once.
836
+ mark_unsettled(name, :include, (@discovery.discovered_includes[name] || EMPTY).size)
837
+ @frames.last.add_marks(mark_for(name, :extend_hook, WILDCARD_LIST, false)) if extend_hook_taint?(name)
838
+ entries = [project_entry(name, :instance)]
839
+ super_lin = superclass_lin(name, :instance, depth)
840
+ entries.concat(super_lin[0])
841
+ prepends = @discovery.discovered_prepends[name] || EMPTY
842
+ boundary = prepend_all(entries, name, prepends, depth)
843
+ includes = @discovery.discovered_includes[name] || EMPTY
844
+ super_start = include_all(entries, name, includes, prepends, depth, boundary)
845
+ finish(entries, name, super_lin, super_start)
846
+ end
847
+
848
+ def compute_singleton(name, depth)
849
+ mark_unsettled(name, :extend, (@discovery.discovered_extends[name] || EMPTY).size)
850
+ entries = [project_entry(name, :singleton)]
851
+ super_lin = superclass_lin(name, :singleton, depth)
852
+ entries.concat(super_lin[0])
853
+ extends = @discovery.discovered_extends[name] || EMPTY
854
+ super_start = include_all(entries, name, extends, EMPTY, depth, [0, 1])
855
+ finish(entries, name, super_lin, super_start)
856
+ end
857
+
858
+ # Marks the node being computed unsettled when the order of its mixin edges on `kind` is not a fact
859
+ # (`DiscoveryIndex#unpositioned_mixins`: an edge written in a conditional, a method, a block or a hook,
860
+ # or a call the walk cannot record, `"*"`), or when its class is declared in more than one file and
861
+ # has two or more such edges, whose order across the files is load order. A node's mark taints every
862
+ # chain that draws on it, so a concern's `included do` edges taint every includer. The {Mark} says why,
863
+ # which is what per-name relevance reads.
864
+ def mark_unsettled(name, kind, edge_count)
865
+ listed = @discovery.unpositioned_mixins[name]&.dig(kind)
866
+ listed = nil if listed && listed.empty?
867
+ multi = edge_count >= 2 && multi_file?(name)
868
+ return if listed.nil? && !multi
869
+
870
+ @frames.last.add_marks(mark_for(name, kind, listed, multi))
871
+ end
872
+
873
+ # The one-element frozen array carrying the {Mark} of `[name, kind]`, interned in the bucket.
874
+ def mark_for(name, kind, listed, multi)
875
+ by_node = (@bucket[:marks][kind] ||= {})
876
+ by_node[name] ||= [ResolutionChain::Mark.new(name, kind, listed.nil? ? EMPTY : listed.to_a.freeze,
877
+ multi)].freeze
878
+ end
879
+
880
+ WILDCARD_LIST = ["*"].freeze # Relevance::WILDCARD, which loads after this file
881
+ private_constant :WILDCARD_LIST
882
+
883
+ # #1730 — whether a module `name` extends has a hook that may mix into `name`'s instance side or its
884
+ # includers' ({ResolutionChain.extend_reshapes_instance?}). Ruby runs it at the `extend`, so the mixin is
885
+ # not a fact of the tables and `name`'s instance side is unpositioned. A spelling no project module
886
+ # answers taints nothing: the project cannot see its hooks.
887
+ def extend_hook_taint?(name)
888
+ (@discovery.discovered_extends[name] || EMPTY).any? do |raw|
889
+ resolved = @resolver.resolve_one(name, raw)
890
+ !resolved.nil? && ResolutionChain.extend_reshapes_instance?(@scope, resolved)
891
+ end
892
+ end
893
+
894
+ def multi_file?(name)
895
+ sites = @discovery.discovered_class_sources[name]
896
+ !sites.nil? && sites.size >= 2
897
+ end
898
+
899
+ def finish(entries, name, super_lin, super_start)
900
+ starts = [0]
901
+ super_lin[1].each { |start| starts << (start + super_start) }
902
+ classes = [name].concat(super_lin[2])
903
+ [entries.freeze, starts.freeze, classes.freeze]
904
+ end
905
+
906
+ # The superclass's own chain on `side`, or a one-entry chain holding it as an external.
907
+ def superclass_lin(name, side, depth)
908
+ raw = @discovery.discovered_superclasses[name]
909
+ return EMPTY_LIN if raw.nil?
910
+
911
+ resolved = resolve(name, raw)
912
+ if resolved.is_a?(String)
913
+ side == :singleton ? singleton_lin(resolved, depth + 1) : instance_lin(resolved, depth + 1)
914
+ else
915
+ # A class the project declares that the `:methods` predicate does not admit (one that only `extend`s)
916
+ # is external here, so its extended modules vanish from the singleton chain and a fork among them
917
+ # goes uncounted. That is a hole in what the chain can see, not an order fork, so it settles to
918
+ # master with the weight of two.
919
+ @frames.last.forks += 2 if side == :singleton && declared_class?(name, raw)
920
+ [[external_entry(name, raw, side, true)].freeze, [0].freeze, [nil].freeze, 0, NO_MARKS, true].freeze
921
+ end
922
+ end
923
+
924
+ def declared_class?(owner, raw)
925
+ @scope.ancestor_name_candidates(owner, raw).any? do |candidate|
926
+ @discovery.discovered_extends.key?(candidate)
927
+ end
928
+ end
929
+
930
+ # Inserts every prepend, in statement order (the table stores search order, nearest first), ahead of
931
+ # the class, and returns `[origin, super_start]` — where the class itself now sits and where its
932
+ # superclass's entries begin. Ruby skips a module already in the prepend region (a fork: the module is
933
+ # present through a second route); the retro world inserts it again. A sub-chain entry other than the
934
+ # prepended module that the class or its superclass already carries is a fork too (the prepended
935
+ # module's own includes may have run before or after), and the tables record `include M; prepend M`
936
+ # exactly as a plain `prepend M`. A direct prepend of the module itself is always inserted, in Ruby and
937
+ # here, so it forks nothing.
938
+ def prepend_all(entries, owner, prepends, depth)
939
+ origin = 0
940
+ super_start = 1
941
+ # The table holds every statement, nearest first: a same-owner repeat is a no-op in Ruby (the first
942
+ # statement wins), so it is dropped here rather than met as a fork.
943
+ prepends.reverse.uniq.each do |raw|
944
+ modules = Array(resolve(owner, raw))
945
+ each_mixin_chain(owner, raw, depth) do |sub|
946
+ point = -1
947
+ sub.each do |entry|
948
+ found = entries.index(entry)
949
+ if found && found < origin && !@retro
950
+ fork_without_retro
951
+ point = found if found > point
952
+ else
953
+ fork_without_retro if found && !@retro && !modules.include?(entry.name)
954
+ entries.insert(point + 1, entry)
955
+ point += 1
956
+ origin += 1
957
+ super_start += 1
958
+ end
959
+ end
960
+ end
961
+ guard_retro(entries)
962
+ end
963
+ [origin, super_start]
964
+ end
965
+
966
+ # Inserts every name in `mixins` that `skip` does not hold, in statement order, after the class, and
967
+ # returns where the superclass's entries now begin. Ruby skips a module already anywhere in the chain;
968
+ # that is a fork (the module is present through a second route), and the retro world inserts it
969
+ # again. Exactly one fork, at or after the class, on the last entry of the sub-chain being inserted, is
970
+ # the one shape whose Ruby worlds are exactly two: the skip made or the insertion made.
971
+ def include_all(entries, owner, mixins, skip, depth, boundary)
972
+ origin, super_start = boundary
973
+ mixins.reverse_each do |raw|
974
+ next if skip.include?(raw)
975
+
976
+ each_mixin_chain(owner, raw, depth) do |sub|
977
+ point = origin
978
+ sub.each_with_index do |entry, index|
979
+ found = @retro ? nil : entries.index(entry)
980
+ if found
981
+ @frames.last.forks += 1
982
+ @frames.last.retro_ok = false unless found >= origin && index == sub.size - 1
983
+ point = found if found > point && found < super_start
984
+ else
985
+ entries.insert(point + 1, entry)
986
+ point += 1
987
+ super_start += 1
988
+ end
989
+ end
990
+ end
991
+ guard_retro(entries)
992
+ end
993
+ super_start
994
+ end
995
+
996
+ # A fork whose second Ruby world is not "the insertion made anyway": the chain settles to master.
997
+ def fork_without_retro
998
+ @frames.last.forks += 1
999
+ @frames.last.retro_ok = false
1000
+ end
1001
+
1002
+ # Yields the instance chain of the module `raw` names from `owner` — once per class an ambiguous
1003
+ # spelling can name under the `:arity` flavor — or a one-entry chain holding it as an external.
1004
+ def each_mixin_chain(owner, raw, depth)
1005
+ resolved = resolve(owner, raw)
1006
+ case resolved
1007
+ when String then yield instance_lin(resolved, depth + 1)[0]
1008
+ when Array then resolved.each { |name| yield instance_lin(name, depth + 1)[0] }
1009
+ else yield [external_entry(owner, raw, :instance, false)]
1010
+ end
1011
+ end
1012
+
1013
+ def resolve(owner, raw) = @resolver.resolve(owner, raw)
1014
+
1015
+ def project_entry(name, side)
1016
+ by_side = (@bucket[:interned][side] ||= {})
1017
+ by_side[name] ||= Entry.new(name, side, nil, nil, false, name.to_s.split("::").last.freeze, nil)
1018
+ end
1019
+
1020
+ def external_entry(owner, raw, side, superclass_edge)
1021
+ by_edge = ((@bucket[:externals][side] ||= {})[superclass_edge] ||= {})
1022
+ by_owner = (by_edge[owner] ||= {})
1023
+ by_owner[raw] ||= Entry.new(nil, side, raw, @scope.ancestor_name_candidates(owner, raw).freeze,
1024
+ superclass_edge, nil, owner)
1025
+ end
1026
+ end
1027
+ private_constant :Builder
1028
+ end
1029
+ end
1030
+ end
1031
+
1032
+ require_relative "resolution_chain/relevance"