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
@@ -4,7 +4,10 @@ require "prism"
4
4
 
5
5
  require_relative "../scope"
6
6
  require_relative "../type"
7
+ require_relative "../source/alias_names"
8
+ require_relative "../source/literals"
7
9
  require_relative "../source/constant_path"
10
+ require_relative "object_mixins"
8
11
  require_relative "../source/node_children"
9
12
  require_relative "../source/node_walker"
10
13
  require_relative "../source/parameter_envelope"
@@ -19,7 +22,11 @@ require_relative "last_line"
19
22
  require_relative "last_status"
20
23
  require_relative "error_info"
21
24
  require_relative "hash_lookup_mutation"
25
+ require_relative "in_effect_refinements"
26
+ require_relative "refine_census"
27
+ require_relative "refine_self"
22
28
  require_relative "index_write_widening"
29
+ require_relative "module_function_state"
23
30
  require_relative "multi_target_binder"
24
31
  require_relative "mutation_widening"
25
32
  require_relative "narrowing"
@@ -27,6 +34,14 @@ require_relative "operand_effects"
27
34
  require_relative "statement_evaluator"
28
35
  require_relative "struct_fold_safety"
29
36
  require_relative "unknown_store_widening"
37
+ require_relative "declaration_walk/traversal"
38
+ require_relative "scope_indexer/class_cvars_collector"
39
+ require_relative "scope_indexer/superclasses_collector"
40
+ require_relative "scope_indexer/def_nestings_collector"
41
+ require_relative "scope_indexer/layered_def_nestings"
42
+ require_relative "scope_indexer/mixin_accumulator"
43
+ require_relative "scope_indexer/certainty"
44
+ require_relative "scope_indexer/member_layouts_collector"
30
45
 
31
46
  module Rigor
32
47
  module Inference
@@ -98,8 +113,8 @@ module Rigor
98
113
  # propagates it across every derived scope.
99
114
  # Issue #722 residue 2 — the project's settled declaration names are the oracle the file's own
100
115
  # compact headers are re-anchored against. `discovered_class_sources` would be the more direct
101
- # reading, but it is seeded only under `--record-dependencies`; this table is the one every run
102
- # carries, and the fold has already applied its own re-anchoring to it.
116
+ # reading, but it names files rather than settled declarations, and the fold has already applied its
117
+ # own re-anchoring to this table.
103
118
  declared_types, discovered_classes =
104
119
  build_declaration_artifacts(root, default_scope.discovered_classes.keys.to_set)
105
120
  # Merge the indexer's findings on top of whatever the base scope already carries so callers that seed cross-file
@@ -152,7 +167,9 @@ module Rigor
152
167
  # discovered-methods existence table and the instance def-node table — see {#build_methods_and_def_nodes}.
153
168
  # `seed_discovered_methods` seeds the former onto the scope and returns the def-node table for
154
169
  # `merge_project_method_indexes` below.
155
- seeded_scope, file_def_nodes, file_envelopes = seed_discovered_methods(seeded_scope, default_scope, root)
170
+ # ADR-119 WD3 — the file's possible contributions, classified once for both def-contribution walks.
171
+ certainty = Certainty.possible_nodes(root)
172
+ seeded_scope, file_tables = seed_discovered_methods(seeded_scope, default_scope, root, certainty)
156
173
 
157
174
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method def nodes, the class -> superclass map, and the
158
175
  # class/module -> included-modules map, each merged under the cross-file pre-pass seed (see below). v0.1.2 —
@@ -160,7 +177,7 @@ module Rigor
160
177
  # `def.method-visibility-mismatch` and ADR-35 `def.override-visibility-reduced` CheckRules consult the table.
161
178
  # Seeded inside `merge_project_method_indexes` so the per-file visibilities merge OVER the cross-file project
162
179
  # seed rather than overwriting it.
163
- seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root, file_def_nodes, file_envelopes)
180
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root, file_tables, certainty)
164
181
 
165
182
  table = {}.compare_by_identity
166
183
  table.default = seeded_scope
@@ -187,11 +204,22 @@ module Rigor
187
204
  # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns `[scope,
188
205
  # file_def_nodes, file_envelopes]` so the caller can thread the def-node and issue #992 envelope tables into
189
206
  # {#merge_project_method_indexes} without walking the file a second time.
190
- def seed_discovered_methods(seeded_scope, default_scope, root)
191
- file_methods, file_def_nodes, file_envelopes, file_refinements =
192
- build_methods_and_def_nodes(root, default_scope.source_path)
193
- discovered_methods = deep_merge_class_methods(default_scope.discovered_methods, file_methods)
194
- discovery = seeded_scope.discovery.with(discovered_methods: discovered_methods)
207
+ #
208
+ # ADR-119 WD3 — the file's `possible_discovered_methods` folds over the seed's by {#fold_possible_methods}, so a
209
+ # name the file defines certainly is no longer possible and a name only the file's possible contribution
210
+ # supplies becomes possible. The file's other siblings are returned for {#merge_project_method_indexes}.
211
+ def seed_discovered_methods(seeded_scope, default_scope, root, certainty = Certainty.possible_nodes(root))
212
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
213
+ build_methods_and_def_nodes(root, default_scope.source_path, certainty: certainty)
214
+ seed_methods = default_scope.discovered_methods
215
+ discovered_methods = deep_merge_class_methods(seed_methods, file_methods)
216
+ discovery = seeded_scope.discovery.with(
217
+ discovered_methods: discovered_methods,
218
+ possible_discovered_methods: fold_possible_methods(
219
+ seeded_scope.discovery.possible_discovered_methods, seed_methods,
220
+ file_siblings[:possible_discovered_methods], file_methods
221
+ )
222
+ )
195
223
  # Issue #1120 — only a file that refines something pays the overlay; the cross-file seed already carries
196
224
  # every other file's refinements.
197
225
  unless file_refinements.empty?
@@ -199,7 +227,7 @@ module Rigor
199
227
  discovered_refinements: merge_refinement_tables(default_scope.discovered_refinements, file_refinements)
200
228
  )
201
229
  end
202
- [seeded_scope.with_discovery(discovery), file_def_nodes, file_envelopes]
230
+ [seeded_scope.with_discovery(discovery), [file_def_nodes, file_envelopes, file_siblings]]
203
231
  end
204
232
 
205
233
  # ADR-48 Struct slice 3 — installs the top-level fold-safe-local set ({Inference::StructFoldSafety}). Struct
@@ -220,19 +248,22 @@ module Rigor
220
248
  # Issue #992 — the envelope table is JOINED with the seed rather than overlaid: the seed already carries this
221
249
  # file's own contribution (identical, so the join keeps it), and a reopening in a sibling file must still
222
250
  # make a disagreeing name opaque here, which "same-file declarations win" would silently undo.
223
- def merge_project_method_indexes(seeded_scope, default_scope, root, file_def_nodes, file_envelopes)
224
- def_nodes, def_nestings = merge_def_node_tables(default_scope, root, file_def_nodes)
225
- singleton_def_nodes = merge_singleton_def_nodes(default_scope, root)
226
- superclasses, header_nestings = merge_ancestry_tables(default_scope, root)
227
- includes, prepends = merge_mixin_tables(default_scope, root)
251
+ #
252
+ # `file_tables` is `[file_def_nodes, file_envelopes, file_siblings]` from {#seed_discovered_methods}.
253
+ def merge_project_method_indexes(seeded_scope, default_scope, root, file_tables,
254
+ certainty = Certainty.possible_nodes(root))
255
+ # ADR-116 WD5 — the superclass, def-nesting and member-layout tables come from ONE shared walk.
256
+ walked = declaration_walk_tables(root, default_scope.source_path)
257
+ def_nodes, def_nestings = merge_def_node_tables(default_scope, walked, file_tables[0])
258
+ singleton_def_nodes, file_singleton = merge_singleton_def_nodes(default_scope, root, certainty)
259
+ superclasses, header_nestings = merge_ancestry_tables(default_scope, walked)
260
+ includes, prepends, *mixin_unpositioned = merge_mixin_tables(default_scope, root)
228
261
  # ADR-35 — per-file visibilities merged OVER the cross-file seed (the current file is authoritative for its own
229
262
  # classes; sibling-file ancestors are preserved from the project seed).
230
- method_visibilities = default_scope.discovered_method_visibilities.merge(
231
- build_discovered_method_visibilities(root)
232
- ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
263
+ method_visibilities, file_visibilities = merge_method_visibilities(default_scope, root, certainty)
233
264
  # ADR-48 — per-file Data + Struct member layouts merged OVER the cross-file seed (same-file declaration is
234
265
  # authoritative).
235
- data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, root)
266
+ data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, walked)
236
267
 
237
268
  # #526 — this file's extends folded against the MERGED instance def-nodes (so `extend M` sees a
238
269
  # sibling-file M through the cross-file seed). The project-wide fold in {#finalize_def_index}
@@ -240,8 +271,11 @@ module Rigor
240
271
  #
241
272
  # Issue #898 — and the same walk's table is now kept, merged over the cross-file seed the way
242
273
  # `includes` is: `Narrowing` asks it what a class object's singleton ancestry holds.
243
- extends, methods_table = merge_and_fold_extends(default_scope, root, def_nodes,
244
- singleton_def_nodes, seeded_scope)
274
+ siblings = per_file_siblings(seeded_scope.discovery.sibling_tables, file_tables[0], file_tables[2],
275
+ file_singleton, file_visibilities)
276
+ extends, methods_table, unpositioned =
277
+ merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
278
+ mixin_unpositioned)
245
279
 
246
280
  seeded_scope.with_discovery(
247
281
  seeded_scope.discovery.with(
@@ -252,23 +286,60 @@ module Rigor
252
286
  discovered_superclasses: superclasses,
253
287
  discovered_header_nestings: header_nestings,
254
288
  discovered_includes: includes,
255
- discovered_prepends: prepends,
256
- discovered_extends: extends,
289
+ discovered_prepends: prepends, discovered_extends: extends,
290
+ unpositioned_mixins: unpositioned,
257
291
  discovered_method_visibilities: method_visibilities,
258
- discovered_parameter_envelopes: merge_envelope_seed(default_scope, file_envelopes),
259
- data_member_layouts: data_member_layouts,
260
- struct_member_layouts: struct_member_layouts,
261
- discovered_deferred_ranges: merge_deferred_ranges_seed(default_scope, root)
292
+ discovered_parameter_envelopes: merge_envelope_seed(default_scope, file_tables[1]),
293
+ data_member_layouts: data_member_layouts, struct_member_layouts: struct_member_layouts,
294
+ discovered_deferred_ranges: merge_deferred_ranges_seed(default_scope, root),
295
+ # ADR-119 WD1/WD3 — the seed's siblings with this file's folded in ({#per_file_siblings}: the file wins
296
+ # every slot it writes), which the extends fold above then followed (copy on write, so the seed's
297
+ # tables are never written).
298
+ **siblings
262
299
  )
263
300
  )
264
301
  end
265
302
 
303
+ # Returns `[merged table, [file table, file contested slots]]` ({#build_method_visibility_tables}).
304
+ def merge_method_visibilities(default_scope, root, certainty)
305
+ file_visibilities = build_method_visibility_tables(root, certainty: certainty)
306
+ merged = default_scope.discovered_method_visibilities.merge(
307
+ file_visibilities.first
308
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
309
+ [merged, file_visibilities]
310
+ end
311
+
266
312
  # Per-file singleton def nodes merged OVER the cross-file seed (same-file declaration is
267
313
  # authoritative for its own classes, sibling-file defs are preserved).
268
- def merge_singleton_def_nodes(default_scope, root)
269
- default_scope.discovered_singleton_def_nodes.merge(
270
- build_discovered_singleton_def_nodes(root)
314
+ #
315
+ # Returns `[merged table, [file table, file contested slots]]` ({#build_singleton_def_tables}).
316
+ def merge_singleton_def_nodes(default_scope, root, certainty)
317
+ file_singleton = build_singleton_def_tables(root, certainty: certainty)
318
+ merged = default_scope.discovered_singleton_def_nodes.merge(
319
+ file_singleton.first
271
320
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
321
+ [merged, file_singleton]
322
+ end
323
+
324
+ # ADR-119 WD3 — the per-file merge of the slot siblings, under the members' own merges: this file's
325
+ # `def_nodes`, singleton defs and visibilities win every slot they write, so the seed's contest on those keys
326
+ # is replaced by the file's ({#fold_slot_contests}); the envelopes are joined, so their contests union. The
327
+ # possible methods were folded already ({#seed_discovered_methods}).
328
+ def per_file_siblings(siblings, file_def_nodes, file_siblings, file_singleton, file_visibilities)
329
+ file_singleton_def_nodes, file_singleton_contested = file_singleton
330
+ siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
331
+ siblings[:contested_discovered_method_visibilities], *file_visibilities
332
+ )
333
+ siblings[:contested_discovered_def_nodes] = fold_slot_contests(
334
+ siblings[:contested_discovered_def_nodes], file_def_nodes, file_siblings[:contested_discovered_def_nodes]
335
+ )
336
+ siblings[:contested_discovered_singleton_def_nodes] = fold_slot_contests(
337
+ siblings[:contested_discovered_singleton_def_nodes], file_singleton_def_nodes, file_singleton_contested
338
+ )
339
+ siblings[:contested_discovered_parameter_envelopes] = union_paths(
340
+ siblings[:contested_discovered_parameter_envelopes], file_siblings[:contested_discovered_parameter_envelopes]
341
+ )
342
+ siblings
272
343
  end
273
344
 
274
345
  # Issue #1123 — the two instance-side mixin tables, from ONE descent of this file. Each merges over
@@ -284,28 +355,60 @@ module Rigor
284
355
  end,
285
356
  default_scope.discovered_prepends.merge(file[:prepends]) do |_class, cross_file, per_file|
286
357
  (per_file + cross_file).uniq
287
- end
358
+ end,
359
+ file[:unpositioned]
288
360
  ]
289
361
  end
290
362
 
363
+ # The union of `{class => {side => [names]}}` tables — how {Scope::DiscoveryIndex#unpositioned_mixins}
364
+ # folds: an edge any contribution cannot position stays unpositioned. The per-file index folds its own
365
+ # walk over a seed that already carries this file's edges, so the first table itself comes back unless a
366
+ # contribution holds a name the seed lacks: the whole project table is copied only then.
367
+ def union_unpositioned(first, *others)
368
+ result = nil
369
+ others.each do |table|
370
+ table.each do |owner, sides|
371
+ sides.each do |side, names|
372
+ have = (result || first).dig(owner, side)
373
+ next if have && names.all? { |name| have.include?(name) }
374
+
375
+ result ||= first.dup
376
+ result[owner] = (result[owner] || {}).merge(side => ((have || []) | names).freeze).freeze
377
+ end
378
+ end
379
+ end
380
+ result ? result.freeze : first
381
+ end
382
+
291
383
  # The `extend`-edge half of {#merge_project_method_indexes}: merges this file's `extend`s over the
292
384
  # cross-file seed AND folds them against the merged def tables — the #526 fold that turns an
293
385
  # extended module's instance defs into singleton-side method entries on the extending class.
294
- def merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope)
295
- file_extends, extends = merge_extend_tables(default_scope, root)
296
- methods_table = fold_per_file_extends(file_extends, def_nodes, singleton_def_nodes, seeded_scope)
297
- [extends, methods_table]
386
+ #
387
+ # ADR-119 WD1 — the unpositioned union (the seed's, the mixin walks' and this file's extend side) is settled
388
+ # first, because the fold reads it to tell a certain extend edge from a listed one. `siblings` is written by
389
+ # the fold.
390
+ def merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
391
+ mixin_unpositioned)
392
+ file_extends, extends, extend_unpositioned = merge_extend_tables(default_scope, root)
393
+ unpositioned = union_unpositioned(default_scope.discovery.unpositioned_mixins, *mixin_unpositioned,
394
+ extend_unpositioned)
395
+ methods_table = fold_per_file_extends(file_extends, def_nodes, singleton_def_nodes, seeded_scope, siblings,
396
+ unpositioned)
397
+ [extends, methods_table, unpositioned]
298
398
  end
299
399
 
300
400
  # Issue #1097 — this file's def / block / lambda ranges merged over the cross-file seed; the
301
401
  # ordering predicates key the table by `source_path`, so the entry must exist even on a run
302
- # that never built the project pre-pass. When the pre-pass DID run the entry is already the
303
- # identical walk product — keep it instead of re-walking the AST.
402
+ # that never built the project pre-pass. A seeded entry is only the identical walk product when
403
+ # the pre-pass read the same bytes (a mutant analysed under a seed of the unmutated files does
404
+ # not), so the file is always re-walked and the seed is kept only when the rows are equal — which
405
+ # spares the project-wide Hash copy per file. Issue #1548.
304
406
  def merge_deferred_ranges_seed(default_scope, root)
305
407
  seeded = default_scope.discovered_deferred_ranges
306
- return seeded if seeded.key?(default_scope.source_path)
408
+ fresh = build_deferred_ranges(root)
409
+ return seeded if seeded[default_scope.source_path] == fresh
307
410
 
308
- seeded.merge(default_scope.source_path => build_deferred_ranges(root))
411
+ seeded.merge(default_scope.source_path => fresh)
309
412
  end
310
413
 
311
414
  def merge_envelope_seed(default_scope, file_envelopes)
@@ -315,70 +418,69 @@ module Rigor
315
418
  # The as-written superclass table and its issue #682 header-nesting twin, each merged over the cross-file
316
419
  # seed. Returned as a pair for the same reason {#merge_def_node_tables} is: both come from ONE walk of the
317
420
  # file, so a caller cannot pair a superclass name with a nesting recorded by a different parse.
318
- def merge_ancestry_tables(default_scope, root)
319
- file_superclasses, file_header_nestings = build_superclass_tables(root, default_scope.source_path)
320
- [default_scope.discovered_superclasses.merge(file_superclasses),
421
+ def merge_ancestry_tables(default_scope, walked)
422
+ [default_scope.discovered_superclasses.merge(walked.fetch(:superclasses)),
321
423
  merge_header_nestings(default_scope.discovery.discovered_header_nestings.dup,
322
- file_header_nestings).freeze]
424
+ walked.fetch(:header_nestings)).freeze]
323
425
  end
324
426
 
325
427
  # The instance-side def-node table and its issue #681 nesting twin, each merged over the cross-file seed.
326
428
  # Returned as a pair so the two stay written together: a node the merge keeps must be the same object the
327
429
  # nesting table keys, and they are only that if both halves take the same file's walk.
328
- def merge_def_node_tables(default_scope, root, file_def_nodes)
430
+ def merge_def_node_tables(default_scope, walked, file_def_nodes)
329
431
  def_nodes = default_scope.discovered_def_nodes.merge(
330
432
  file_def_nodes
331
433
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
332
434
  [def_nodes,
333
- merge_def_nestings(default_scope.discovery.discovered_def_nestings, build_def_nestings(root))]
435
+ merge_def_nestings(default_scope.discovery.discovered_def_nestings, walked.fetch(:def_nestings))]
334
436
  end
335
437
 
336
438
  # Issue #681 — the per-file nesting table over the cross-file seed. Both are keyed by node identity, so
337
- # a same-file declaration and its cross-file twin are distinct keys and the merge order is immaterial;
338
- # the copy exists only so the seed stays frozen. Skipped when the file declares no `def` at all — since
339
- # issue #716 a top-level `def` records its empty chain, so a file of plain top-level helpers no longer
340
- # takes that path and pays one copy of the seed, the same as any file that declares a method.
439
+ # a same-file declaration and its cross-file twin are distinct keys and the layering order is immaterial.
440
+ # Issue #1507 — layered by lookup ({LayeredDefNestings}) instead of copied: the copy was one insert per
441
+ # `def` in the project for every analysed file. Either table alone is returned as it is when the other is
442
+ # empty — since issue #716 a top-level `def` records its empty chain, so only a file declaring no `def` at
443
+ # all hands back the seed itself.
341
444
  def merge_def_nestings(seed, file_nestings)
342
445
  return seed if file_nestings.empty?
343
446
  return file_nestings if seed.empty?
344
447
 
345
- merged = {}.compare_by_identity
346
- merged.merge!(seed)
347
- merged.merge!(file_nestings)
348
- merged.freeze
448
+ LayeredDefNestings.new(file_nestings, seed)
349
449
  end
350
450
 
351
451
  # Issue #898 — one walk, two consumers: the raw per-file table the #526 method fold reads, and the
352
452
  # same table merged over the cross-file seed for the scope. Returned as a pair so a caller cannot
353
453
  # pair the fold with a table a different parse produced, as {#merge_ancestry_tables} is.
354
454
  def merge_extend_tables(default_scope, root)
355
- file_extends = build_discovered_extends(root)
455
+ file = extend_tables(root)
456
+ file_extends = file.fetch(:extends)
356
457
  # The table stores singleton-ancestor search order, so the file under analysis — the
357
458
  # later-loading contribution for a reopened class — prepends over the cross-file seed.
358
459
  merged = default_scope.discovered_extends.merge(
359
460
  file_extends
360
461
  ) { |_class, cross_file, per_file| (per_file + cross_file).uniq }
361
- [file_extends, merged]
462
+ [file_extends, merged, file.fetch(:unpositioned)]
362
463
  end
363
464
 
364
465
  # The per-file half of the #526 fold: mutable copies of the merged tables take the extends, and the
365
466
  # existence table (already seeded onto the scope) is rebuilt only when the fold touched it.
366
- def fold_per_file_extends(extends, def_nodes, singleton_def_nodes, seeded_scope)
467
+ def fold_per_file_extends(extends, def_nodes, singleton_def_nodes, seeded_scope, siblings, unpositioned)
367
468
  methods_table = seeded_scope.discovered_methods
368
469
  return methods_table if extends.empty?
369
470
 
370
471
  mutable_methods = methods_table.transform_values(&:dup)
371
- fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods)
472
+ fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods,
473
+ siblings: siblings, unpositioned: unpositioned)
372
474
  mutable_methods
373
475
  end
374
476
 
375
477
  # ADR-48 — the per-file Data + Struct member-layout tables, each merged OVER the cross-file seed so a same-file
376
478
  # declaration wins for its own classes. Returned as a pair to keep {#merge_project_method_indexes} under the
377
479
  # method-size budget.
378
- def merge_member_layouts(default_scope, root)
480
+ def merge_member_layouts(default_scope, walked)
379
481
  [
380
- default_scope.data_member_layouts.merge(build_data_member_layouts(root)),
381
- default_scope.struct_member_layouts.merge(build_struct_member_layouts(root))
482
+ default_scope.data_member_layouts.merge(walked.fetch(:data_member_layouts)),
483
+ default_scope.struct_member_layouts.merge(walked.fetch(:struct_member_layouts))
382
484
  ]
383
485
  end
384
486
 
@@ -1946,7 +2048,20 @@ module Rigor
1946
2048
  # `Prism::ClassVariableWriteNode` writes inside ANY def body (instance or singleton) of the enclosing class,
1947
2049
  # because Ruby cvars are shared across both facets. The resulting table is seeded into both instance and singleton
1948
2050
  # method bodies through `Scope#class_cvars_for`.
2051
+ #
2052
+ # ADR-116 WD5 — built by {ClassCvarsCollector} on the shared {DeclarationWalk}; `RIGOR_SHADOW_RULE_WALK`
2053
+ # checks it against {#legacy_class_cvar_index}.
1949
2054
  def build_class_cvar_index(root, default_scope)
2055
+ collector = ClassCvarsCollector.new
2056
+ DeclarationWalk.run(root, [collector], DeclarationWalk::Context.root(scope: default_scope))
2057
+ DeclarationWalk::Shadow.verified(:class_cvars, default_scope.source_path, collector.table) do
2058
+ legacy_class_cvar_index(root, default_scope)
2059
+ end
2060
+ end
2061
+
2062
+ # The walker {ClassCvarsCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
2063
+ # ported every table walker onto {DeclarationWalk}.
2064
+ def legacy_class_cvar_index(root, default_scope)
1950
2065
  accumulator = {}
1951
2066
  walk_class_cvars(root, [], default_scope, accumulator)
1952
2067
  accumulator.transform_values(&:freeze).freeze
@@ -2140,11 +2255,13 @@ module Rigor
2140
2255
  # @return the `program_globals` table and the census, keyed by the discovery index members it fills
2141
2256
  def build_program_global_index(root, default_scope)
2142
2257
  accumulator = {}
2258
+ in_effect = InEffectRefinements.new(root)
2143
2259
  census = { patched_line_readers: Set.new, clears_last_status: false, defines_case_equality: false,
2144
- write_census: GlobalWriteCensus::Collector.new }
2260
+ write_census: GlobalWriteCensus::Collector.new(in_effect) }
2145
2261
  gather_global_writes(root, default_scope, accumulator, census)
2146
2262
  census[:patched_line_readers] = census[:patched_line_readers].freeze
2147
2263
  census[:implicit_self_evidence] = LastLine::SelfEvidence.new(root)
2264
+ census[:in_effect_refinements] = in_effect
2148
2265
  [accumulator.freeze, census]
2149
2266
  end
2150
2267
 
@@ -2867,8 +2984,7 @@ module Rigor
2867
2984
  # `ArgumentsNode`, and a `&blk` pass-through is a `BlockArgumentNode`.
2868
2985
  def walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner,
2869
2986
  singleton_cref)
2870
- rebound = rebound_block_self(node, qualified_prefix, default_scope, nil,
2871
- rebound_self_base(self_owner))
2987
+ rebound = rebound_block_self(node, qualified_prefix, default_scope, nil, self_owner)
2872
2988
  node.rigor_each_child do |child|
2873
2989
  rebinds_self = rebound && child.is_a?(Prism::BlockNode)
2874
2990
  owner = rebinds_self ? rebound : self_owner
@@ -2894,14 +3010,18 @@ module Rigor
2894
3010
  # caller ({#meta_new_block_owner}) ([#710](https://github.com/rigortype/rigor/issues/710)). It is
2895
3011
  # supplied only by the publication census; the typed walk returns at a `ConstantWriteNode` without
2896
3012
  # descending into its rvalue, so no `self::` write inside one reaches that walk at all.
3013
+ #
3014
+ # `self_owner` is the enclosing body's self as the walk carries it, unsplit. Every walk asks this at
3015
+ # every node it visits, and only an eval call reads the self, so the split waits until one does
3016
+ # ([#1502](https://github.com/rigortype/rigor/issues/1502)).
2897
3017
  def rebound_block_self(node, qualified_prefix, default_scope = nil, meta_owner = nil,
2898
- self_prefix = nil)
3018
+ self_owner = nil)
2899
3019
  return nil unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
2900
3020
  return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name)
2901
3021
  return meta_owner || OPAQUE_SELF if meta_new_constant_rvalue?(node)
2902
3022
  return nil unless RECEIVER_EVAL_CALLS.include?(node.name)
2903
3023
 
2904
- eval_receiver_self(node.receiver, qualified_prefix, default_scope, self_prefix)
3024
+ eval_receiver_self(node.receiver, qualified_prefix, default_scope, rebound_self_base(self_owner))
2905
3025
  end
2906
3026
 
2907
3027
  # Issue #710 — the qualified name a `Klass = Class.new { … }` gives the block's class, or nil when
@@ -3128,23 +3248,249 @@ module Rigor
3128
3248
  # Issue #1120 — and a fourth, `refinements`: `{refined class => {method => [refining modules]}}`, the
3129
3249
  # `def`s of every `refine X do … end` body ({#record_refinement_defs}). Those are kept OUT of the first
3130
3250
  # three tables, which answer everywhere: a refined method exists only where a `using` is in effect.
3131
- def build_methods_and_def_nodes(root, source_path = nil)
3132
- tables = MethodTables.new({}, {})
3251
+ #
3252
+ # ADR-119 WD3 — and a fifth, the file's def-contribution siblings ({MethodTables#siblings}):
3253
+ # `possible_discovered_methods`, `contested_discovered_def_nodes` and
3254
+ # `contested_discovered_parameter_envelopes`, from the {Certainty} classification `certainty` (computed here
3255
+ # when the caller has none).
3256
+ def build_methods_and_def_nodes(root, source_path = nil, certainty: Certainty.possible_nodes(root), census: nil)
3257
+ tables = MethodTables.new({}, {}, nil, certainty, true)
3258
+ tables.root = root
3259
+ tables.census = census
3133
3260
  def_nodes = {}
3134
3261
  walk_methods_and_def_nodes(root, [], false, tables, def_nodes, source_path)
3135
- apply_alias_def_nodes(root, def_nodes)
3262
+ apply_alias_def_nodes(root, def_nodes, tables)
3136
3263
  [tables.existence.transform_values(&:freeze).freeze, def_nodes.transform_values(&:freeze).freeze,
3137
- tables.envelopes.transform_values(&:freeze).freeze, freeze_refinements(tables.refinements)]
3264
+ tables.envelopes.transform_values(&:freeze).freeze, freeze_refinements(tables.refinements),
3265
+ tables.siblings]
3138
3266
  end
3139
3267
 
3140
3268
  # The accumulator {#walk_methods_and_def_nodes} threads: the existence table and its issue #992
3141
3269
  # envelope twin, which only {#record_method} and {#record_surface_mark} write, and the issue #1120
3142
3270
  # refinement table, which only {#record_refinement_defs} writes. That last one stays nil until a
3143
3271
  # `refine` block is seen, so a file without one allocates nothing for it.
3144
- MethodTables = Struct.new(:existence, :envelopes, :refinements)
3272
+ #
3273
+ # ADR-119 WD3 — `certainty` is the file's {Certainty} answer and `certain` whether the contribution being
3274
+ # recorded is certain ({#at} sets it before each recorder). `possible`, `contested_envelopes` and
3275
+ # `contested_def_nodes` are the siblings the recorders fill, each nil until a possible contribution writes it.
3276
+ #
3277
+ # Issue #1689 — `root` is the walked tree, and `in_effect` its {InEffectRefinements}, built the first time a
3278
+ # `refine`-shaped call asks {#refine_target_of}, so a file without one never builds it.
3279
+ #
3280
+ # ADR-121 WD7 — `census`, when an Array, collects `[start offset, outcome]` for every `refine`-shaped node the
3281
+ # walk accounts for ({ScopeIndexer.refine_census}); `refine_text` memoises the byte offsets of the `refine`
3282
+ # words in the file's text, so a file with none skips every census scan and a `def` with none is not scanned;
3283
+ # `literal_seen` holds the offsets of the refine literals a call's arguments already accounted for;
3284
+ # `source_bytes` memoises the byte offsets of the file's heredoc openers; `loose_constants` memoises
3285
+ # {ScopeIndexer.loose_constant_segments}. The module a `refine`-shaped node is charged to is what the in-effect
3286
+ # walk worked out for it ({#refine_context}).
3287
+ MethodTables = Struct.new(:existence, :envelopes, :refinements, :certainty, :certain, :possible,
3288
+ :contested_envelopes, :contested_def_nodes, :root, :in_effect, :census,
3289
+ :refine_text, :literal_seen, :source_bytes, :loose_constants, :block_spans) do
3290
+ # {ScopeIndexer.module_refine_target} against this tree's {InEffectRefinements}.
3291
+ def refine_target_of(node)
3292
+ return nil if ScopeIndexer.refine_target(node).nil?
3293
+
3294
+ ScopeIndexer.module_refine_target(node, in_effect_query)
3295
+ end
3296
+
3297
+ # {InEffectRefinements#class_body_refine?} for a `refine` call of this tree.
3298
+ def class_body_refine?(node) = ScopeIndexer.refine_call?(node) && in_effect_query.class_body_refine?(node)
3299
+
3300
+ def in_effect_query = (self.in_effect ||= InEffectRefinements.new(root))
3301
+
3302
+ # Does the walked file's text name `refine` as a word? Every census scan asks first, so a file that only says
3303
+ # `refined` or `refinement` pays nothing for it.
3304
+ def mentions_refine? = !refine_offsets.empty?
3305
+
3306
+ # Does the text in `[start, stop)` name `refine` as a word, or open a heredoc whose body (which follows the
3307
+ # line, and so may end after `stop`) could? A `def` that does neither is not scanned.
3308
+ def refine_in?(start, stop)
3309
+ ScopeIndexer.offset_in?(refine_offsets, start, stop) ||
3310
+ ScopeIndexer.offset_in?(self.source_bytes ||= ScopeIndexer.heredoc_offsets(root), start, stop)
3311
+ end
3312
+
3313
+ # ADR-121 WD7 — the `self` the `refine`-shaped `node` runs on, as the in-effect walk worked it out
3314
+ # ({InEffectRefinements#refine_context}); a module the walk cannot name for a node it did not record.
3315
+ def refine_context(node) = in_effect_query.refine_context(node) || RefineSelf.unknown
3316
+
3317
+ # The byte offsets of each `refine` word in the file's text, ascending.
3318
+ def refine_offsets
3319
+ self.refine_text ||= ScopeIndexer.refine_word_offsets(root)
3320
+ end
3321
+
3322
+ # Records that the walk accounted for the `refine`-shaped `node` with `outcome` (and the module it charged),
3323
+ # when a census is taken.
3324
+ def account(node, outcome, charged = nil)
3325
+ census&.push([node.location.start_offset, outcome, charged])
3326
+ end
3327
+
3328
+ # Marks a refine literal accounted for by its call, so the walk's descent does not account for it again.
3329
+ def see_literal(node) = (self.literal_seen ||= Set.new) << node.location.start_offset
3330
+
3331
+ def literal_seen?(node) = !literal_seen.nil? && literal_seen.include?(node.location.start_offset)
3332
+
3333
+ # A6 — the last segments of the constants this file binds by `||=` or by a literal `const_set`, which the
3334
+ # project-wide `constant_writers` a reader consults leaves out.
3335
+ def loose_constant?(segment)
3336
+ self.loose_constants ||= ScopeIndexer.loose_constant_segments(root)
3337
+ loose_constants.include?(segment)
3338
+ end
3339
+
3340
+ # Issue #1820 — does `node` sit inside a block or lambda of this tree? A block's `self` may be an object even in
3341
+ # a class body or at the top level (`s.instance_eval { … }`, a `define_method` block). The spans are found once
3342
+ # per file, on the first ask.
3343
+ def in_block?(node)
3344
+ self.block_spans ||= ScopeIndexer.block_spans(root)
3345
+ offset = node.location.start_offset
3346
+ block_spans.any? { |start, stop| offset >= start && offset < stop }
3347
+ end
3348
+
3349
+ # Sets {#certain} for the contribution `node` and returns the tables.
3350
+ def at(node)
3351
+ self.certain = !Certainty.possible?(certainty, node)
3352
+ self
3353
+ end
3354
+
3355
+ # The `{sibling name => table}` part this walk fills, frozen, with the shared empty value for each
3356
+ # sibling nothing wrote.
3357
+ def siblings
3358
+ empty = Scope::DiscoveryIndex::EMPTY_SIBLINGS
3359
+ {
3360
+ possible_discovered_methods:
3361
+ possible ? possible.transform_values(&:freeze).freeze : empty[:possible_discovered_methods],
3362
+ contested_discovered_def_nodes:
3363
+ frozen_paths(contested_def_nodes) || empty[:contested_discovered_def_nodes],
3364
+ contested_discovered_parameter_envelopes:
3365
+ frozen_paths(contested_envelopes) || empty[:contested_discovered_parameter_envelopes]
3366
+ }.freeze
3367
+ end
3368
+
3369
+ private
3370
+
3371
+ def frozen_paths(set)
3372
+ return nil if set.nil? || set.empty?
3373
+
3374
+ set.each(&:freeze).freeze
3375
+ end
3376
+ end
3145
3377
 
3146
3378
  EMPTY_REFINEMENTS = {}.freeze
3147
- private_constant :EMPTY_REFINEMENTS
3379
+ REFINEMENT_WILDCARD = Scope::DiscoveryIndex::REFINEMENT_WILDCARD
3380
+ REFINE_WORD = /\brefine\b/
3381
+ HEREDOC_OPENER = /<<[~-]?["'`A-Za-z_]/
3382
+ private_constant :EMPTY_REFINEMENTS, :REFINEMENT_WILDCARD, :REFINE_WORD, :HEREDOC_OPENER
3383
+
3384
+ NO_OFFSETS = [].freeze
3385
+ private_constant :NO_OFFSETS
3386
+
3387
+ # The byte offsets of the `refine` words in `root`'s text (none for no tree), which `MethodTables#refine_in?`
3388
+ # matches node offsets against.
3389
+ def refine_word_offsets(root) = pattern_offsets(root, REFINE_WORD)
3390
+
3391
+ # The byte offsets of the heredoc openers (`<<~ID`, `<<-ID`, `<<ID`, `<<"ID"`) in `root`'s text: a heredoc's body
3392
+ # follows its line, so it may end after the `def` that opens it.
3393
+ def heredoc_offsets(root) = pattern_offsets(root, HEREDOC_OPENER)
3394
+
3395
+ def pattern_offsets(root, pattern)
3396
+ return NO_OFFSETS if root.nil?
3397
+
3398
+ # Byte-wise: a file may hold bytes its encoding does not (binary `__END__` data, a stray comment byte), and a
3399
+ # regexp over such a String raises.
3400
+ binary = root.send(:source).source.b
3401
+ return NO_OFFSETS unless pattern.match?(binary)
3402
+
3403
+ offsets = []
3404
+ position = 0
3405
+ while (match = pattern.match(binary, position))
3406
+ offsets << match.begin(0)
3407
+ position = match.end(0)
3408
+ end
3409
+ offsets.freeze
3410
+ end
3411
+
3412
+ # Is one of the ascending `offsets` in `[start, stop)`?
3413
+ # The `[start, end)` byte span of every block and lambda in `root`.
3414
+ def block_spans(root)
3415
+ spans = []
3416
+ return spans if root.nil?
3417
+
3418
+ Source::NodeWalker.each(root) do |node|
3419
+ next unless node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
3420
+
3421
+ location = node.location
3422
+ spans << [location.start_offset, location.end_offset]
3423
+ end
3424
+ spans
3425
+ end
3426
+
3427
+ def offset_in?(offsets, start, stop)
3428
+ index = offsets.bsearch_index { |offset| offset >= start }
3429
+ !index.nil? && offsets[index] < stop
3430
+ end
3431
+
3432
+ # A6 — the last segments of the constants `root` binds by `||=` (`K ||= String`, `A::K ||= …`) or by a
3433
+ # `const_set` whose name is a literal (`Object.const_set(:K, String)`), and (issue #1749) by any write in a
3434
+ # `class << …` body: the project-wide constant census files a write there under no name a `refine` argument
3435
+ # spells, but a `def` in that body reads it lexically (`class << self; K = String; def setup = refine(K) {…}`).
3436
+ # Scanned only for a file with a literal-target `refine`.
3437
+ def loose_constant_segments(root)
3438
+ segments = Set.new
3439
+ Source::NodeWalker.each(root) do |node|
3440
+ case node
3441
+ when Prism::ConstantOrWriteNode then segments << node.name.to_s
3442
+ when Prism::ConstantPathOrWriteNode then segments << node.target.name.to_s
3443
+ when Prism::SingletonClassNode then singleton_constant_segments(node.body, segments)
3444
+ when Prism::CallNode
3445
+ name = node.name == :const_set && node.arguments&.arguments&.first
3446
+ segments << name.unescaped if name.is_a?(Prism::SymbolNode) || name.is_a?(Prism::StringNode)
3447
+ end
3448
+ end
3449
+ segments
3450
+ end
3451
+
3452
+ # The last segment of every constant a write, an operator write or a multiple assignment target in `body` binds.
3453
+ def singleton_constant_segments(body, segments)
3454
+ return if body.nil?
3455
+
3456
+ Source::NodeWalker.each(body) do |node|
3457
+ case node
3458
+ when Prism::ConstantWriteNode, Prism::ConstantTargetNode, Prism::ConstantOperatorWriteNode,
3459
+ Prism::ConstantAndWriteNode, Prism::ConstantPathTargetNode
3460
+ segments << node.name.to_s
3461
+ when Prism::ConstantPathWriteNode, Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathAndWriteNode
3462
+ segments << node.target.name.to_s
3463
+ end
3464
+ end
3465
+ end
3466
+
3467
+ # ADR-121 WD7 — the census gate's view of one parsed file (`root`): `[refinement table, [[start offset,
3468
+ # outcome], …]]`, one pair for every `refine`-shaped node the method walk accounted for. The outcomes are
3469
+ # `:recorded` (a literal target and block, its names recorded; also one in a module's own singleton method,
3470
+ # #1749), `:class_unknown` (a block whose target the walk cannot name), `:targets_wildcard` (a `refine` that
3471
+ # runs once a method is called, other than a module's own singleton method's, or in a block that may rebind
3472
+ # `self`, charged to its module or the wildcard), `:literal` (A3's `:refine` / eval-string literal), `:nested` (a
3473
+ # `refine` in a refine body, or in a singleton method of one), `:dsl` (where `self` is a class, #1689) and
3474
+ # `:raises` (no block, a Proc as the block, another receiver, or a computed target where `self` is `main`), and
3475
+ # `:data` (a `:refine` literal where `self` is a plain class or one of its instances).
3476
+ def refine_census(root)
3477
+ census = []
3478
+ refinements = build_methods_and_def_nodes(root, census: census)[3]
3479
+ [refinements, census]
3480
+ end
3481
+
3482
+ # ADR-121 WD7 (issue #1814) — the refinement table one parsed file contributes: the census of
3483
+ # {#build_methods_and_def_nodes}, whose fourth table is the project walk's own (`seed_discovered_methods` and
3484
+ # `walk_def_contributions` take it from the same call), run for a caller that wants nothing else. The gem source
3485
+ # walker reads a gem file this way, so a gem module's rows carry the wildcards a project module's would
3486
+ # (`walker_walk_census_parity_spec.rb`, beside the walker's specs, holds the two to one table). A file whose text
3487
+ # never names `refine` as a word records no row, so it is not walked. `source_path` names an anonymous module's
3488
+ # row (`#<Module:<path>:L:C>`) as the project walk does, so two files' bare `Module.new` blocks never share one.
3489
+ def refinement_table(root, source_path = nil)
3490
+ return EMPTY_REFINEMENTS if refine_word_offsets(root).empty?
3491
+
3492
+ build_methods_and_def_nodes(root, source_path)[3]
3493
+ end
3148
3494
 
3149
3495
  # Issue #1120 — a refinement table, deep-frozen; the shared empty table when nothing was recorded.
3150
3496
  def freeze_refinements(table)
@@ -3168,8 +3514,7 @@ module Rigor
3168
3514
  # implicit- or `self`-receiver `refine` with one constant argument and a literal block. A computed target
3169
3515
  # (`refine(klass) { … }`) names no class this walk can key, so its body walks as it did before.
3170
3516
  def refine_target(node)
3171
- return nil unless node.name == :refine && node.block.is_a?(Prism::BlockNode)
3172
- return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
3517
+ return nil unless refine_call?(node)
3173
3518
 
3174
3519
  arguments = node.arguments&.arguments
3175
3520
  return nil unless arguments&.size == 1
@@ -3178,27 +3523,239 @@ module Rigor
3178
3523
  target if target.is_a?(Prism::ConstantReadNode) || target.is_a?(Prism::ConstantPathNode)
3179
3524
  end
3180
3525
 
3526
+ # An implicit- or `self`-receiver `refine` call with a literal block, whatever its argument.
3527
+ def refine_call?(node)
3528
+ node.name == :refine && node.block.is_a?(Prism::BlockNode) &&
3529
+ (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
3530
+ end
3531
+
3532
+ # Issue #1689 — {#refine_target}, for a call that `in_effect` (its file's {InEffectRefinements}) does not place
3533
+ # where `self` is a class. `Class` undefines `refine`, so `refine X do … end` directly in a `class` body is the
3534
+ # class's own method (a DSL), and its block walks as any other block. Every walk that treats a refine body
3535
+ # specially asks this, so the method tables, the in-effect refinements and the typer agree on which calls are
3536
+ # refine bodies.
3537
+ def module_refine_target(node, in_effect)
3538
+ target = refine_target(node)
3539
+ target unless target.nil? || in_effect.class_body_refine?(node)
3540
+ end
3541
+
3181
3542
  # Issue #1120 — records the instance `def`s of a `refine X do … end` body as refinement methods of X, keyed by
3182
- # the refining module (`owner_prefix`, the `self` the `refine` call runs on). X is resolved LEXICALLY
3543
+ # the refining module (`refining`, the `self` the `refine` call runs on). X is resolved LEXICALLY
3183
3544
  # (`qualified_prefix`), which is where Ruby resolves the argument even inside a `Module.new { … }` block
3184
3545
  # whose `self` is anonymous; every name it can denote is recorded, as {#constant_receiver_candidates}
3185
3546
  # explains. Nothing in the body reaches the class tables: `refine String do def shout` does not give
3186
3547
  # `String` or the refining module a `shout` outside a `using`.
3187
- def record_refinement_defs(node, target, qualified_prefix, owner_prefix, tables)
3548
+ #
3549
+ # ADR-121 WD7 (issue #1799) — the names are what {RefineCensus.read_body} reads (`def`, `alias`,
3550
+ # `alias_method`, `define_method`, `attr_*`, `undef`), and a body that may define a name it cannot spell adds a
3551
+ # names-wildcard row per target. `refining` is `""` at the top level (where Ruby raises). The body is not
3552
+ # walked any further, so the census scans it here.
3553
+ #
3554
+ # A6 — a target this file binds by `||=` or a literal `const_set` (`K ||= String; refine(K)`) names whatever value
3555
+ # the write holds, so its names go under the wildcard class, as a computed target's do.
3556
+ def record_refinement_defs(node, target, qualified_prefix, refining, tables)
3557
+ loose = tables.loose_constant?(target.name.to_s)
3558
+ tables.account(node, loose ? :class_unknown : :recorded, census_charge(refining))
3188
3559
  body = node.block.body
3189
3560
  return if body.nil?
3190
3561
 
3191
- refining = owner_prefix.join("::")
3192
- targets = constant_receiver_candidates(target, qualified_prefix)
3193
- each_refinement_def(body) do |def_node|
3194
- targets.each do |class_name|
3195
- methods = ((tables.refinements ||= {})[class_name] ||= {})
3196
- modules = (methods[def_node.name] ||= [])
3197
- modules << refining unless modules.include?(refining)
3562
+ targets = loose ? [REFINEMENT_WILDCARD] : constant_receiver_candidates(target, qualified_prefix)
3563
+ reading = RefineCensus.read_body(body)
3564
+ refining.each do |owner|
3565
+ tables.refinements = RefineCensus.record_rows(tables.refinements, targets, reading, owner)
3566
+ end
3567
+ census_scan(body, qualified_prefix, tables, refine_body: true) if tables.mentions_refine?
3568
+ end
3569
+
3570
+ # What the census records as charged for `owners`: the one module, or every name.
3571
+ def census_charge(owners) = owners.size == 1 ? owners.first : owners
3572
+
3573
+ # ADR-121 WD7 — a literal-target `refine X do … end` the module-body walk reaches, charged where the in-effect
3574
+ # walk says it runs ({MethodTables#refine_context}): rows for the module it names (the walk's own name for an
3575
+ # anonymous `Module.new` block, `""` at the top level), or a targets-wildcard for that module (the wildcard
3576
+ # where it cannot be named) when it runs only once a method is called or on a module the walk cannot name.
3577
+ def record_literal_target_refine(node, target, qualified_prefix, owner_prefix, tables)
3578
+ context = tables.refine_context(node)
3579
+ here = context.here
3580
+ return record_refinement_defs(node, target, qualified_prefix, [""], tables) if here == RefineSelf::MAIN
3581
+ if context.deferred || here == REFINEMENT_WILDCARD
3582
+ return record_deferred_refine(node, RefineSelf.charged_modules(context), qualified_prefix, tables)
3583
+ end
3584
+
3585
+ record_refinement_defs(node, target, qualified_prefix, census_owners(context, owner_prefix), tables)
3586
+ end
3587
+
3588
+ # The modules a `refine` charged to `context` refines for; an anonymous `Module.new` block's is named by its
3589
+ # position (`owner_prefix`).
3590
+ def census_owners(context, owner_prefix)
3591
+ context.here == RefineSelf::ANONYMOUS ? [owner_prefix.join("::")] : RefineSelf.charged_modules(context)
3592
+ end
3593
+
3594
+ # ADR-121 WD7 — a targets-wildcard for each of `owners`, as a `refine` that runs once a method is called records
3595
+ # (an instance `def`, a `define_method` or `define_singleton_method` block; a module's own singleton method
3596
+ # records rows instead, #1749). The body is not walked any further, so the census scans it here.
3597
+ def record_deferred_refine(node, owners, qualified_prefix, tables)
3598
+ tables.account(node, :targets_wildcard, census_charge(owners))
3599
+ owners.each { |owner| record_targets_wildcard(tables, owner) }
3600
+ body = node.block.body
3601
+ census_scan(body, qualified_prefix, tables, refine_body: true) if body && tables.mentions_refine?
3602
+ end
3603
+
3604
+ # ADR-121 WD7 — a `refine`-shaped call the canonical arm ({#refine_target}) does not take: on another receiver
3605
+ # (`Module#refine` is private, so it raises), where `self` is a class (#1689's DSL), with no literal block (no
3606
+ # block raises, and so does a Proc passed as one), or with a target the walk cannot name (`refine(k)`,
3607
+ # `refine(self)`, `refine(K.dup)`). The last records the body's names under the wildcard class, so they decline
3608
+ # on every receiver, and a names-wildcard row when they are incomplete, which makes the module opaque; one that
3609
+ # runs once a method is called, or on a module the walk cannot name, records a targets-wildcard. At the top
3610
+ # level `self` is `main`, which has no `refine`.
3611
+ def account_other_refine(node, owner_prefix, tables)
3612
+ context = tables.refine_context(node)
3613
+ outcome, charged =
3614
+ if tables.class_body_refine?(node) || context.here == RefineSelf::CLASS then :dsl
3615
+ elsif !RefineCensus.self_call?(node) || !node.block.is_a?(Prism::BlockNode) ||
3616
+ context.here == RefineSelf::MAIN
3617
+ :raises
3618
+ elsif context.deferred || context.here == REFINEMENT_WILDCARD
3619
+ owners = RefineSelf.charged_modules(context)
3620
+ owners.each { |owner| record_targets_wildcard(tables, owner) }
3621
+ [:targets_wildcard, census_charge(owners)]
3622
+ else
3623
+ owners = census_owners(context, owner_prefix)
3624
+ owners.each { |owner| record_unreadable_target(node, owner, tables) }
3625
+ [:class_unknown, census_charge(owners)]
3626
+ end
3627
+ tables.account(node, outcome, charged)
3628
+ end
3629
+
3630
+ def record_unreadable_target(node, owner, tables)
3631
+ tables.refinements = RefineCensus.record_rows(tables.refinements, [REFINEMENT_WILDCARD],
3632
+ RefineCensus.read_body(node.block.body), owner)
3633
+ end
3634
+
3635
+ # `{"*" => {"*" => [owner]}}`: some `refine` of `owner` (every module, for the wildcard) is unreadable.
3636
+ def record_targets_wildcard(tables, owner)
3637
+ tables.refinements = RefineCensus.add_row(tables.refinements || {}, REFINEMENT_WILDCARD, REFINEMENT_WILDCARD,
3638
+ owner)
3639
+ end
3640
+
3641
+ # ADR-121 WD7 — the census over a subtree the method walk does not descend (a `def` body, a refine body), each
3642
+ # node charged where the in-effect walk says it runs. `refine_body` says `self` is the refinement itself, where a
3643
+ # nested `refine` refines for the refinement and only makes the body's names incomplete (§2.2).
3644
+ def census_scan(node, qualified_prefix, tables, refine_body: false)
3645
+ case node
3646
+ when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
3647
+ refine_body = false
3648
+ when Prism::CallNode
3649
+ if (target = own_method_refine_target(node, tables))
3650
+ return record_own_method_refine(node, target, qualified_prefix, tables)
3651
+ end
3652
+
3653
+ census_call(node, qualified_prefix, tables, refine_body)
3654
+ when Prism::SymbolNode
3655
+ census_symbol(node, tables)
3656
+ end
3657
+ node.rigor_each_child { |child| census_scan(child, qualified_prefix, tables, refine_body: refine_body) }
3658
+ end
3659
+
3660
+ # ADR-121 WD7 (issue #1749) — the target of a canonical `refine X do … end` ({#refine_target}) in a module's own
3661
+ # singleton method (`def self.x`, a `def` in the module's `class << self`, or a block there that keeps `self`),
3662
+ # else nil. `self` is the module itself there, so the call refines X for it once the method is called. Anything
3663
+ # else in a method body (an instance `def`, a `define_method` or `define_singleton_method` block, a computed
3664
+ # target, a block that may rebind `self`) charges a targets-wildcard in {#census_call}, and a `refine` in a refine
3665
+ # body is nested there, whichever method it sits in: its context ({RefineSelf.refinement_body}) is not an
3666
+ # own method's.
3667
+ def own_method_refine_target(node, tables)
3668
+ return nil if node.name != :refine
3669
+
3670
+ target = tables.refine_target_of(node)
3671
+ target if target && tables.refine_context(node).own_method
3672
+ end
3673
+
3674
+ # Records `node` as the refine of its module, as a `refine` in the module body is recorded, with the refined
3675
+ # class marked as that one is ({#record_refinement}). The call's block is scanned here, so {#census_scan} does
3676
+ # not descend into it.
3677
+ def record_own_method_refine(node, target, qualified_prefix, tables)
3678
+ record_refinement(node, qualified_prefix, tables)
3679
+ owners = RefineSelf.charged_modules(tables.refine_context(node))
3680
+ record_refinement_defs(node, target, qualified_prefix, owners, tables)
3681
+ end
3682
+
3683
+ def census_call(node, qualified_prefix, tables, refine_body)
3684
+ census_literal_arguments(node, qualified_prefix, tables)
3685
+ return unless node.name == :refine
3686
+
3687
+ context = tables.refine_context(node)
3688
+ nested = refine_body || context.here == RefineSelf::REFINEMENT
3689
+ outcome, charged =
3690
+ if tables.class_body_refine?(node) || context.here == RefineSelf::CLASS then :dsl
3691
+ elsif nested && RefineCensus.self_call?(node) then :nested
3692
+ elsif !RefineCensus.self_call?(node) || node.block.nil? || context.here == RefineSelf::MAIN then :raises
3693
+ else
3694
+ owners = RefineSelf.charged_modules(context)
3695
+ owners.each { |owner| record_targets_wildcard(tables, owner) }
3696
+ [:targets_wildcard, census_charge(owners)]
3198
3697
  end
3698
+ tables.account(node, outcome, charged)
3699
+ end
3700
+
3701
+ # A3 — a `:refine` Symbol argument of any call, or a String argument holding `refine` of an eval, `send` or
3702
+ # method-naming call, records a targets-wildcard for the module the call runs `refine` on: the receiver of a
3703
+ # `send` or `*_eval` (each name a constant one can denote), else the module the in-effect walk says `self` is
3704
+ # there. Where `self` is a plain class or one of its instances, a literal charged to `self` is data.
3705
+ def census_literal_arguments(node, qualified_prefix, tables)
3706
+ arguments = node.arguments&.arguments
3707
+ return if arguments.nil?
3708
+
3709
+ strings = RefineCensus.string_literal_call?(node)
3710
+ arguments.each do |argument|
3711
+ next unless RefineCensus.refine_symbol?(argument) || (strings && RefineCensus.refine_string?(argument))
3712
+ next if tables.literal_seen?(argument)
3713
+
3714
+ tables.see_literal(argument)
3715
+ census_literal_argument(node, argument, qualified_prefix, tables)
3199
3716
  end
3200
3717
  end
3201
3718
 
3719
+ def census_literal_argument(call, literal, qualified_prefix, tables)
3720
+ target = RefineCensus.literal_target(call)
3721
+ context = tables.refine_context(literal)
3722
+ return tables.account(literal, :data) if target == :self && context.literal == RefineSelf::CLASS
3723
+ return tables.account(literal, :nested) if target == :self && context.literal == RefineSelf::REFINEMENT
3724
+
3725
+ owners = literal_owners(call, target, RefineSelf.charged_modules(context, literal: true), qualified_prefix)
3726
+ owners.each { |owner| record_targets_wildcard(tables, owner) }
3727
+ tables.account(literal, :literal, target == :self ? census_charge(owners) : [:receiver, owners])
3728
+ end
3729
+
3730
+ def literal_owners(call, target, self_modules, qualified_prefix)
3731
+ case target
3732
+ when :self then self_modules
3733
+ when :unknown then [REFINEMENT_WILDCARD]
3734
+ else
3735
+ receiver = call.receiver
3736
+ names = constant_shaped?(receiver) ? constant_receiver_candidates(receiver, qualified_prefix) : []
3737
+ names.empty? ? [REFINEMENT_WILDCARD] : names
3738
+ end
3739
+ end
3740
+
3741
+ def constant_shaped?(node) = node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
3742
+
3743
+ # A3 — a `:refine` Symbol anywhere else (`alias my_refine refine`, `%i[refine]`, a hash) names `Module#refine`
3744
+ # for code the walk cannot follow, run on the module the in-effect walk says `self` is there; where `self` is a
3745
+ # plain class or one of its instances it is data.
3746
+ def census_symbol(node, tables)
3747
+ return unless RefineCensus.refine_symbol?(node) && !tables.literal_seen?(node)
3748
+
3749
+ tables.see_literal(node)
3750
+ context = tables.refine_context(node)
3751
+ return tables.account(node, :data) if context.literal == RefineSelf::CLASS
3752
+ return tables.account(node, :nested) if context.literal == RefineSelf::REFINEMENT
3753
+
3754
+ owners = RefineSelf.charged_modules(context, literal: true)
3755
+ owners.each { |owner| record_targets_wildcard(tables, owner) }
3756
+ tables.account(node, :literal, census_charge(owners))
3757
+ end
3758
+
3202
3759
  # The instance `def`s a refine body defines on the refined class: nested declarations open their own
3203
3760
  # scope, and a `def self.x` or a `def` inside another `def` defines nothing on it.
3204
3761
  def each_refinement_def(node, &)
@@ -3215,9 +3772,58 @@ module Rigor
3215
3772
 
3216
3773
  # The walk's single existence writer. Everything except a `def` passes no envelope and so records
3217
3774
  # {Source::ParameterEnvelope::OPAQUE}.
3775
+ #
3776
+ # ADR-119 WD3 — the existence table's `possible` sibling follows the contribution's certainty
3777
+ # ({MethodTables#certain}): a certain contribution removes its kind from the name's possible kind, and a
3778
+ # possible one adds its kind unless the table already answered that kind certainly. A possible contribution
3779
+ # also contests the envelope entry it joins (envelopes are joined, so one possible joiner is enough).
3218
3780
  def record_method(tables, class_name, method_name, kind, envelope = Source::ParameterEnvelope::OPAQUE)
3781
+ follow_method_certainty(tables, class_name, method_name, kind) unless tables.certain && tables.possible.nil?
3219
3782
  record_method_kind(tables.existence, class_name, method_name, kind)
3220
3783
  record_envelope(tables.envelopes, class_name, [kind, method_name], envelope)
3784
+ contest_envelope(tables, class_name, [kind, method_name]) unless tables.certain
3785
+ end
3786
+
3787
+ # The `possible_discovered_methods` half of {#record_method}, read BEFORE the existence table is written.
3788
+ def follow_method_certainty(tables, class_name, method_name, kind)
3789
+ if tables.certain
3790
+ settle_possible_kind(tables.possible, class_name, method_name, kind)
3791
+ else
3792
+ sitting = tables.possible&.dig(class_name, method_name)
3793
+ return if kind_covers?(tables.existence[class_name]&.[](method_name), kind) && !kind_covers?(sitting, kind)
3794
+
3795
+ row = ((tables.possible ||= {})[class_name] ||= {})
3796
+ row[method_name] = merge_sibling_kind(sitting, kind)
3797
+ end
3798
+ end
3799
+
3800
+ # A certain contribution: `kind` leaves the name's possible kind, and an emptied row goes.
3801
+ def settle_possible_kind(possible, class_name, method_name, kind)
3802
+ row = possible[class_name]
3803
+ return if row.nil?
3804
+
3805
+ remaining = without_kind(row[method_name], kind)
3806
+ return if remaining == row[method_name]
3807
+
3808
+ remaining.nil? ? row.delete(method_name) : (row[method_name] = remaining)
3809
+ possible.delete(class_name) if row.empty?
3810
+ end
3811
+
3812
+ # Whether a recorded kind (`:instance`, `:singleton`, `:both`, or nil) answers `kind`.
3813
+ def kind_covers?(recorded, kind)
3814
+ !recorded.nil? && (recorded == kind || recorded == Scope::DiscoveryIndex::METHOD_KIND_BOTH)
3815
+ end
3816
+
3817
+ # `recorded` less `kind`: nil when nothing remains.
3818
+ def without_kind(recorded, kind)
3819
+ return nil if recorded.nil? || recorded == kind
3820
+ return recorded unless recorded == Scope::DiscoveryIndex::METHOD_KIND_BOTH
3821
+
3822
+ kind == :instance ? :singleton : :instance
3823
+ end
3824
+
3825
+ def contest_envelope(tables, class_name, key)
3826
+ (tables.contested_envelopes ||= Set.new) << [class_name, key]
3221
3827
  end
3222
3828
 
3223
3829
  def record_envelope(envelopes, class_name, key, envelope)
@@ -3303,7 +3909,10 @@ module Rigor
3303
3909
  # `self::` header under a REBOUND self (eval/meta-new body) names
3304
3910
  # `owner::Name` instead.
3305
3911
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
3306
- record_declaration_facts(node, child_prefix, methods_acc) unless child_cref
3912
+ if node.is_a?(Prism::ClassNode) && node.superclass && methods_acc.mentions_refine?
3913
+ census_scan(node.superclass, qualified_prefix, methods_acc)
3914
+ end
3915
+ record_declaration_facts(node, child_prefix, methods_acc.at(node)) unless child_cref
3307
3916
  body_prefix = child_cref ? [] : child_prefix
3308
3917
  if node.body
3309
3918
  walk_methods_and_def_nodes(node.body, body_prefix, false, methods_acc, def_nodes_acc,
@@ -3319,6 +3928,10 @@ module Rigor
3319
3928
  # still decline. The lexical cref below is unnameable in every case.
3320
3929
  singleton_prefix = singleton_body_prefix(node, in_singleton_class, owner_prefix,
3321
3930
  qualified_prefix)
3931
+ tables = methods_acc.at(node)
3932
+ if !node.expression.is_a?(Prism::SelfNode) || tables.in_block?(node)
3933
+ record_singleton_body_prepends(node, qualified_prefix, tables)
3934
+ end
3322
3935
  walk_methods_and_def_nodes(node.expression, qualified_prefix, in_singleton_class,
3323
3936
  methods_acc, def_nodes_acc, source_path, def_owner_prefix,
3324
3937
  singleton_cref: singleton_cref,
@@ -3336,6 +3949,7 @@ module Rigor
3336
3949
  return
3337
3950
  when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode, Prism::ConstantOrWriteNode,
3338
3951
  Prism::ConstantPathOrWriteNode
3952
+ record_meta_superclass(node, qualified_prefix, def_owner_prefix, methods_acc.at(node))
3339
3953
  if (split = meta_new_block_split(node, qualified_prefix, def_owner_prefix, singleton_cref))
3340
3954
  enclosing, body, body_self = split
3341
3955
  # A meta-new block rebinds only `self` — `Module.nesting` stays lexical — so
@@ -3346,7 +3960,9 @@ module Rigor
3346
3960
  child_prefix = meta_new_child_prefix(node, qualified_prefix, def_owner_prefix)
3347
3961
  meta_ownerless = singleton_cref &&
3348
3962
  !meta_new_path_target_nameable?(node, def_owner_prefix)
3349
- record_meta_new_facts(meta_new_rvalue(node), child_prefix, methods_acc) if child_prefix && !meta_ownerless
3963
+ if child_prefix && !meta_ownerless
3964
+ record_meta_new_facts(meta_new_rvalue(node), child_prefix, methods_acc.at(node))
3965
+ end
3350
3966
  enclosing.each do |part|
3351
3967
  walk_methods_and_def_nodes(part, qualified_prefix, in_singleton_class, methods_acc,
3352
3968
  def_nodes_acc, source_path, def_owner_prefix,
@@ -3370,34 +3986,48 @@ module Rigor
3370
3986
  # receiver, a singleton nothing names) or any ownerless prefix under an unnameable
3371
3987
  # cref — is NOT top level; `record_def_node` would file the def under `<toplevel>`
3372
3988
  # where an implicit-self call could find a method Ruby never installed there.
3989
+ location = node.location
3990
+ if methods_acc.mentions_refine? && methods_acc.refine_in?(location.start_offset, location.end_offset)
3991
+ node.rigor_each_child { |child| census_scan(child, qualified_prefix, methods_acc) }
3992
+ end
3373
3993
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3374
3994
  (singleton_cref && owner_prefix.empty?)
3375
3995
  singleton_def = in_singleton_class || defs_singleton
3376
- record_def_method(node, owner_prefix, singleton_def, methods_acc)
3996
+ record_def_method(node, owner_prefix, singleton_def, methods_acc.at(node))
3377
3997
  record_def_body_evidence(node, owner_prefix, methods_acc)
3378
- record_def_node(node, owner_prefix, singleton_def, def_nodes_acc)
3998
+ record_def_node(node, owner_prefix, singleton_def, def_nodes_acc, methods_acc)
3379
3999
  end
3380
4000
  return
3381
4001
  when Prism::AliasMethodNode, Prism::UndefNode
4002
+ census_scan(node, qualified_prefix, methods_acc) if methods_acc.mentions_refine?
3382
4003
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3383
4004
  (singleton_cref && owner_prefix.empty?)
3384
4005
  record_alias_or_undef(node, owner_prefix, in_singleton_class || defs_singleton,
3385
- methods_acc)
4006
+ methods_acc.at(node))
3386
4007
  end
3387
4008
  return
4009
+ when Prism::SymbolNode
4010
+ census_symbol(node, methods_acc) if methods_acc.mentions_refine?
3388
4011
  when Prism::CallNode
4012
+ census_literal_arguments(node, qualified_prefix, methods_acc) if methods_acc.mentions_refine?
3389
4013
  if receiver_eval_call?(node)
3390
4014
  return walk_eval_methods_and_defs(node, qualified_prefix, in_singleton_class, methods_acc,
3391
4015
  def_nodes_acc, source_path, def_owner_prefix,
3392
4016
  singleton_cref: singleton_cref,
3393
4017
  defs_singleton: defs_singleton)
3394
4018
  end
3395
- anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc, source_path)
4019
+ anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
4020
+ source_path)
4021
+ tables = methods_acc.at(node)
4022
+ record_singleton_prepend(node, qualified_prefix, tables,
4023
+ self_ok: node.name == :prepend && tables.in_block?(node))
3396
4024
  # Issue #1120 — a refine body's defs go to the refinement table and nowhere else.
3397
- if (target = refine_target(node))
3398
- return record_refinement_defs(node, target, qualified_prefix, owner_prefix, methods_acc)
4025
+ if (target = methods_acc.refine_target_of(node))
4026
+ return record_literal_target_refine(node, target, qualified_prefix, owner_prefix, methods_acc)
3399
4027
  end
3400
4028
 
4029
+ account_other_refine(node, owner_prefix, methods_acc) if node.name == :refine
4030
+
3401
4031
  if anonymous
3402
4032
  walk_anonymous_meta_block(node, anonymous, qualified_prefix, in_singleton_class, methods_acc,
3403
4033
  def_nodes_acc, source_path, def_owner_prefix,
@@ -3463,8 +4093,24 @@ module Rigor
3463
4093
  end
3464
4094
 
3465
4095
  # The block form of a meta-new constant write: its members, and the module mark `Module.new` earns.
4096
+ # Issue #1820 — `K = Class.new(Parent)`, with or without a block, names a class whose superclass the superclass
4097
+ # table leaves out; it takes {Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK}.
4098
+ def record_meta_superclass(node, qualified_prefix, self_base, tables)
4099
+ rvalue = meta_new_rvalue(node)
4100
+ return unless class_new_call?(rvalue) && !(rvalue.arguments&.arguments || []).empty?
4101
+
4102
+ child_prefix = meta_new_child_prefix(node, qualified_prefix, self_base)
4103
+ return if child_prefix.nil? || child_prefix.empty?
4104
+
4105
+ record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_META_SUPERCLASS_MARK)
4106
+ end
4107
+
3466
4108
  def record_meta_new_facts(rvalue, child_prefix, tables)
3467
4109
  record_meta_members(rvalue, child_prefix, tables)
4110
+ # `Module.new(&BODY)`: the body runs on the new module but cannot be read, so its surface is unknown (#1816).
4111
+ if rvalue.block.is_a?(Prism::BlockArgumentNode)
4112
+ record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK)
4113
+ end
3468
4114
  return unless module_new_call?(rvalue)
3469
4115
 
3470
4116
  record_surface_mark(tables, child_prefix.join("::"), Scope::DiscoveryIndex::ENVELOPE_MODULE_MARK)
@@ -3497,7 +4143,7 @@ module Rigor
3497
4143
  def record_alias_method_call(call_node, qualified_prefix, in_singleton_class, accumulator)
3498
4144
  return if qualified_prefix.empty?
3499
4145
 
3500
- names = alias_method_call_names(call_node)
4146
+ names = Source::AliasNames.alias_method_call_names(call_node)
3501
4147
  return if names.nil?
3502
4148
 
3503
4149
  kind = in_singleton_class ? :singleton : :instance
@@ -3514,7 +4160,7 @@ module Rigor
3514
4160
  def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc, # rubocop:disable Metrics/ParameterLists
3515
4161
  def_nodes_acc, source_path, def_owner_prefix = nil,
3516
4162
  singleton_cref: false, defs_singleton: false)
3517
- record_meta_members(call_node, [name], methods_acc)
4163
+ record_meta_members(call_node, [name], methods_acc.at(call_node))
3518
4164
  call_node.rigor_each_child do |child|
3519
4165
  if child.equal?(call_node.block)
3520
4166
  body = call_node.block.body
@@ -3604,7 +4250,9 @@ module Rigor
3604
4250
  # other two. {#meta_new_block_call} owns both unwrappings; every walk asks it, so no spelling can be
3605
4251
  # recognised by one pass and missed by another.
3606
4252
  def meta_new_block_body(node)
3607
- meta_new_block_call(node)&.block&.body
4253
+ block = meta_new_block_call(node)&.block
4254
+ # A `&BODY` / `&:sym` block ARGUMENT is not a literal block: its body is unreadable here (#1816).
4255
+ block.body if block.is_a?(Prism::BlockNode)
3608
4256
  end
3609
4257
 
3610
4258
  # The class-creating call a constant write's rvalue ultimately is, or nil when the write is not the idiom.
@@ -3858,16 +4506,57 @@ module Rigor
3858
4506
  return if def_node.body.nil?
3859
4507
 
3860
4508
  Source::NodeWalker.each(def_node.body) do |node|
4509
+ if node.is_a?(Prism::SingletonClassNode)
4510
+ record_singleton_body_prepends(node, qualified_prefix, tables)
4511
+ next
4512
+ end
3861
4513
  next unless node.is_a?(Prism::CallNode)
3862
4514
 
3863
4515
  if node.name == :extend
3864
4516
  record_object_extension(node, qualified_prefix, tables)
3865
4517
  elsif SURFACE_EVAL_CALLS.include?(node.name) || SURFACE_NAMING_CALLS.include?(node.name)
3866
4518
  record_body_rewrite(node, qualified_prefix, tables)
4519
+ else
4520
+ record_singleton_prepend(node, qualified_prefix, tables, self_ok: true)
3867
4521
  end
3868
4522
  end
3869
4523
  end
3870
4524
 
4525
+ # Issue #1820 — `obj.singleton_class.prepend(M)`, or `prepend M` in a `class << obj` body, puts `M` ahead of
4526
+ # `obj`'s class even when the class already includes it (`prepend` re-inserts it; `extend` would not), so
4527
+ # `M`'s refinement answers first for that object. `M` takes {Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK},
4528
+ # the mark an `extend` in a method body already leaves: an object of any class may carry its instance methods
4529
+ # ahead of its class's own. A `singleton_class` of `self` (and a `class << self` body, which the caller leaves
4530
+ # out) written directly in a class or module body or at the top level is the class object's or `main`'s, not an
4531
+ # instance's; in a method body or any block (`self_ok`), where `self` may be an object, it counts.
4532
+ # `singleton_body` says the call sits in a `class << obj` body.
4533
+ def record_singleton_prepend(node, qualified_prefix, tables, singleton_body: false, self_ok: false)
4534
+ return unless node.name == :prepend
4535
+
4536
+ receiver = node.receiver
4537
+ singleton = singleton_body && (receiver.nil? || receiver.is_a?(Prism::SelfNode))
4538
+ singleton ||= singleton_class_call?(receiver, self_ok)
4539
+ record_object_extension(node, qualified_prefix, tables) if singleton
4540
+ end
4541
+
4542
+ def singleton_class_call?(receiver, self_ok)
4543
+ return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class
4544
+ return false unless receiver.arguments.nil?
4545
+
4546
+ object = receiver.receiver
4547
+ self_ok || !(object.nil? || object.is_a?(Prism::SelfNode))
4548
+ end
4549
+
4550
+ def record_singleton_body_prepends(node, qualified_prefix, tables)
4551
+ return if node.body.nil?
4552
+
4553
+ Source::NodeWalker.each(node.body) do |inner|
4554
+ next unless inner.is_a?(Prism::CallNode)
4555
+
4556
+ record_singleton_prepend(inner, qualified_prefix, tables, singleton_body: true, self_ok: true)
4557
+ end
4558
+ end
4559
+
3871
4560
  def record_body_rewrite(node, qualified_prefix, tables)
3872
4561
  receiver = node.receiver
3873
4562
  if receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
@@ -3928,13 +4617,30 @@ module Rigor
3928
4617
  # same-named local method.
3929
4618
  TOP_LEVEL_DEF_KEY = "<toplevel>"
3930
4619
 
3931
- def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
4620
+ #
4621
+ # ADR-119 WD3 — the slot follows its writer (later-wins): a certain write clears the key from
4622
+ # `contested_discovered_def_nodes` and a possible one adds it ({MethodTables#certain}, set for `def_node`).
4623
+ def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator, tables = nil)
3932
4624
  return if def_singleton?(def_node, qualified_prefix, in_singleton_class)
3933
4625
 
3934
4626
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
3935
4627
  accumulator[class_name] ||= {}
3936
4628
  accumulator[class_name][def_node.name] = def_node
3937
- record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
4629
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, def_node.name], tables.nil? || tables.certain)
4630
+ record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables)
4631
+ end
4632
+
4633
+ # ADR-119 WD3 — a single-valued slot's contest follows the write that holds it: `certain` clears `key` from
4634
+ # the `slot` Set of `tables`, a possible write adds it. Nil `tables` (a caller with no certainty) is a no-op.
4635
+ def follow_slot_writer(tables, slot, key, certain)
4636
+ return if tables.nil?
4637
+
4638
+ if certain
4639
+ set = tables[slot]
4640
+ set&.delete(key)
4641
+ else
4642
+ (tables[slot] ||= Set.new) << key
4643
+ end
3938
4644
  end
3939
4645
 
3940
4646
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
@@ -3946,12 +4652,16 @@ module Rigor
3946
4652
  # `spawn(...) { start }` block. Giving the body a class of its own must not silently retract it: the call
3947
4653
  # resolves at runtime, and `call.unresolved-toplevel` firing on it would be a new false positive traded
3948
4654
  # for the ones this change retires. Never clobbers a real top-level `def` of the same name.
3949
- def record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
4655
+ def record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables = nil)
3950
4656
  return unless qualified_prefix.length == 1
3951
4657
  return unless Type::AnonymousClassName.match?(qualified_prefix.first)
3952
4658
 
3953
4659
  table = (accumulator[TOP_LEVEL_DEF_KEY] ||= {})
3954
- table[def_node.name] ||= def_node
4660
+ return if table.key?(def_node.name)
4661
+
4662
+ table[def_node.name] = def_node
4663
+ follow_slot_writer(tables, :contested_def_nodes, [TOP_LEVEL_DEF_KEY, def_node.name],
4664
+ tables.nil? || tables.certain)
3955
4665
  end
3956
4666
 
3957
4667
  # Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of `build_discovered_def_nodes`.
@@ -3963,9 +4673,43 @@ module Rigor
3963
4673
  # singleton defs (`def self.x` outside any class — `self` is `main`) are not recorded; they have no constant
3964
4674
  # receiver to dispatch through.
3965
4675
  def build_discovered_singleton_def_nodes(root)
3966
- accumulator = {}
4676
+ build_singleton_def_tables(root).first
4677
+ end
4678
+
4679
+ # ADR-119 WD3 — {#build_discovered_singleton_def_nodes} with its sibling: `[table,
4680
+ # contested_discovered_singleton_def_nodes]`, the second a frozen Set of `[class, name]` keys whose slot a
4681
+ # possible `def self.x`, `class << self` def or `module_function` copy wrote last ({Certainty} `certainty`).
4682
+ def build_singleton_def_tables(root, certainty: Certainty.possible_nodes(root))
4683
+ accumulator = SingletonDefTable.new.track(certainty)
3967
4684
  walk_singleton_def_nodes(root, [], false, accumulator)
3968
- accumulator.transform_values(&:freeze).freeze
4685
+ [accumulator.transform_values(&:freeze).freeze, accumulator.contested_paths]
4686
+ end
4687
+
4688
+ # The singleton walk's accumulator: the `{class => {name => DefNode}}` table, plus the contested slots, which
4689
+ # follow each slot's writer ({#follow_slot_writer}'s rule).
4690
+ class SingletonDefTable < Hash
4691
+ def track(certainty)
4692
+ @certainty = certainty
4693
+ @contested = nil
4694
+ self
4695
+ end
4696
+
4697
+ # Writes the slot; `node` and `other` are the contributions the write rests on, possible when either is.
4698
+ def write(class_name, name, def_node, node, other = nil)
4699
+ (self[class_name] ||= {})[name] = def_node
4700
+ if Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4701
+ (@contested ||= Set.new) << [class_name, name]
4702
+ else
4703
+ @contested&.delete([class_name, name])
4704
+ end
4705
+ end
4706
+
4707
+ def contested_paths
4708
+ contested = @contested
4709
+ return contested.each(&:freeze).freeze if contested && !contested.empty?
4710
+
4711
+ Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_singleton_def_nodes]
4712
+ end
3969
4713
  end
3970
4714
 
3971
4715
  # Issue #1097 — `[[start_offset, end_offset, name, kind, owner], ...]` for every `def` / block /
@@ -4004,12 +4748,14 @@ module Rigor
4004
4748
  defs_singleton: false)
4005
4749
  return unless node.is_a?(Prism::Node)
4006
4750
 
4007
- if CLASS_BODY_NODES.any? { |kind| node.is_a?(kind) }
4751
+ # Tested by exact class (Prism's node classes have no subclasses): the three `any?` scans this
4752
+ # replaced ran a block per list member at every node ([#1507](https://github.com/rigortype/rigor/issues/1507)).
4753
+ if CLASS_BODY_NODES.include?(node.class)
4008
4754
  return walk_deferred_lexical_body(node, qualified_prefix, in_singleton_class,
4009
4755
  inside_deferred, mf_offsets, ranges, def_owner_prefix,
4010
4756
  singleton_cref: singleton_cref)
4011
4757
  end
4012
- if META_WRITE_NODES.any? { |kind| node.is_a?(kind) }
4758
+ if META_WRITE_NODES.include?(node.class)
4013
4759
  return walk_deferred_meta_new(node, qualified_prefix, in_singleton_class, inside_deferred,
4014
4760
  mf_offsets, ranges, def_owner_prefix,
4015
4761
  singleton_cref: singleton_cref)
@@ -4019,7 +4765,7 @@ module Rigor
4019
4765
  mf_offsets, ranges, def_owner_prefix, singleton_cref,
4020
4766
  defs_singleton)
4021
4767
  end
4022
- if DEFERRED_RANGE_NODES.any? { |kind| node.is_a?(kind) }
4768
+ if DEFERRED_RANGE_NODES.include?(node.class)
4023
4769
  # Deferred: a call inside runs at invocation / interpreter-exit time. `BEGIN`
4024
4770
  # (PreExecutionNode) is the opposite — eager, before the class body — and stays unlisted.
4025
4771
  ranges << [node.location.start_offset, node.location.end_offset, nil, nil, nil]
@@ -4052,10 +4798,10 @@ module Rigor
4052
4798
  defs_singleton: defs_singleton)
4053
4799
  end
4054
4800
 
4055
- DEFERRED_RANGE_NODES = [Prism::BlockNode, Prism::LambdaNode, Prism::PostExecutionNode].freeze
4056
- META_WRITE_NODES = [Prism::ConstantWriteNode, Prism::ConstantPathWriteNode,
4057
- Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode].freeze
4058
- CLASS_BODY_NODES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
4801
+ DEFERRED_RANGE_NODES = Set[Prism::BlockNode, Prism::LambdaNode, Prism::PostExecutionNode].freeze
4802
+ META_WRITE_NODES = Set[Prism::ConstantWriteNode, Prism::ConstantPathWriteNode,
4803
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode].freeze
4804
+ CLASS_BODY_NODES = Set[Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
4059
4805
  private_constant :DEFERRED_RANGE_NODES, :META_WRITE_NODES, :CLASS_BODY_NODES
4060
4806
 
4061
4807
  def walk_deferred_children(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists
@@ -4156,7 +4902,7 @@ module Rigor
4156
4902
  end
4157
4903
 
4158
4904
  # Body-level entry for a class / module / `class <<` / meta-`new` / eval-block body: prescans
4159
- # the body's `module_function` state first ({#collect_module_function_state} — bare-call
4905
+ # the body's `module_function` state first ({ModuleFunctionState.prescan_deferred} — bare-call
4160
4906
  # offsets plus the retro-install and `module_function def x` rows), then walks each statement
4161
4907
  # with the offsets in hand. The prescan looks through nested containers (`if` / `begin` /
4162
4908
  # `rescue` / blocks) because a `module_function` that RAN there still flips later defs —
@@ -4173,8 +4919,8 @@ module Rigor
4173
4919
  # `module_function` under an unnameable definee (`class <<` + `instance_eval`)
4174
4920
  # toggles the metaclass — its rows must not name the enclosing class.
4175
4921
  prescan_owner = defs_singleton == :unnameable ? EMPTY_PREFIX : owner_prefix
4176
- collect_module_function_state(body, prescan_owner, in_singleton_class, mf_offsets,
4177
- ranges)
4922
+ ModuleFunctionState.prescan_deferred(body, prescan_owner, in_singleton_class, mf_offsets,
4923
+ ranges)
4178
4924
  end
4179
4925
  statements_of(body).each do |stmt|
4180
4926
  walk_deferred_ranges(stmt, qualified_prefix, in_singleton_class, inside_deferred,
@@ -4183,49 +4929,6 @@ module Rigor
4183
4929
  end
4184
4930
  end
4185
4931
 
4186
- # The `module_function` prescan over one body's subtree: bare-call offsets (defs starting after
4187
- # one are module functions), a `:singleton` row at each `module_function :name` call — the
4188
- # retro-install happens AT THE CALL, not the def — and a `:both` / `:singleton` row for a
4189
- # `module_function def x` argument. Nested class / module / `class <<` / def bodies are skipped:
4190
- # `module_function` there targets a different module. An `END` body is skipped too — it runs at
4191
- # interpreter exit, after every def — and so is a `*_eval` / `*_exec` block, whose `self`
4192
- # rebinds to the receiver's module. Blocks, lambdas and control-flow containers are entered —
4193
- # their `self` is still this module, so the call really can toggle.
4194
- def collect_module_function_state(node, qualified_prefix, in_singleton_class, mf_offsets, ranges)
4195
- return unless node.is_a?(Prism::Node)
4196
- return if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
4197
- node.is_a?(Prism::SingletonClassNode) || node.is_a?(Prism::DefNode) ||
4198
- node.is_a?(Prism::PostExecutionNode)
4199
-
4200
- if node.is_a?(Prism::CallNode)
4201
- if module_function_toggle?(node)
4202
- return collect_module_function_call(node, qualified_prefix, in_singleton_class,
4203
- mf_offsets, ranges)
4204
- end
4205
- if receiver_eval_call?(node)
4206
- return collect_eval_call_state(node, qualified_prefix, in_singleton_class, mf_offsets,
4207
- ranges)
4208
- end
4209
- end
4210
-
4211
- node.rigor_each_child do |child|
4212
- collect_module_function_state(child, qualified_prefix, in_singleton_class, mf_offsets, ranges)
4213
- end
4214
- end
4215
-
4216
- # The prescan's eval arm: a `*_eval` / `*_exec` block's self rebinds to the receiver's module,
4217
- # so only the call's receiver and arguments keep this module's `module_function` context.
4218
- def collect_eval_call_state(node, qualified_prefix, in_singleton_class, mf_offsets, ranges)
4219
- if node.receiver
4220
- collect_module_function_state(node.receiver, qualified_prefix, in_singleton_class,
4221
- mf_offsets, ranges)
4222
- end
4223
- node.arguments&.arguments&.each do |arg|
4224
- collect_module_function_state(arg, qualified_prefix, in_singleton_class, mf_offsets,
4225
- ranges)
4226
- end
4227
- end
4228
-
4229
4932
  # The wider form: any call whose block rebinds `self` to the receiver — `instance_eval` and
4230
4933
  # `instance_exec` included. Walks that read `self`-anchored facts use this gate; walks that
4231
4934
  # own `def` leaves additionally split on {INSTANCE_EVAL_CALLS}, whose `def`s bind on the
@@ -4234,29 +4937,6 @@ module Rigor
4234
4937
  node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
4235
4938
  end
4236
4939
 
4237
- def collect_module_function_call(node, qualified_prefix, in_singleton_class, mf_offsets, ranges)
4238
- if bare_module_function?(node)
4239
- mf_offsets << node.location.start_offset
4240
- else
4241
- owner = qualified_prefix.empty? ? nil : qualified_prefix.join("::")
4242
- node.arguments&.arguments&.each do |arg|
4243
- if arg.is_a?(Prism::DefNode)
4244
- kind = def_singleton?(arg, qualified_prefix, in_singleton_class) ? :singleton : :both
4245
- ranges << [arg.location.start_offset, arg.location.end_offset, arg.name, kind, owner]
4246
- elsif (name = symbol_argument_name(arg))
4247
- ranges << [node.location.start_offset, node.location.end_offset, name, :singleton, owner]
4248
- else
4249
- collect_module_function_state(arg, qualified_prefix, in_singleton_class, mf_offsets,
4250
- ranges)
4251
- end
4252
- end
4253
- end
4254
- return unless node.block
4255
-
4256
- collect_module_function_state(node.block, qualified_prefix, in_singleton_class, mf_offsets,
4257
- ranges)
4258
- end
4259
-
4260
4940
  # A `class_eval` / `module_eval` / `class_exec` / `module_exec` block is NOT deferred: it runs
4261
4941
  # eagerly during the receiver's call, as a class-ish body — its `module_function` state gets
4262
4942
  # its own prescan, and its defs belong to the RECEIVER's surface. A nameable receiver gives
@@ -4620,7 +5300,7 @@ module Rigor
4620
5300
 
4621
5301
  kind = if def_singleton?(def_node, qualified_prefix, in_singleton_class)
4622
5302
  :singleton
4623
- elsif mf_offsets.any? { |offset| offset < start }
5303
+ elsif ModuleFunctionState.deferred_module_function?(mf_offsets, start)
4624
5304
  :both
4625
5305
  else
4626
5306
  :instance
@@ -4773,12 +5453,27 @@ module Rigor
4773
5453
  # CompactBase` calls it, and the bare `Post` inside `make` names `::Post` — the constant the compact
4774
5454
  # declaration that OWNS the body reaches, not the one the receiver's spelling would suggest.
4775
5455
  #
4776
- # A separate descent rather than a leaf of the fused methods/def-nodes walk: it needs the CHAIN
4777
- # threaded, which the fused walk does not carry (a singleton-class body and a `Class.new` block body
4778
- # both push a qualified prefix while pushing no `Module.nesting` entry), and threading a second value
4779
- # through that walk and its anonymous-block twin exceeds their parameter budget. It stops at every
4780
- # `Prism::DefNode`, so a def-dense file pays only the declaration spine.
5456
+ # Not a leaf of the fused methods/def-nodes walk: it needs the CHAIN threaded, which the fused walk does
5457
+ # not carry (a singleton-class body and a `Class.new` block body both push a qualified prefix while
5458
+ # pushing no `Module.nesting` entry). {DefNestingsCollector} declines at every `Prism::DefNode`, so a
5459
+ # def-dense file pays only the declaration spine.
5460
+ #
5461
+ # ADR-116 WD5 — production builds this table in {#declaration_walk_tables}' shared run, and nothing in
5462
+ # `lib` calls this builder. It runs the collector alone and stays for the specs, which compare it with
5463
+ # the shared run; `RIGOR_SHADOW_RULE_WALK` checks it against {#legacy_def_nestings}.
4781
5464
  def build_def_nestings(root)
5465
+ collector = DefNestingsCollector.new
5466
+ DeclarationWalk.run(root, [collector], superclass_walk_root(nil))
5467
+ verified_def_nestings(root, collector.table)
5468
+ end
5469
+
5470
+ def verified_def_nestings(root, table, source_path = nil)
5471
+ DeclarationWalk::Shadow.verified(:def_nestings, source_path, table) { legacy_def_nestings(root) }
5472
+ end
5473
+
5474
+ # The walker {DefNestingsCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
5475
+ # ported every table walker onto {DeclarationWalk}.
5476
+ def legacy_def_nestings(root)
4782
5477
  accumulator = {}.compare_by_identity
4783
5478
  walk_def_nestings(root, EMPTY_NESTING, accumulator)
4784
5479
  accumulator.freeze
@@ -4904,24 +5599,21 @@ module Rigor
4904
5599
  end
4905
5600
 
4906
5601
  # Walks a class/module/singleton-class body's direct statements in source order, threading the
4907
- # bare-`module_function` toggle: once a bare `module_function` is seen, every subsequent `def` in the body
4908
- # registers as a singleton method. Nested classes/modules/defs and `module_function :a, :b` named forms recurse /
4909
- # record through the general walker so the toggle stays scoped to its own body.
5602
+ # bare-`module_function` toggle ({ModuleFunctionState.each_singleton_sibling}): once a bare
5603
+ # `module_function` is seen, every subsequent `def` in the body registers as a singleton method. Nested
5604
+ # classes/modules/defs recurse through the general walker so the toggle stays scoped to its own body, and a
5605
+ # `module_function :a, :b` named form records through {#record_module_function_names}.
4910
5606
  def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator,
4911
5607
  def_owner_prefix = nil, singleton_cref: false,
4912
5608
  defs_singleton: false)
4913
5609
  owner_prefix = def_owner_prefix || qualified_prefix
4914
- module_function_on = false
4915
- statements_of(body).each do |stmt|
4916
- if stmt.is_a?(Prism::CallNode) && module_function_toggle?(stmt)
4917
- if bare_module_function?(stmt)
4918
- module_function_on = true
4919
- else
4920
- # `:unnameable` — the named defs the call copies live on the metaclass,
4921
- # which this table cannot name either.
4922
- record_module_function_names(stmt, owner_prefix, body, accumulator) unless
4923
- defs_singleton == :unnameable
4924
- end
5610
+ statements = statements_of(body)
5611
+ ModuleFunctionState.each_singleton_sibling(statements) do |stmt, module_function_on, named_call|
5612
+ if named_call
5613
+ # `:unnameable` — the named defs the call copies live on the metaclass,
5614
+ # which this table cannot name either.
5615
+ record_module_function_names(stmt, owner_prefix, statements, accumulator) unless
5616
+ defs_singleton == :unnameable
4925
5617
  next
4926
5618
  end
4927
5619
  if stmt.is_a?(Prism::DefNode)
@@ -4972,42 +5664,30 @@ module Rigor
4972
5664
  return if qualified_prefix.empty?
4973
5665
 
4974
5666
  class_name = qualified_prefix.join("::")
4975
- (accumulator[class_name] ||= {})[def_node.name] = def_node
4976
- end
4977
-
4978
- # A bare `module_function` (no arguments) flips every following `def` in the module body to module-function
4979
- # (instance + singleton) mode.
4980
- def module_function_toggle?(node)
4981
- node.name == :module_function && node.receiver.nil?
5667
+ write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node)
4982
5668
  end
4983
5669
 
4984
- def bare_module_function?(node)
4985
- node.arguments.nil? || node.arguments.arguments.empty?
5670
+ # One singleton slot write, through {SingletonDefTable#write} when the walk tracks certainty.
5671
+ def write_singleton_slot(accumulator, class_name, name, def_node, node, other = nil)
5672
+ if accumulator.is_a?(SingletonDefTable)
5673
+ accumulator.write(class_name, name, def_node, node, other)
5674
+ else
5675
+ (accumulator[class_name] ||= {})[name] = def_node
5676
+ end
4986
5677
  end
4987
5678
 
4988
- # `module_function :a, :b` retro-marks named siblings (defined earlier OR later in the same body) as
4989
- # module-functions. Resolves each symbol-literal argument against the body's own `def`s and registers the matching
4990
- # `DefNode` on the module's singleton side. Non-symbol arguments and names with no matching `def` are skipped (a
4991
- # miss degrades to today's `Dynamic`, never a false resolution).
4992
- def record_module_function_names(node, qualified_prefix, body, accumulator)
5679
+ # `module_function :a, :b` retro-marks named siblings as module-functions: registers on the module's singleton
5680
+ # side each `DefNode` {ModuleFunctionState.each_singleton_copy} resolves among the body's direct `statements`.
5681
+ # Non-symbol arguments and names with no matching `def` are skipped (a miss degrades to today's `Dynamic`).
5682
+ def record_module_function_names(node, qualified_prefix, statements, accumulator)
4993
5683
  return if qualified_prefix.empty?
4994
5684
 
4995
- defs_by_name = statements_of(body).each_with_object({}) do |stmt, acc|
4996
- acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
4997
- end
4998
5685
  class_name = qualified_prefix.join("::")
4999
- node.arguments&.arguments&.each do |arg|
5000
- name = symbol_argument_name(arg)
5001
- def_node = name && defs_by_name[name]
5002
- (accumulator[class_name] ||= {})[name] = def_node if def_node
5686
+ ModuleFunctionState.each_singleton_copy(node, statements) do |name, def_node|
5687
+ write_singleton_slot(accumulator, class_name, name, def_node, node, def_node)
5003
5688
  end
5004
5689
  end
5005
5690
 
5006
- # The Symbol value of a `:name` / `"name"` literal argument, or nil.
5007
- def symbol_argument_name(arg)
5008
- arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
5009
- end
5010
-
5011
5691
  # ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the
5012
5692
  # `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
5013
5693
  # non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
@@ -5021,13 +5701,57 @@ module Rigor
5021
5701
  # the enclosing cref — `[]` for the first spelling, `["Admin"]` for the second. A class reopened under two
5022
5702
  # spellings keeps the LAST one walked, matching how `superclasses` itself merges.
5023
5703
  #
5704
+ # ADR-116 WD5 — built by {SuperclassesCollector} on the shared {DeclarationWalk}, following the two
5705
+ # legacy variants it declares; `RIGOR_SHADOW_RULE_WALK` checks both tables against
5706
+ # {#legacy_superclass_tables}. Production builds both in {#declaration_walk_tables}' shared run, and
5707
+ # nothing in `lib` calls this builder: it runs the collector alone and stays for the specs, which
5708
+ # compare it with the shared run.
5709
+ #
5024
5710
  # @return the `[superclasses, header_nestings]` pair
5025
5711
  def build_superclass_tables(root, source_path = nil)
5026
- accumulator = { superclasses: {}, header_nestings: {} }
5712
+ collector = SuperclassesCollector.new
5713
+ DeclarationWalk.run(root, [collector], superclass_walk_root(source_path))
5714
+ verified_superclass_tables(root, source_path, collector.tables)
5715
+ end
5716
+
5717
+ # The root context the superclass tables are walked from: the ancestry nesting starts empty and the
5718
+ # anonymous-class key carries the file's path.
5719
+ def superclass_walk_root(source_path, scope: nil)
5720
+ DeclarationWalk::Context.root(scope: scope, nesting: EMPTY_NESTING, source_path: source_path)
5721
+ end
5722
+
5723
+ # `tables` once the shadow harness has compared them with the legacy walker's, when it is on.
5724
+ def verified_superclass_tables(root, source_path, tables)
5725
+ DeclarationWalk::Shadow.verified(:superclass_tables, source_path, tables) do
5726
+ legacy_superclass_tables(root, source_path)
5727
+ end
5728
+ end
5729
+
5730
+ # The walker {SuperclassesCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
5731
+ # ported every table walker onto {DeclarationWalk}.
5732
+ def legacy_superclass_tables(root, source_path = nil)
5733
+ accumulator = { superclasses: {}, header_nestings: {} }
5027
5734
  walk_class_superclasses(root, [], accumulator, source_path)
5028
5735
  [accumulator[:superclasses].freeze, accumulator[:header_nestings].freeze]
5029
5736
  end
5030
5737
 
5738
+ # ADR-116 WD5 — the file's superclass, header-nesting, def-nesting and member-layout tables from ONE
5739
+ # shared {DeclarationWalk}: the file is descended once for all of them, where the legacy walkers
5740
+ # descended it once per table. `RIGOR_SHADOW_RULE_WALK` checks each table against its legacy walker.
5741
+ #
5742
+ # @return `{ superclasses:, header_nestings:, def_nestings:, data_member_layouts:, struct_member_layouts: }`
5743
+ def declaration_walk_tables(root, source_path = nil)
5744
+ superclasses = SuperclassesCollector.new
5745
+ layouts = MemberLayoutsCollector.new
5746
+ def_nestings = DefNestingsCollector.new
5747
+ DeclarationWalk.run(root, [superclasses, layouts, def_nestings], superclass_walk_root(source_path))
5748
+ supers, header_nestings = verified_superclass_tables(root, source_path, superclasses.tables)
5749
+ data, struct = verified_member_layouts(root, layouts.tables, source_path)
5750
+ { superclasses: supers, header_nestings: header_nestings,
5751
+ def_nestings: verified_def_nestings(root, def_nestings.table, source_path),
5752
+ data_member_layouts: data, struct_member_layouts: struct }
5753
+ end
5754
+
5031
5755
  # `self_base` names a rebound `self` for `self::`-anchored headers — a meta-new
5032
5756
  # block's class; `[]` marks a self no name covers.
5033
5757
  def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil,
@@ -5342,7 +6066,31 @@ module Rigor
5342
6066
  # `Data.define` is recorded: `Struct.new` instances are mutable, so member-value folding would be unsound (the
5343
6067
  # Struct follow-up is deferred — see ADR-48 § "Struct follow-up"). Consumed by
5344
6068
  # {Inference::MethodDispatcher::DataFolding} via {Scope#data_member_layout}.
6069
+ #
6070
+ # ADR-116 WD5 — built with its Struct sibling by {MemberLayoutsCollector}. Production builds both in
6071
+ # {#declaration_walk_tables}' shared run, and nothing in `lib` calls this builder: it stays for the specs,
6072
+ # and walks both layout tables to return this one.
5345
6073
  def build_data_member_layouts(root)
6074
+ member_layout_tables(root).first
6075
+ end
6076
+
6077
+ # `[data_member_layouts, struct_member_layouts]` from a {MemberLayoutsCollector} run of its own, checked by
6078
+ # `RIGOR_SHADOW_RULE_WALK` against the two legacy walkers. Spec-only, like the two builders on it.
6079
+ def member_layout_tables(root)
6080
+ collector = MemberLayoutsCollector.new
6081
+ DeclarationWalk.run(root, [collector], superclass_walk_root(nil))
6082
+ verified_member_layouts(root, collector.tables)
6083
+ end
6084
+
6085
+ def verified_member_layouts(root, tables, source_path = nil)
6086
+ DeclarationWalk::Shadow.verified(:member_layouts, source_path, tables) do
6087
+ [legacy_data_member_layouts(root), legacy_struct_member_layouts(root)]
6088
+ end
6089
+ end
6090
+
6091
+ # The walker {MemberLayoutsCollector} replaced for the `Data.define` table, kept as the shadow harness's
6092
+ # oracle until ADR-116 WD5 has ported every table walker onto {DeclarationWalk}.
6093
+ def legacy_data_member_layouts(root)
5346
6094
  accumulator = {}
5347
6095
  walk_data_member_layouts(root, [], accumulator)
5348
6096
  accumulator.freeze
@@ -5445,7 +6193,16 @@ module Rigor
5445
6193
  # table so the existing `Data.define` value-shape contract (a bare `[Symbol]`) is untouched: a Struct entry
5446
6194
  # carries `{ members:, keyword_init: }` because the dispatcher needs the flag to fold the matching `.new` call
5447
6195
  # form (positional vs keyword) without manufacturing a wrong map.
6196
+ #
6197
+ # ADR-116 WD5 — production builds it in {#declaration_walk_tables}' shared run; this builder is spec-only,
6198
+ # like {#build_data_member_layouts}.
5448
6199
  def build_struct_member_layouts(root)
6200
+ member_layout_tables(root).last
6201
+ end
6202
+
6203
+ # The walker {MemberLayoutsCollector} replaced for the `Struct.new` table, kept as the shadow harness's
6204
+ # oracle until ADR-116 WD5 has ported every table walker onto {DeclarationWalk}.
6205
+ def legacy_struct_member_layouts(root)
5449
6206
  accumulator = {}
5450
6207
  walk_struct_member_layouts(root, [], accumulator)
5451
6208
  accumulator.freeze
@@ -5589,7 +6346,7 @@ module Rigor
5589
6346
  # stored in instance-ancestor SEARCH order: a later `prepend` statement puts its module NEARER than
5590
6347
  # an earlier one (`prepend A; prepend B` searches B before A) while the arguments of ONE
5591
6348
  # `prepend A, B` keep call order — exactly the convention {#record_extend_targets} documents for
5592
- # `extend`, and the order `Scope#user_def_through_ancestors`'s prepend wedge consumes. Ruby inserts a
6349
+ # `extend`, and the order `Scope::ResolutionChain` reads a body's prepends in. Ruby inserts a
5593
6350
  # prepended module, and its own ancestry, immediately BEFORE the class that prepends it, so these
5594
6351
  # names outrank the class's own `def`s; without the kind recorded separately from {#build_discovered_includes}
5595
6352
  # there was nothing to order on and `prepend` was silently an `include`.
@@ -5599,13 +6356,17 @@ module Rigor
5599
6356
 
5600
6357
  # One descent, both instance-side mixin tables: the walk classifies each mixin call it sees, so the
5601
6358
  # two tables cost one walk rather than two. Each value is frozen and de-duplicated per class, as the
5602
- # single-table builder always did.
6359
+ # single-table builder always did. `:unpositioned` names the recorded edges whose order is not a fact
6360
+ # ({MixinAccumulator}).
5603
6361
  def mixin_tables(root)
5604
- accumulator = {}
6362
+ accumulator = MixinAccumulator.new
6363
+ accumulator.direct_body(program_statements(root))
6364
+ accumulator.program_body(program_statements(root))
5605
6365
  walk_class_includes(root, [], nil, accumulator)
5606
6366
  {
5607
6367
  includes: freeze_mixin_lists(accumulator, :include),
5608
- prepends: freeze_mixin_lists(accumulator, :prepend)
6368
+ prepends: freeze_mixin_lists(accumulator, :prepend),
6369
+ unpositioned: accumulator.unpositioned
5609
6370
  }
5610
6371
  end
5611
6372
 
@@ -5620,7 +6381,10 @@ module Rigor
5620
6381
  # already store nearest-first (see {#write_mixin_targets}), so the concat preserves MRO.
5621
6382
  def freeze_mixin_lists(accumulator, kind)
5622
6383
  accumulator.each_with_object({}) do |(class_name, kinds), out|
5623
- names = mixin_names_for(kinds, kind).uniq.freeze
6384
+ names = mixin_names_for(kinds, kind)
6385
+ # The instance-side projection is a SET in search order; the prepend table keeps every statement.
6386
+ names = names.uniq if kind == :include
6387
+ names = names.freeze
5624
6388
  out[class_name] = names unless names.empty?
5625
6389
  end.freeze
5626
6390
  end
@@ -5635,6 +6399,12 @@ module Rigor
5635
6399
  end
5636
6400
  end
5637
6401
 
6402
+ # The top-level statements of a parsed file: the one body whose declarations need no enclosing
6403
+ # declaration to be direct ({MixinAccumulator}).
6404
+ def program_statements(root)
6405
+ root.is_a?(Prism::ProgramNode) ? root.statements : root
6406
+ end
6407
+
5638
6408
  def walk_class_includes(node, qualified_prefix, current_class, accumulator,
5639
6409
  singleton_self: false, singleton_cref: false)
5640
6410
  return unless node.is_a?(Prism::Node)
@@ -5665,6 +6435,25 @@ module Rigor
5665
6435
  singleton_self, singleton_cref)
5666
6436
  end
5667
6437
 
6438
+ walk_includes_children(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
6439
+ end
6440
+
6441
+ # The generic descent of {#walk_class_includes}; a `self.included(base)`-style hook's body walks with
6442
+ # its parameters in scope ({MixinAccumulator#with_hook_params}).
6443
+ def walk_includes_children(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
6444
+ hook_params = hook_def_params(node)
6445
+ if hook_params
6446
+ return accumulator.with_hook_params(hook_params) do
6447
+ walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self,
6448
+ singleton_cref)
6449
+ end
6450
+ end
6451
+
6452
+ walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
6453
+ end
6454
+
6455
+ def walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self,
6456
+ singleton_cref)
5668
6457
  node.rigor_each_child do |child|
5669
6458
  walk_class_includes(child, qualified_prefix, current_class, accumulator,
5670
6459
  singleton_self: singleton_self, singleton_cref: singleton_cref)
@@ -5682,6 +6471,7 @@ module Rigor
5682
6471
  return true unless node.body
5683
6472
 
5684
6473
  _self_decl, child_prefix, child_cref = ctx
6474
+ accumulator.direct_body(node.body) if accumulator.direct?(node)
5685
6475
  walk_class_includes(node.body, child_cref ? [] : child_prefix,
5686
6476
  child_cref ? nil : child_prefix.join("::"), accumulator,
5687
6477
  singleton_cref: child_cref)
@@ -5726,7 +6516,7 @@ module Rigor
5726
6516
  walk_class_includes(arg, qualified_prefix, current_class, accumulator,
5727
6517
  singleton_self: singleton_self, singleton_cref: singleton_cref)
5728
6518
  end
5729
- if (body = call.block&.body)
6519
+ if (body = meta_new_block_body(node))
5730
6520
  walk_class_includes(body, qualified_prefix, meta_owner, accumulator,
5731
6521
  singleton_cref: body_cref)
5732
6522
  end
@@ -5752,7 +6542,7 @@ module Rigor
5752
6542
  # names, so it declines rather than re-anchoring to the lexical class.
5753
6543
  unnameable_self = singleton_self || (singleton_cref && current_class.nil?)
5754
6544
  rebound = rebound_block_self(node, qualified_prefix, nil, nil,
5755
- rebound_self_base(unnameable_self ? OPAQUE_SELF : current_class))
6545
+ unnameable_self ? OPAQUE_SELF : current_class)
5756
6546
  node.rigor_each_child do |child|
5757
6547
  rebinds_self = rebound && child.is_a?(Prism::BlockNode)
5758
6548
  owner =
@@ -5776,15 +6566,151 @@ module Rigor
5776
6566
  # it or the set-shaped consumers (arity, visibility, undefined-method suppression) would answer
5777
6567
  # differently for the two spellings of one edge.
5778
6568
  def record_mixin_call(node, qualified_prefix, current_class, accumulator)
5779
- return unless mixin_call_recorded?(node, current_class)
6569
+ taint_opaque_eval(node, current_class, accumulator)
6570
+ kind, _arguments, via_send = mixin_call_view(node)
6571
+ return if kind.nil?
6572
+
6573
+ current_class ||= toplevel_include_owner(node, qualified_prefix, current_class, accumulator)
6574
+ effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: false)
6575
+ .select { |_owner, side| side == :include }
6576
+ return if effects.empty?
6577
+ return if !via_send && instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6578
+
6579
+ # A mixin call the tables cannot hold still reshapes the ancestry: taint the whole owner side.
6580
+ effects.each { |owner, side| accumulator.taint(owner, side) }
6581
+ end
5780
6582
 
5781
- targets = node.arguments&.arguments&.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
5782
- return if targets.nil? || targets.empty?
6583
+ # Records the instance-side edges of a call {#mixin_call_recorded?} accepts. False when it recorded
6584
+ # nothing — the caller then taints the owner instead.
6585
+ def instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6586
+ return false unless mixin_call_recorded?(node, current_class)
6587
+
6588
+ arguments = node.arguments&.arguments || []
6589
+ targets = arguments.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
6590
+ return false if targets.empty?
5783
6591
 
5784
6592
  owner = node.receiver.nil? ? current_class : prepend_call_receiver(node, qualified_prefix)
5785
- return if owner.nil?
6593
+ return false if owner.nil?
5786
6594
 
5787
6595
  write_mixin_targets(accumulator, owner, targets, prepend: node.name == :prepend)
6596
+ accumulator.note(node, owner, :include, targets, complete: targets.size == arguments.size)
6597
+ true
6598
+ end
6599
+
6600
+ MIXIN_KINDS = %i[include prepend extend].freeze
6601
+ SEND_CALL_NAMES = %i[send public_send __send__].freeze
6602
+ private_constant :MIXIN_KINDS, :SEND_CALL_NAMES
6603
+
6604
+ # `[kind, arguments, via_send]` for a call that mixes modules in — `include` / `prepend` / `extend`, or
6605
+ # `send(:include, M)` and its `public_send` / `__send__` twins with a literal first argument — else nil.
6606
+ # A computed `send` name is not read as a mixin: it names nothing this walk could taint by.
6607
+ def mixin_call_view(node)
6608
+ return [node.name, node.arguments&.arguments || [], false] if MIXIN_KINDS.include?(node.name)
6609
+ return unless SEND_CALL_NAMES.include?(node.name)
6610
+
6611
+ arguments = node.arguments&.arguments || []
6612
+ first = arguments.first
6613
+ return unless first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
6614
+
6615
+ # By string: a name literal need not be a valid symbol in its encoding.
6616
+ kind = MIXIN_KINDS.find { |candidate| candidate.name == first.unescaped }
6617
+ [kind, arguments.drop(1), true] if kind
6618
+ end
6619
+
6620
+ # `[[owner, side], …]` — the ancestry sides a mixin call reshapes. `self` and the implicit receiver
6621
+ # are the enclosing class (`:include` for `include` / `prepend`, `:extend` for `extend`, and both
6622
+ # flip to `:extend` inside `class << self`); `singleton_class` is the singleton side; a constant is
6623
+ # that class; any other receiver is unknown, so it may be the class that includes this one from a
6624
+ # hook (`base.include M`) and taints both sides of the enclosing class.
6625
+ def mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton:)
6626
+ receiver = node.receiver
6627
+ side = kind == :extend ? :extend : :include
6628
+ pairs =
6629
+ if receiver.nil? || receiver.is_a?(Prism::SelfNode)
6630
+ own_mixin_effects(kind, side, current_class, in_singleton)
6631
+ else
6632
+ receiver_mixin_effects(node, side, qualified_prefix, current_class, accumulator)
6633
+ end
6634
+ pairs.reject { |owner, _side| owner.nil? }
6635
+ end
6636
+
6637
+ # The implicit-receiver / `self` arm of {#mixin_effects}: inside `class << self`, `include` / `prepend`
6638
+ # are singleton-side and `extend` reaches the singleton's own singleton, which nothing names.
6639
+ def own_mixin_effects(kind, side, current_class, in_singleton)
6640
+ return [] if in_singleton && kind == :extend
6641
+
6642
+ [[current_class, in_singleton ? :extend : side]]
6643
+ end
6644
+
6645
+ # A receiver that is not `self` reshapes the class it names, or the singleton of `singleton_class`. Any
6646
+ # other receiver is an object of its own (`ids.prepend(1)`, `obj.extend(Decorator)`) and reshapes none of
6647
+ # the enclosing class's ancestry — except in a `self.included(base)`-style hook, where `base` IS the
6648
+ # class that includes the enclosing module and a call on it taints both sides of the enclosing class.
6649
+ def receiver_mixin_effects(node, side, qualified_prefix, current_class, accumulator)
6650
+ receiver = node.receiver
6651
+ return [[current_class, :extend]] if singleton_class_receiver?(receiver)
6652
+
6653
+ owner = prepend_call_receiver(node, qualified_prefix)
6654
+ return [[owner, side]] if owner
6655
+ return [] unless hook_receiver?(receiver, accumulator)
6656
+
6657
+ [[current_class, :include], [current_class, :extend]]
6658
+ end
6659
+
6660
+ HOOK_DEFS = %i[included extended prepended inherited].freeze
6661
+ MIXIN_EVAL_CALLS = %i[class_eval module_eval class_exec module_exec instance_eval instance_exec].freeze
6662
+ private_constant :HOOK_DEFS, :MIXIN_EVAL_CALLS
6663
+
6664
+ # The parameter names of a `def included(base)`-style hook (a hook is written `def self.included`, or
6665
+ # `def included` inside `class << self`), else nil.
6666
+ def hook_def_params(node)
6667
+ return nil unless node.is_a?(Prism::DefNode) && HOOK_DEFS.include?(node.name)
6668
+
6669
+ parameters = node.parameters
6670
+ return [] unless parameters
6671
+
6672
+ (parameters.requireds + parameters.optionals).filter_map do |parameter|
6673
+ parameter.name if parameter.respond_to?(:name)
6674
+ end
6675
+ end
6676
+
6677
+ # Whether `receiver` is a hook parameter, or `singleton_class` / a no-argument call chain off one.
6678
+ def hook_receiver?(receiver, accumulator)
6679
+ case receiver
6680
+ when Prism::LocalVariableReadNode then accumulator.hook_local?(receiver.name)
6681
+ when Prism::CallNode
6682
+ receiver.arguments.nil? && receiver.receiver && hook_receiver?(receiver.receiver, accumulator)
6683
+ else false
6684
+ end
6685
+ end
6686
+
6687
+ # `base.class_eval { include X }` in a hook, and `opaque.module_eval { include B }` anywhere: the block
6688
+ # opens a class this walk cannot name, so its mixin calls record nowhere, yet the enclosing class's
6689
+ # ancestry is what the reader was asked about. Taint both sides of the enclosing class. Outside a hook
6690
+ # the block is scanned for a mixin call first, so an ordinary `obj.instance_eval { … }` DSL taints nothing.
6691
+ def taint_opaque_eval(node, current_class, accumulator)
6692
+ return unless current_class && node.block.is_a?(Prism::BlockNode) && MIXIN_EVAL_CALLS.include?(node.name)
6693
+
6694
+ receiver = node.receiver
6695
+ return if receiver.nil? || receiver.is_a?(Prism::SelfNode)
6696
+ return if Source::ConstantPath.qualified_name_or_nil(receiver)
6697
+ return unless hook_receiver?(receiver, accumulator) || block_mixes_in?(node.block)
6698
+
6699
+ accumulator.taint(current_class, :include)
6700
+ accumulator.taint(current_class, :extend)
6701
+ end
6702
+
6703
+ def block_mixes_in?(node)
6704
+ return true if node.is_a?(Prism::CallNode) && (MIXIN_KINDS.include?(node.name) || mixin_call_view(node))
6705
+
6706
+ found = false
6707
+ node.rigor_each_child { |child| found ||= block_mixes_in?(child) }
6708
+ found
6709
+ end
6710
+
6711
+ def singleton_class_receiver?(receiver)
6712
+ receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil? &&
6713
+ (receiver.receiver.nil? || receiver.receiver.is_a?(Prism::SelfNode))
5788
6714
  end
5789
6715
 
5790
6716
  # Issue #1123 — one class's contribution to the two tables. A prepended module lands in BOTH: the
@@ -5802,12 +6728,15 @@ module Rigor
5802
6728
  # freeze time — ahead of every include, where Ruby puts them.
5803
6729
  def write_mixin_targets(accumulator, owner, targets, prepend:)
5804
6730
  bucket = accumulator[owner] ||= {}
5805
- if prepend
5806
- (bucket[:prepend] ||= []).unshift(*targets)
5807
- else
5808
- list = (bucket[:include] ||= [])
5809
- list.unshift(*targets.reject { |target| list.include?(target) })
5810
- end
6731
+ kind, other = prepend ? %i[prepend include] : %i[include prepend]
6732
+ list = (bucket[kind] ||= [])
6733
+ # A target the OTHER kind already carries (`include M; prepend M`) is one body the two tables cannot
6734
+ # represent (Ruby's `[M, C, M]`), so the instance side is named unpositioned.
6735
+ accumulator.taint(owner, :include) if targets.any? { |target| bucket[other]&.include?(target) }
6736
+ # A repeated `include` keeps its first position (Ruby skips it). A repeated `prepend` is KEPT: the
6737
+ # prepend table holds every statement, nearest first, so the readers that walk the raw list see what
6738
+ # they always saw, and the resolution chain dedupes per owner, first statement winning.
6739
+ list.unshift(*(prepend ? targets : targets.reject { |target| list.include?(target) }))
5811
6740
  end
5812
6741
 
5813
6742
  # Whether a mixin call contributes to the tables at all: a receiverless `include` / `prepend` needs an
@@ -5822,6 +6751,25 @@ module Rigor
5822
6751
  node.name == :prepend
5823
6752
  end
5824
6753
 
6754
+ # Issue #1697 — a receiverless `include M` written as a statement of the file's own top level is
6755
+ # `main.include`, which is `Object.include`: it mixes M into `Object`. It is recorded under its own
6756
+ # owner key, `ObjectMixins::TOPLEVEL_INCLUDE_KEY`, rather than on `Object`, so it rides every table an include
6757
+ # rides (the declaration signature, `discovered_class_sources`, the seed bundle) while no ancestry
6758
+ # walk that types a call reaches it: what Ruby reaches first for a name such an include supplies
6759
+ # depends on more than the chain shows (a block that rebinds `self`, an `extend` or a singleton `def`
6760
+ # on `main`), so only the diagnostic silences read it (`Inference::ObjectMixins`), and typing is
6761
+ # issue #1715's. Only a direct program statement counts.
6762
+ # The same call inside a top-level block, a method body or a conditional stays unrecorded, as before:
6763
+ # its `self` may not be `main` (a `describe do include M end` or `class_exec` block rebinds it, while
6764
+ # `[1].each do include M end` does not, and the walk cannot tell them apart), and it may not run. `main`
6765
+ # has no `prepend`, and a top-level `extend M` reaches `main`'s singleton alone, so neither is recorded.
6766
+ def toplevel_include_owner(node, qualified_prefix, current_class, accumulator)
6767
+ return nil unless current_class.nil? && qualified_prefix.empty?
6768
+ return nil unless node.receiver.nil? && node.name == :include
6769
+
6770
+ accumulator.program_statement?(node) ? ObjectMixins::TOPLEVEL_INCLUDE_KEY : nil
6771
+ end
6772
+
5825
6773
  # Issue #1123 — the class a `Recv.prepend(M)` call form targets, as the qualified name the prepend
5826
6774
  # table is keyed by, or nil when the receiver names no static class. The receiver is a constant READ
5827
6775
  # at the call site, so it resolves in the `Module.nesting` the call is written in — the same rule (and
@@ -5851,9 +6799,19 @@ module Rigor
5851
6799
  # Issue #915 — plus `class << self; include M; end`, the same singleton ancestor spelled through the
5852
6800
  # singleton-class body. It folds like an `extend` because Ruby makes it one.
5853
6801
  def build_discovered_extends(root)
5854
- accumulator = {}
6802
+ extend_tables(root).fetch(:extends)
6803
+ end
6804
+
6805
+ # {#build_discovered_extends} and the `extend` edges whose order is not a fact ({MixinAccumulator}), from
6806
+ # one descent.
6807
+ def extend_tables(root)
6808
+ accumulator = MixinAccumulator.new
6809
+ accumulator.direct_body(program_statements(root))
5855
6810
  walk_class_extends(root, [], nil, accumulator)
5856
- accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
6811
+ {
6812
+ extends: accumulator.transform_values { |mods| mods.uniq.freeze }.freeze,
6813
+ unpositioned: accumulator.unpositioned
6814
+ }
5857
6815
  end
5858
6816
 
5859
6817
  def walk_class_extends(node, qualified_prefix, current_class, accumulator, in_singleton: false,
@@ -5880,18 +6838,40 @@ module Rigor
5880
6838
  return if walk_extends_meta_new?(node, qualified_prefix, current_class, accumulator,
5881
6839
  in_singleton, singleton_self, singleton_cref)
5882
6840
  when Prism::CallNode
5883
- record_extend_call(node, current_class, accumulator, in_singleton: in_singleton)
6841
+ record_extend_call(node, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
5884
6842
  if receiver_eval_call?(node)
5885
6843
  return walk_eval_extends_call(node, qualified_prefix, current_class, accumulator,
5886
6844
  in_singleton: in_singleton, singleton_self: singleton_self,
5887
6845
  singleton_cref: singleton_cref)
5888
6846
  end
5889
- if node.block.is_a?(Prism::BlockNode)
5890
- return walk_extends_block_call(node, qualified_prefix, current_class, accumulator,
5891
- in_singleton, singleton_self, singleton_cref)
6847
+ if node.block.is_a?(Prism::BlockNode) &&
6848
+ extends_block_handled?(node, qualified_prefix, current_class, accumulator,
6849
+ in_singleton, singleton_self, singleton_cref)
6850
+ return
6851
+ end
6852
+ end
6853
+
6854
+ walk_extends_children(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6855
+ singleton_cref)
6856
+ end
6857
+
6858
+ # The generic descent of {#walk_class_extends}; a hook's body walks with its parameters in scope.
6859
+ def walk_extends_children(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6860
+ singleton_cref)
6861
+ hook_params = hook_def_params(node)
6862
+ if hook_params
6863
+ return accumulator.with_hook_params(hook_params) do
6864
+ walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton,
6865
+ singleton_self, singleton_cref)
5892
6866
  end
5893
6867
  end
5894
6868
 
6869
+ walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6870
+ singleton_cref)
6871
+ end
6872
+
6873
+ def walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton,
6874
+ singleton_self, singleton_cref)
5895
6875
  node.rigor_each_child do |child|
5896
6876
  walk_class_extends(child, qualified_prefix, current_class, accumulator,
5897
6877
  in_singleton: in_singleton, singleton_self: singleton_self,
@@ -5915,6 +6895,7 @@ module Rigor
5915
6895
  # singleton body, `self` IS the singleton and `class << self` opens the
5916
6896
  # singleton's own singleton (`#<Class:#<Class:C>>`), which nothing names.
5917
6897
  opens_self = node.expression.is_a?(Prism::SelfNode) && !in_singleton
6898
+ accumulator.direct_body(node.body) if opens_self && accumulator.direct?(node)
5918
6899
  walk_class_extends(node.body, qualified_prefix, opens_self ? current_class : nil,
5919
6900
  accumulator, in_singleton: opens_self,
5920
6901
  singleton_self: true, singleton_cref: true)
@@ -5931,6 +6912,8 @@ module Rigor
5931
6912
  return unless child_prefix && node.body
5932
6913
 
5933
6914
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
6915
+ accumulator.direct_body(node.body) if accumulator.direct?(node)
6916
+ accumulator.body_statements(node.body)
5934
6917
  walk_class_extends(node.body, child_cref ? [] : child_prefix,
5935
6918
  child_cref ? nil : child_prefix.join("::"), accumulator,
5936
6919
  singleton_cref: child_cref)
@@ -5954,6 +6937,110 @@ module Rigor
5954
6937
  in_singleton, singleton_self, singleton_cref)
5955
6938
  end
5956
6939
 
6940
+ # What the extends walk does with a block-carrying call: `:rebound` (its own eval / `define_method` / meta-new
6941
+ # handling), `:skip` (the block runs on an object this walk cannot vouch for, as an instance's `synchronize`
6942
+ # or an `on_load` hook, or may never run: `Registry.each { … }`), or `:descend` (it provably runs once with the
6943
+ # body's `self`, #1592, and its direct statements are positioned when the call itself is).
6944
+ def extends_block_plan(node, accumulator)
6945
+ return :rebound if rebinding_extends_block?(node)
6946
+ return :skip unless runs_body_block_once?(node, accumulator)
6947
+
6948
+ accumulator.direct_body(node.block.body) if accumulator.direct?(node)
6949
+ :descend
6950
+ end
6951
+
6952
+ # True when {#extends_block_plan} settled the block (walked as rebound, or skipped); false leaves it to the
6953
+ # generic descent.
6954
+ def extends_block_handled?(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6955
+ singleton_cref)
6956
+ case extends_block_plan(node, accumulator)
6957
+ when :skip then true
6958
+ when :rebound
6959
+ walk_extends_block_call(node, qualified_prefix, current_class, accumulator,
6960
+ in_singleton, singleton_self, singleton_cref)
6961
+ true
6962
+ else false
6963
+ end
6964
+ end
6965
+
6966
+ ARRAY_ITERATORS = %i[each each_with_index map].freeze
6967
+ HASH_ITERATORS = %i[each each_pair each_key each_value map].freeze
6968
+ private_constant :ARRAY_ITERATORS, :HASH_ITERATORS
6969
+
6970
+ # A block that provably runs at least once with the class body's `self`: the call is a statement of the
6971
+ # body itself (not nested in a `def` or another block) and either `tap` / `then` on a literal (they
6972
+ # always yield once, self preserved) or an iterator over a literal that is known non-empty (a
6973
+ # non-empty Array or Hash literal, a positive `Integer#times`, an Integer-literal Range that is not empty,
6974
+ # `upto` / `downto` in the right order). A constant or variable receiver (`Registry.each`, a lazy `map`) may
6975
+ # never yield, so it is not one. The walk then records the block's direct statements as positioned: the
6976
+ # block runs where it is written, and a repeated `extend` is idempotent (#1592).
6977
+ def runs_body_block_once?(node, accumulator)
6978
+ return false unless accumulator.body_block?(node)
6979
+ return literal_or_self?(node.receiver) if %i[tap then].include?(node.name)
6980
+
6981
+ non_empty_literal_iteration?(node.name, node.receiver, node.arguments&.arguments || [])
6982
+ end
6983
+
6984
+ TAP_RECEIVERS = [Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode, Prism::ArrayNode,
6985
+ Prism::HashNode, Prism::RangeNode, Prism::NilNode, Prism::TrueNode, Prism::FalseNode].freeze
6986
+ private_constant :TAP_RECEIVERS
6987
+
6988
+ # `tap` / `then` yield once only on a receiver whose method nobody overrode: a literal. A constant or `self`
6989
+ # may define its own `tap` (`def self.tap`), a promise's `then` defers, and a receiverless call may reach a
6990
+ # class-level DSL method, so none of those is read as running.
6991
+ def literal_or_self?(receiver)
6992
+ TAP_RECEIVERS.any? { |kind| receiver.is_a?(kind) }
6993
+ end
6994
+
6995
+ def non_empty_literal_iteration?(name, receiver, arguments)
6996
+ case receiver
6997
+ when Prism::ArrayNode
6998
+ ARRAY_ITERATORS.include?(name) && !receiver.elements.empty? &&
6999
+ receiver.elements.none?(Prism::SplatNode)
7000
+ when Prism::HashNode
7001
+ HASH_ITERATORS.include?(name) && !receiver.elements.empty? &&
7002
+ receiver.elements.all?(Prism::AssocNode)
7003
+ when Prism::IntegerNode then integer_iteration_runs?(name, receiver.value, arguments)
7004
+ when Prism::ParenthesesNode then non_empty_range_iteration?(name, receiver, arguments)
7005
+ else false
7006
+ end
7007
+ end
7008
+
7009
+ def integer_iteration_runs?(name, value, arguments)
7010
+ limit = arguments.first
7011
+ case name
7012
+ when :times then arguments.empty? && value.positive?
7013
+ when :upto then arguments.size == 1 && limit.is_a?(Prism::IntegerNode) && value <= limit.value
7014
+ when :downto then arguments.size == 1 && limit.is_a?(Prism::IntegerNode) && value >= limit.value
7015
+ else false
7016
+ end
7017
+ end
7018
+
7019
+ def positive_integer_literal?(node) = node.is_a?(Prism::IntegerNode) && node.value.positive?
7020
+
7021
+ # `(1..3).each` and `(1..3).map`, and `(1..3).step(2)` with a positive literal step.
7022
+ def non_empty_range_iteration?(name, parens, arguments)
7023
+ range = parens.body.is_a?(Prism::StatementsNode) && parens.body.body.size == 1 ? parens.body.body.first : nil
7024
+ return false unless range.is_a?(Prism::RangeNode)
7025
+ return false unless range.left.is_a?(Prism::IntegerNode) && range.right.is_a?(Prism::IntegerNode)
7026
+
7027
+ low = range.left.value
7028
+ high = range.right.value
7029
+ return false unless range.exclude_end? ? low < high : low <= high
7030
+
7031
+ case name
7032
+ when :each, :map then arguments.empty?
7033
+ when :step then positive_integer_literal?(arguments.first) && arguments.size == 1
7034
+ else false
7035
+ end
7036
+ end
7037
+
7038
+ # Whether {#walk_extends_block_call} owns this block-carrying call. Any other block (`[1].each { extend M }`,
7039
+ # `included do extend M end`) keeps the enclosing `self`, so the generic descent walks it (#1592).
7040
+ def rebinding_extends_block?(node)
7041
+ INSTANCE_EVAL_CALLS.include?(node.name) || node.name == :define_method || meta_new_constant_rvalue?(node)
7042
+ end
7043
+
5957
7044
  # A block whose `self` is an object this walk cannot name — a `define_method` body (its
5958
7045
  # self is the receiver's instance at call time) or an unnamed `Class.new { … }`-family
5959
7046
  # block — walks ownerless with an unnameable self, while the call's receiver and
@@ -6001,7 +7088,7 @@ module Rigor
6001
7088
  in_singleton: in_singleton, singleton_self: singleton_self,
6002
7089
  singleton_cref: singleton_cref)
6003
7090
  end
6004
- if (body = call.block&.body)
7091
+ if (body = meta_new_block_body(node))
6005
7092
  walk_class_extends(body, qualified_prefix, meta_owner, accumulator,
6006
7093
  singleton_cref: body_cref)
6007
7094
  end
@@ -6067,19 +7154,39 @@ module Rigor
6067
7154
  # `class << self; extend M; end` puts M on the singleton's OWN singleton, one level further out than
6068
7155
  # anything this table describes, and `module_function` in a singleton body is not the scope toggle
6069
7156
  # {#build_discovered_extends} over-approximates. So the two arms are disjoint rather than additive.
6070
- def record_extend_call(node, current_class, accumulator, in_singleton: false)
6071
- return unless current_class && node.receiver.nil?
7157
+ # Inside `class << self` the instance walk is ownerless, so this walk also keeps the instance-side taints
7158
+ # (`base.include X` in a hook written there).
7159
+ def record_extend_call(node, qualified_prefix, current_class, accumulator, in_singleton: false)
7160
+ return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
6072
7161
 
6073
- if in_singleton
6074
- record_extend_targets(node, current_class, accumulator) if MIXIN_CALL_NAMES.include?(node.name)
6075
- return
6076
- end
7162
+ taint_opaque_eval(node, current_class, accumulator)
6077
7163
 
6078
- case node.name
6079
- when :extend then record_extend_targets(node, current_class, accumulator)
6080
- when :module_function
6081
- (accumulator[current_class] ||= []) << current_class if bare_module_function?(node)
6082
- end
7164
+ kind, _arguments, via_send = mixin_call_view(node)
7165
+ return if kind.nil?
7166
+
7167
+ effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
7168
+ .select { |_owner, side| side == :extend || in_singleton }
7169
+ return if effects.empty?
7170
+ return if !via_send && singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7171
+
7172
+ effects.each { |owner, side| accumulator.taint(owner, side) }
7173
+ end
7174
+
7175
+ # The bare `module_function` toggle: the module extends itself. A direct statement only.
7176
+ def record_module_function(node, current_class, accumulator, in_singleton)
7177
+ return unless current_class && node.receiver.nil? && !in_singleton && ModuleFunctionState.extends_self?(node)
7178
+
7179
+ (accumulator[current_class] ||= []) << current_class
7180
+ accumulator.note(node, current_class, :extend, [current_class], complete: true)
7181
+ end
7182
+
7183
+ # Records the singleton-side edges of a receiverless `extend` (or, inside `class << self`, `include` /
7184
+ # `prepend`). False when it recorded nothing.
7185
+ def singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
7186
+ return false unless current_class && node.receiver.nil?
7187
+ return false unless in_singleton ? MIXIN_CALL_NAMES.include?(kind) : kind == :extend
7188
+
7189
+ !record_extend_targets(node, current_class, accumulator).empty?
6083
7190
  end
6084
7191
 
6085
7192
  # The table stores search order, not call order: a later `extend` statement prepends its module
@@ -6088,11 +7195,22 @@ module Rigor
6088
7195
  # and consumers iterating the list forward read the same order the singleton ancestry searches.
6089
7196
  def record_extend_targets(node, current_class, accumulator)
6090
7197
  targets = []
6091
- node.arguments&.arguments&.each do |arg|
7198
+ arguments = node.arguments&.arguments || []
7199
+ arguments.each do |arg|
6092
7200
  target = arg.is_a?(Prism::SelfNode) ? current_class : Source::ConstantPath.qualified_name(arg)
6093
7201
  targets << target if target
6094
7202
  end
6095
- (accumulator[current_class] ||= []).unshift(*targets) unless targets.empty?
7203
+ return targets if targets.empty?
7204
+
7205
+ list = (accumulator[current_class] ||= [])
7206
+ # A repeated `extend` keeps the table position it always had (the later statement's: the freeze `uniq`s
7207
+ # the nearest-first list), which is not Ruby's (Ruby skips the repeat and keeps the first, #1573), and
7208
+ # the folded singleton tables read that order. The order is not a fact, so the singleton side is named
7209
+ # unpositioned and the chain settles to the readers' own answer.
7210
+ accumulator.taint(current_class, :extend) if targets.any? { |target| list.include?(target) }
7211
+ list.unshift(*targets)
7212
+ accumulator.note(node, current_class, :extend, targets, complete: targets.size == arguments.size)
7213
+ targets
6096
7214
  end
6097
7215
 
6098
7216
  # The materialization half of #526: for every `C extends M`, M's INSTANCE defs become C's
@@ -6100,36 +7218,133 @@ module Rigor
6100
7218
  # call-site return inference runs with `self = Singleton[C]`, which is what Ruby binds). A name C
6101
7219
  # already defines on its own singleton wins (`||=`); an extend target with no discovered defs
6102
7220
  # contributes nothing (RBS-module extends stay on the dispatch tiers).
6103
- def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods)
7221
+ #
7222
+ # ADR-119 WD1 — the copy follows its siblings, which this writes (copy on write; a table the caller passed
7223
+ # is never mutated). `unpositioned` is the project's `unpositioned_mixins`; an edge it lists, or taints
7224
+ # with "*", is not a fact, so what it copies is `possible`:
7225
+ #
7226
+ # - a slot the copy WROTE is contested when the edge is listed or the source slot is contested (a slot some
7227
+ # other copy wrote was decided by that copy, which the `||=` lets win);
7228
+ # - the singleton name is `possible` when the edge is listed or the source name is possible on the instance
7229
+ # side, unless C's singleton already answers it certainly (never `:both` from here: the instance side is
7230
+ # not touched);
7231
+ # - a certain copy makes the singleton name certain, so `:singleton` leaves the name's possible kind.
7232
+ #
7233
+ # A contested source with a certain edge contests the slot and leaves the name certain: a value-only
7234
+ # contest does not make the name possible.
7235
+ def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods, siblings:,
7236
+ unpositioned:)
7237
+ owned = {}.compare_by_identity
6104
7238
  extends.each do |class_name, mods|
7239
+ listed_mods = unpositioned.dig(class_name, :extend)
6105
7240
  mods.each do |mod_name|
6106
- source_defs = extend_source_defs(def_nodes, class_name, mod_name)
7241
+ resolved, source_defs = extend_source_defs(def_nodes, class_name, mod_name)
6107
7242
  next if source_defs.nil?
6108
7243
 
6109
7244
  # An inner table inherited unchanged from a frozen seed must be thawed before the fold writes.
6110
7245
  target = singleton_def_nodes[class_name]
6111
7246
  target = singleton_def_nodes[class_name] = (target ? target.dup : {}) if target.nil? || target.frozen?
7247
+ listed = extend_edge_listed?(listed_mods, mod_name)
6112
7248
  source_defs.each do |method_name, def_node|
7249
+ wrote = target[method_name].nil?
6113
7250
  target[method_name] ||= def_node
7251
+ certain_before = singleton_name_certain?(methods, siblings, class_name, method_name)
6114
7252
  record_method_kind(methods, class_name, method_name, :singleton)
7253
+ follow_extend_siblings(siblings, owned, [class_name, resolved, method_name],
7254
+ wrote: wrote, listed: listed, certain_before: certain_before)
6115
7255
  end
6116
7256
  end
6117
7257
  end
6118
7258
  end
6119
7259
 
7260
+ # Whether `unpositioned_mixins` lists the extend edge to `mod_name` (or taints the whole side).
7261
+ def extend_edge_listed?(listed_mods, mod_name)
7262
+ !listed_mods.nil? && (listed_mods.include?(mod_name) || listed_mods.include?(MixinAccumulator::WILDCARD))
7263
+ end
7264
+
7265
+ # Whether `class_name`'s singleton already answers `method_name` through a certain fact: the existence table
7266
+ # covers the singleton side and the `possible` copy does not.
7267
+ def singleton_name_certain?(methods, siblings, class_name, method_name)
7268
+ return false unless covers_singleton?(methods.dig(class_name, method_name))
7269
+
7270
+ !covers_singleton?(siblings[:possible_discovered_methods].dig(class_name, method_name))
7271
+ end
7272
+
7273
+ SINGLETON_KINDS = [:singleton, Scope::DiscoveryIndex::METHOD_KIND_BOTH].freeze
7274
+ INSTANCE_KINDS = [:instance, Scope::DiscoveryIndex::METHOD_KIND_BOTH].freeze
7275
+ private_constant :SINGLETON_KINDS, :INSTANCE_KINDS
7276
+
7277
+ def covers_singleton?(kind)
7278
+ SINGLETON_KINDS.include?(kind)
7279
+ end
7280
+
7281
+ # The sibling half of one copied name; see {#fold_extends_into_singleton_tables}. `names` is
7282
+ # `[extender, resolved source module, method name]`.
7283
+ def follow_extend_siblings(siblings, owned, names, wrote:, listed:, certain_before:)
7284
+ class_name, resolved, method_name = names
7285
+ contested_source = !siblings[:contested_discovered_def_nodes].empty? &&
7286
+ siblings[:contested_discovered_def_nodes].include?([resolved, method_name])
7287
+ if wrote && (listed || contested_source)
7288
+ own_sibling_table(siblings, owned, :contested_discovered_singleton_def_nodes) << [class_name, method_name]
7289
+ end
7290
+ if listed || source_name_possible?(siblings, resolved, method_name)
7291
+ return if certain_before
7292
+
7293
+ row = (own_sibling_table(siblings, owned, :possible_discovered_methods)[class_name] ||= {})
7294
+ row[method_name] = merge_sibling_kind(row[method_name], :singleton)
7295
+ else
7296
+ strip_possible_singleton(siblings, owned, class_name, method_name)
7297
+ end
7298
+ end
7299
+
7300
+ def source_name_possible?(siblings, resolved, method_name)
7301
+ INSTANCE_KINDS.include?(siblings[:possible_discovered_methods].dig(resolved, method_name))
7302
+ end
7303
+
7304
+ def merge_sibling_kind(sitting, arriving)
7305
+ sitting.nil? || sitting == arriving ? arriving : Scope::DiscoveryIndex::METHOD_KIND_BOTH
7306
+ end
7307
+
7308
+ # A certain copy: `:singleton` leaves the `possible` kind (`:both` becomes `:instance`), and an emptied row goes.
7309
+ def strip_possible_singleton(siblings, owned, class_name, method_name)
7310
+ return unless covers_singleton?(siblings[:possible_discovered_methods].dig(class_name, method_name))
7311
+
7312
+ possible = own_sibling_table(siblings, owned, :possible_discovered_methods)
7313
+ row = possible[class_name]
7314
+ if row[method_name] == :singleton
7315
+ row.delete(method_name)
7316
+ possible.delete(class_name) if row.empty?
7317
+ else
7318
+ row[method_name] = :instance
7319
+ end
7320
+ end
7321
+
7322
+ # The sibling `key` as a table this fold owns: the first write copies it (a Set, or a Hash of Hashes) and
7323
+ # keeps the copy in `siblings`, so a table shared with a seed, a bundle or an empty constant is never written.
7324
+ def own_sibling_table(siblings, owned, key)
7325
+ table = siblings.fetch(key)
7326
+ return table if owned.key?(table)
7327
+
7328
+ copy = table.is_a?(Set) ? table.dup : table.transform_values(&:dup)
7329
+ owned[copy] = true
7330
+ siblings[key] = copy
7331
+ end
7332
+
6120
7333
  # `extend CustomSig` inside `module Outer; class F` stores the as-written name, while
6121
7334
  # `def_nodes` keys the module's defs as `Outer::CustomSig`. Walk the enclosing namespaces
6122
- # innermost-first so the fold finds the same module Ruby constant-lookup would.
7335
+ # innermost-first so the fold finds the same module Ruby constant-lookup would. Returns
7336
+ # `[the key the defs were found under, the defs]`, or nil.
6123
7337
  def extend_source_defs(def_nodes, class_name, mod_name)
6124
7338
  found = def_nodes[mod_name]
6125
- return found if found
7339
+ return [mod_name, found] if found
6126
7340
  return nil if mod_name.nil? || mod_name.start_with?("::")
6127
7341
 
6128
7342
  parts = class_name.to_s.split("::")
6129
7343
  while parts.size > 1
6130
7344
  parts.pop
6131
- found = def_nodes["#{parts.join('::')}::#{mod_name}"]
6132
- return found if found
7345
+ key = "#{parts.join('::')}::#{mod_name}"
7346
+ found = def_nodes[key]
7347
+ return [key, found] if found
6133
7348
  end
6134
7349
  nil
6135
7350
  end
@@ -6155,9 +7370,83 @@ module Rigor
6155
7370
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
6156
7371
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
6157
7372
  def build_discovered_method_visibilities(root)
6158
- accumulator = {}
7373
+ build_method_visibility_tables(root).first
7374
+ end
7375
+
7376
+ # ADR-119 WD3 — the visibility table and its `contested_discovered_method_visibilities` sibling, from ONE walk
7377
+ # over the file's {Certainty} classification `certainty`: `[table, contested key paths]`. The recorded values
7378
+ # are {#build_discovered_method_visibilities}'s; a slot is contested when its last writer is ({VisibilityTable}).
7379
+ def build_method_visibility_tables(root, certainty: Certainty.possible_nodes(root))
7380
+ accumulator = VisibilityTable.new.track(certainty)
6159
7381
  walk_method_visibilities(root, [], false, :public, accumulator)
6160
- accumulator.transform_values(&:freeze).freeze
7382
+ [accumulator.transform_values(&:freeze).freeze, accumulator.contested_paths]
7383
+ end
7384
+
7385
+ # The bare calls that set the running default visibility of the body they run in: the modifiers and
7386
+ # `module_function`, which {#walk_method_visibilities} does not track as a value (#1569) but which moves the
7387
+ # default all the same.
7388
+ VISIBILITY_TOGGLES = (VISIBILITY_MODIFIERS + %i[module_function]).freeze
7389
+
7390
+ # The `{class => {name => visibility}}` accumulator of {#walk_method_visibilities}, which also carries the
7391
+ # ADR-119 WD3 state of the slots it writes. A slot is contested when the write that holds it is possible: a
7392
+ # `def` or a named target (`private :x`) the {Certainty} classification reads as possible, or a `def` written
7393
+ # after an UNCERTAIN bare toggle of its body. A toggle is uncertain when it is possible itself or when the walk
7394
+ # does not carry it into the body's running default: one under any child of the body that is not its
7395
+ # statement list (`if c then private end`, `begin private end`, a block, `(private)`), since Ruby applies a
7396
+ # bare toggle there to the body's later `def`s while the walk keeps the default it had. A certain toggle
7397
+ # written directly in the body settles the default again. A named target does not read the running default,
7398
+ # so a toggle does not contest it. A certain write clears the slot's contest.
7399
+ class VisibilityTable < Hash
7400
+ # The bare toggles the walk has met so far; a body's statement loop compares it around each child.
7401
+ attr_accessor :toggles
7402
+ # Whether the running default of the body being walked rests on an uncertain toggle.
7403
+ attr_accessor :uncertain
7404
+ # The file's {Certainty} answer.
7405
+ attr_reader :certainty
7406
+
7407
+ def track(certainty)
7408
+ @certainty = certainty
7409
+ @contested = nil
7410
+ @toggles = 0
7411
+ @uncertain = false
7412
+ self
7413
+ end
7414
+
7415
+ # Records that `node` wrote the `[class_name, name]` slot; `toggled` when the write reads the running
7416
+ # default (a `def`).
7417
+ def wrote(class_name, name, node, toggled:)
7418
+ if Certainty.possible?(@certainty, node) || (toggled && @uncertain)
7419
+ (@contested ||= Set.new) << [class_name, name].freeze
7420
+ else
7421
+ @contested&.delete([class_name, name])
7422
+ end
7423
+ end
7424
+
7425
+ # Runs the block as a fresh body (a class or module, `class << self`, a meta-new or eval block), whose
7426
+ # default starts certain and whose toggles do not reach the enclosing body.
7427
+ def body
7428
+ saved_toggles = @toggles
7429
+ saved_uncertain = @uncertain
7430
+ @uncertain = false
7431
+ yield
7432
+ ensure
7433
+ @toggles = saved_toggles
7434
+ @uncertain = saved_uncertain
7435
+ end
7436
+
7437
+ def contested_paths
7438
+ contested = @contested
7439
+ return Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_method_visibilities] if contested.nil?
7440
+ return Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_method_visibilities] if contested.empty?
7441
+
7442
+ contested.freeze
7443
+ end
7444
+ end
7445
+
7446
+ # A bare toggle ({VISIBILITY_TOGGLES}): receiverless, with no arguments.
7447
+ def bare_visibility_toggle?(node)
7448
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && VISIBILITY_TOGGLES.include?(node.name) &&
7449
+ (node.arguments.nil? || node.arguments.arguments.empty?) && node.block.nil?
6161
7450
  end
6162
7451
 
6163
7452
  # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
@@ -6179,8 +7468,10 @@ module Rigor
6179
7468
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
6180
7469
  body_prefix = child_cref ? [] : child_prefix
6181
7470
  if node.body
6182
- walk_method_visibilities(node.body, body_prefix, false, :public,
6183
- accumulator, nil, singleton_cref: child_cref)
7471
+ accumulator.body do
7472
+ walk_method_visibilities(node.body, body_prefix, false, :public,
7473
+ accumulator, nil, singleton_cref: child_cref)
7474
+ end
6184
7475
  end
6185
7476
  return current_visibility
6186
7477
  end
@@ -6200,9 +7491,11 @@ module Rigor
6200
7491
  defs_singleton: defs_singleton)
6201
7492
  end
6202
7493
  if body
6203
- walk_method_visibilities(body, qualified_prefix, false, :public,
6204
- accumulator, body_self,
6205
- singleton_cref: singleton_cref)
7494
+ accumulator.body do
7495
+ walk_method_visibilities(body, qualified_prefix, false, :public,
7496
+ accumulator, body_self,
7497
+ singleton_cref: singleton_cref)
7498
+ end
6206
7499
  end
6207
7500
  return current_visibility
6208
7501
  end
@@ -6217,6 +7510,7 @@ module Rigor
6217
7510
  defs_singleton: defs_singleton)
6218
7511
  return current_visibility
6219
7512
  end
7513
+ accumulator.toggles += 1 if bare_visibility_toggle?(node)
6220
7514
  # The visibility table is instance-side only — `private :x` inside `class <<`
6221
7515
  # (or a receiver-eval body that re-evaluates a singleton self) marks the
6222
7516
  # SINGLETON method, which this table cannot express.
@@ -6230,13 +7524,23 @@ module Rigor
6230
7524
 
6231
7525
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
6232
7526
  # entry visibility unchanged.
7527
+ #
7528
+ # ADR-119 WD3 — the statement loop also settles whether the running default is uncertain
7529
+ # ({VisibilityTable}): a bare toggle written here sets it from its own certainty, and any other statement
7530
+ # under which the walk met a toggle makes it uncertain.
6233
7531
  if node.is_a?(Prism::StatementsNode)
6234
7532
  local_visibility = current_visibility
6235
7533
  node.rigor_each_child do |child|
7534
+ toggles = accumulator.toggles
6236
7535
  local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
6237
7536
  local_visibility, accumulator, def_owner_prefix,
6238
7537
  singleton_cref: singleton_cref,
6239
7538
  defs_singleton: defs_singleton)
7539
+ if bare_visibility_toggle?(child)
7540
+ accumulator.uncertain = Certainty.possible?(accumulator.certainty, child)
7541
+ elsif accumulator.toggles != toggles
7542
+ accumulator.uncertain = true
7543
+ end
6240
7544
  end
6241
7545
  else
6242
7546
  node.rigor_each_child do |child|
@@ -6263,8 +7567,10 @@ module Rigor
6263
7567
  defs_singleton: defs_singleton)
6264
7568
  return unless node.body
6265
7569
 
6266
- walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator,
6267
- singleton_prefix, singleton_cref: true)
7570
+ accumulator.body do
7571
+ walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator,
7572
+ singleton_prefix, singleton_cref: true)
7573
+ end
6268
7574
  end
6269
7575
 
6270
7576
  # The eval-block arm of {#walk_method_visibilities}: the block is a fresh class body under
@@ -6295,10 +7601,12 @@ module Rigor
6295
7601
  # receiver-as-module and stay instance-side (`in_singleton_class` off) for a class
6296
7602
  # receiver.
6297
7603
  call_singleton, defs_flag = eval_body_def_context(node, in_singleton_class)
6298
- node.block.rigor_each_child do |child|
6299
- walk_method_visibilities(child, qualified_prefix, call_singleton, :public, accumulator,
6300
- eval_prefix, singleton_cref: singleton_cref,
6301
- defs_singleton: defs_flag)
7604
+ accumulator.body do
7605
+ node.block.rigor_each_child do |child|
7606
+ walk_method_visibilities(child, qualified_prefix, call_singleton, :public, accumulator,
7607
+ eval_prefix, singleton_cref: singleton_cref,
7608
+ defs_singleton: defs_flag)
7609
+ end
6302
7610
  end
6303
7611
  end
6304
7612
 
@@ -6309,6 +7617,7 @@ module Rigor
6309
7617
  class_name = qualified_prefix.join("::")
6310
7618
  accumulator[class_name] ||= {}
6311
7619
  accumulator[class_name][def_node.name] = current_visibility
7620
+ accumulator.wrote(class_name, def_node.name, def_node, toggled: true)
6312
7621
  end
6313
7622
 
6314
7623
  # Recognises modifier calls on the implicit-self receiver inside a class body. Returns the (possibly updated)
@@ -6329,26 +7638,25 @@ module Rigor
6329
7638
  if args.empty?
6330
7639
  call_node.name
6331
7640
  else
6332
- apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
7641
+ apply_named_visibility(args, qualified_prefix, call_node, accumulator)
6333
7642
  current_visibility
6334
7643
  end
6335
7644
  end
6336
7645
 
6337
- def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
7646
+ def apply_named_visibility(args, qualified_prefix, call_node, accumulator)
6338
7647
  class_name = qualified_prefix.join("::")
6339
7648
  args.each do |arg|
6340
7649
  name = visibility_target_name(arg)
6341
7650
  next if name.nil?
6342
7651
 
6343
7652
  accumulator[class_name] ||= {}
6344
- accumulator[class_name][name] = visibility
7653
+ accumulator[class_name][name] = call_node.name
7654
+ accumulator.wrote(class_name, name, call_node, toggled: false)
6345
7655
  end
6346
7656
  end
6347
7657
 
6348
7658
  def visibility_target_name(arg)
6349
- return arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
6350
-
6351
- nil
7659
+ Source::Literals.symbol_or_string(arg)
6352
7660
  end
6353
7661
 
6354
7662
  # Registers the alias name in the `discovered_methods` table so `undefined-method` diagnostics are not emitted for
@@ -6376,15 +7684,30 @@ module Rigor
6376
7684
  class_name = qualified_prefix.join("::")
6377
7685
  undef_node.names.each do |name|
6378
7686
  method_name = literal_method_name(name)
6379
- record_method_envelope_opaque(tables, class_name, method_name) if method_name
7687
+ next unless method_name
7688
+
7689
+ record_method_envelope_opaque(tables, class_name, method_name)
7690
+ record_removed_name(tables, class_name, method_name)
6380
7691
  end
6381
7692
  end
6382
7693
 
7694
+ REMOVING_CALLS = %i[remove_method undef_method].to_set.freeze
7695
+ private_constant :REMOVING_CALLS
7696
+
7697
+ # Issue #1820 — `undef f`, `remove_method :f` or `undef_method :f` on the class, as its own entry beside the
7698
+ # opaque envelopes: an `attr_*` or `define_method` records an opaque envelope too, so only this says the name
7699
+ # is gone.
7700
+ def record_removed_name(tables, class_name, method_name)
7701
+ record_envelope(tables.envelopes, class_name, [Scope::DiscoveryIndex::ENVELOPE_REMOVED_KIND, method_name],
7702
+ Source::ParameterEnvelope::OPAQUE)
7703
+ end
7704
+
6383
7705
  # Both kinds, and the envelope table only: the evidence says something rewrites the name, not which
6384
7706
  # side it lives on, and it is not evidence that the name EXISTS, so the existence table is untouched.
6385
7707
  def record_method_envelope_opaque(tables, class_name, method_name)
6386
7708
  %i[instance singleton].each do |kind|
6387
7709
  record_envelope(tables.envelopes, class_name, [kind, method_name], Source::ParameterEnvelope::OPAQUE)
7710
+ contest_envelope(tables, class_name, [kind, method_name]) unless tables.certain
6388
7711
  end
6389
7712
  end
6390
7713
 
@@ -6394,7 +7717,7 @@ module Rigor
6394
7717
  # `def_delegator :@x, :f`) would leave a `def`'s envelope standing for a method that no longer has it.
6395
7718
  NAME_NEUTRAL_MACROS = %i[
6396
7719
  private public protected module_function private_class_method public_class_method
6397
- private_constant public_constant require require_relative autoload
7720
+ private_constant public_constant require require_relative autoload autoload_relative
6398
7721
  ].to_set.freeze
6399
7722
  private_constant :NAME_NEUTRAL_MACROS
6400
7723
 
@@ -6445,13 +7768,21 @@ module Rigor
6445
7768
  if dynamic_surface_call?(node)
6446
7769
  record_surface_mark(tables, class_name, Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK)
6447
7770
  elsif node.name == :refine
6448
- record_refinement(node, qualified_prefix, tables)
7771
+ # Issue #1689 — a `refine` where `self` is a class is the class's own method, and refines nothing.
7772
+ record_refinement(node, qualified_prefix, tables) unless tables.class_body_refine?(node)
6449
7773
  elsif !NAME_NEUTRAL_MACROS.include?(node.name)
6450
- (node.arguments&.arguments || []).each do |argument|
6451
- # `memoize def f(a)` wraps the def it is handed exactly as `memoize :f` does.
6452
- method_name = argument.is_a?(Prism::DefNode) ? argument.name : literal_method_name(argument)
6453
- record_method_envelope_opaque(tables, class_name, method_name) if method_name
6454
- end
7774
+ record_named_arguments_opaque(node, class_name, tables)
7775
+ end
7776
+ end
7777
+
7778
+ def record_named_arguments_opaque(node, class_name, tables)
7779
+ (node.arguments&.arguments || []).each do |argument|
7780
+ # `memoize def f(a)` wraps the def it is handed exactly as `memoize :f` does.
7781
+ method_name = argument.is_a?(Prism::DefNode) ? argument.name : literal_method_name(argument)
7782
+ next unless method_name
7783
+
7784
+ record_method_envelope_opaque(tables, class_name, method_name)
7785
+ record_removed_name(tables, class_name, method_name) if REMOVING_CALLS.include?(node.name)
6455
7786
  end
6456
7787
  end
6457
7788
 
@@ -6505,8 +7836,11 @@ module Rigor
6505
7836
  # Post-pass over the `def_nodes` accumulator: for every `alias` declaration inside a class body, if the original
6506
7837
  # method name maps to a `Prism::DefNode`, register the new name pointing to the same node so inter-procedural
6507
7838
  # return-type inference works for the aliased name.
6508
- def apply_alias_def_nodes(root, accumulator)
6509
- alias_map = collect_class_alias_map(root, [], {})
7839
+ #
7840
+ # ADR-119 WD3 — the alias is the slot's writer: the new name's slot is contested when the alias is possible
7841
+ # or the slot it copies is contested, and cleared otherwise.
7842
+ def apply_alias_def_nodes(root, accumulator, tables = nil)
7843
+ alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables&.certainty))
6510
7844
  alias_map.each do |class_name, aliases|
6511
7845
  class_defs = accumulator[class_name]
6512
7846
  next unless class_defs
@@ -6516,10 +7850,40 @@ module Rigor
6516
7850
  next unless def_node.is_a?(Prism::DefNode)
6517
7851
 
6518
7852
  (accumulator[class_name] ||= {})[new_name] = def_node
7853
+ next if tables.nil?
7854
+
7855
+ certain = !alias_map.possible?(class_name, new_name) &&
7856
+ !tables.contested_def_nodes&.include?([class_name, old_name])
7857
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], certain)
6519
7858
  end
6520
7859
  end
6521
7860
  end
6522
7861
 
7862
+ # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also remembers, per
7863
+ # entry, whether the alias that wrote it last is possible ({Certainty}).
7864
+ class AliasMap < Hash
7865
+ def track(certainty)
7866
+ @certainty = certainty || Certainty::EMPTY
7867
+ @possible = nil
7868
+ self
7869
+ end
7870
+
7871
+ # Records that `node` wrote the `[class_name, new_name]` entry.
7872
+ def wrote(class_name, new_name, node)
7873
+ return if @certainty.nil?
7874
+
7875
+ if Certainty.possible?(@certainty, node)
7876
+ (@possible ||= Set.new) << [class_name, new_name]
7877
+ else
7878
+ @possible&.delete([class_name, new_name])
7879
+ end
7880
+ end
7881
+
7882
+ def possible?(class_name, new_name)
7883
+ !@possible.nil? && @possible.include?([class_name, new_name])
7884
+ end
7885
+ end
7886
+
6523
7887
  # Builds a map `{class_name => {new_name_sym => old_name_sym}}` by walking the tree for `AliasMethodNode` nodes
6524
7888
  # inside class bodies. `leaf_owner` is the meta-new override: inside `K = Class.new { … }` the
6525
7889
  # block's aliases bind on the class the write names — it overrides `qualified_prefix`, which
@@ -6577,9 +7941,9 @@ module Rigor
6577
7941
  # `alias_method :new, :old` — the CallNode twin of the `alias` keyword (#533; liquid's i18n
6578
7942
  # `t` alias was the corpus case). Unlike AliasMethodNode a call's children can carry further
6579
7943
  # class bodies (`Class.new do … end`), so the walk continues below it either way.
6580
- names = alias_method_call_names(node)
7944
+ names = Source::AliasNames.alias_method_call_names(node)
6581
7945
  if names && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
6582
- (accumulator[rec_prefix.join("::")] ||= {})[names.first] = names.last
7946
+ record_alias_call_entry(accumulator, rec_prefix.join("::"), names, node)
6583
7947
  end
6584
7948
  false
6585
7949
  end
@@ -6638,21 +8002,6 @@ module Rigor
6638
8002
  true
6639
8003
  end
6640
8004
 
6641
- # `[new_name, old_name]` for an implicit-self `alias_method` call with two literal symbol /
6642
- # string arguments, or nil. A variable-named alias is runtime data and stays unrecorded.
6643
- def alias_method_call_names(call_node)
6644
- return nil unless call_node.name == :alias_method && call_node.receiver.nil?
6645
-
6646
- args = call_node.arguments&.arguments
6647
- return nil unless args && args.size == 2
6648
-
6649
- new_name = literal_method_name(args[0])
6650
- old_name = literal_method_name(args[1])
6651
- return nil if new_name.nil? || old_name.nil?
6652
-
6653
- [new_name, old_name]
6654
- end
6655
-
6656
8005
  # The class/module arm of {#collect_class_alias_map}: under an unnameable cref a
6657
8006
  # bare/`self::` header opens `#<singleton>::Name` — the map files nothing for it;
6658
8007
  # a rooted or explicit-base header re-anchors at a nameable prefix.
@@ -6688,14 +8037,21 @@ module Rigor
6688
8037
  accumulator
6689
8038
  end
6690
8039
 
8040
+ def record_alias_call_entry(accumulator, class_name, names, node)
8041
+ (accumulator[class_name] ||= {})[names.first] = names.last
8042
+ accumulator.wrote(class_name, names.first, node) if accumulator.is_a?(AliasMap)
8043
+ end
8044
+
6691
8045
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
6692
8046
  return if qualified_prefix.empty?
6693
- return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)
8047
+
8048
+ names = Source::AliasNames.keyword_names(alias_node)
8049
+ return if names.nil?
6694
8050
 
6695
8051
  class_name = qualified_prefix.join("::")
6696
- new_name = alias_node.new_name.unescaped.to_sym
6697
- old_name = alias_node.old_name.unescaped.to_sym
8052
+ new_name, old_name = names
6698
8053
  (accumulator[class_name] ||= {})[new_name] = old_name
8054
+ accumulator.wrote(class_name, new_name, alias_node) if accumulator.is_a?(AliasMap)
6699
8055
  end
6700
8056
 
6701
8057
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
@@ -6793,9 +8149,7 @@ module Rigor
6793
8149
  end
6794
8150
 
6795
8151
  def literal_method_name(node)
6796
- return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
6797
-
6798
- node.unescaped&.to_sym
8152
+ Source::Literals.symbol_or_string(node)
6799
8153
  end
6800
8154
 
6801
8155
  # Walks every file in `paths` (each path is parsed once with `Prism.parse_file`) and returns the unioned
@@ -6848,6 +8202,8 @@ module Rigor
6848
8202
  physical = buffer ? buffer.resolve(path) : path
6849
8203
  root = Prism.parse(File.read(physical), filepath: path).value
6850
8204
  accumulate_project_index(acc, path, root)
8205
+ rescue DeclarationWalk::ContractError
8206
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
6851
8207
  rescue StandardError
6852
8208
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
6853
8209
  next
@@ -6880,6 +8236,8 @@ module Rigor
6880
8236
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
6881
8237
  declaration_signatures[path] = declaration_signature(file_index)
6882
8238
  refinements[path] = file_index[:refinements] if file_index[:refinements]
8239
+ rescue DeclarationWalk::ContractError
8240
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
6883
8241
  rescue StandardError
6884
8242
  next
6885
8243
  end
@@ -6910,9 +8268,29 @@ module Rigor
6910
8268
  append_def_signatures(parts, file_index[:def_nodes], "#")
6911
8269
  append_def_signatures(parts, file_index[:singleton_def_nodes], ".")
6912
8270
  append_envelope_signature(parts, file_index[:parameter_envelopes] || {})
8271
+ append_certainty_signature(parts, file_index[:siblings] || {})
6913
8272
  Digest::SHA256.hexdigest(parts.join("\x00"))
6914
8273
  end
6915
8274
 
8275
+ # ADR-119 WD3 — which of the file's contributions are possible is a declaration surface too: a `… end if X`
8276
+ # modifier, or moving a `def` into a block, keeps every def line and envelope and moves only this, while a
8277
+ # dependent's candidate-set read now declines where it answered.
8278
+ def append_certainty_signature(parts, siblings)
8279
+ certainty = []
8280
+ siblings.each do |name, table|
8281
+ next if table.empty?
8282
+
8283
+ if name == :possible_discovered_methods
8284
+ table.each do |class_name, row|
8285
+ row.each { |method_name, kind| certainty << "P:#{class_name}##{method_name}=#{kind}" }
8286
+ end
8287
+ else
8288
+ table.each { |path| certainty << "C:#{name}:#{path.inspect}" }
8289
+ end
8290
+ end
8291
+ parts.concat(certainty.sort)
8292
+ end
8293
+
6916
8294
  # Issue #992 — the joined envelope table is a declaration surface the per-def signatures above cannot
6917
8295
  # reconstruct: they keep the LAST `def` of a name, and they see neither a second `def` with another
6918
8296
  # shape nor a `memoize :f` beside it, either of which moves a dependent's `call.wrong-arity` verdict.
@@ -6950,13 +8328,26 @@ module Rigor
6950
8328
  def append_ancestry_signature(parts, file_index)
6951
8329
  parts.concat(file_index[:class_sources].keys.sort_by(&:to_s).map { |cn| "c:#{cn}" })
6952
8330
  parts.concat(sorted_by_class(file_index[:superclasses]).map { |cn, sc| "s:#{cn}<#{sc}" })
6953
- parts.concat(sorted_by_class(file_index[:includes]).map do |cn, mods|
6954
- "i:#{cn}=#{Array(mods).map(&:to_s).sort.join(',')}"
6955
- end)
8331
+ append_mixin_signature(parts, file_index)
6956
8332
  parts.concat(sorted_by_class(file_index[:data_member_layouts]).map { |cn, l| "d:#{cn}=#{l.inspect}" })
6957
8333
  parts.concat(sorted_by_class(file_index[:struct_member_layouts]).map { |cn, l| "t:#{cn}=#{l.inspect}" })
6958
8334
  end
6959
8335
 
8336
+ # The mixin surface of {#append_ancestry_signature}. The lists keep SOURCE order: this is one file's own
8337
+ # index, so the order is deterministic, and it is a fact an ancestor-order reader consumes, so an edit
8338
+ # that only reorders an `include` must move the signature. The unpositioned table is there for the same
8339
+ # reason: guarding an `include` changes no name and no order, only whether the order is known.
8340
+ def append_mixin_signature(parts, file_index)
8341
+ { "i" => :includes, "p" => :prepends, "x" => :extends }.each do |tag, key|
8342
+ sorted_by_class(file_index[key] || {}).each { |cn, mods| parts << "#{tag}:#{cn}=#{Array(mods).join(',')}" }
8343
+ end
8344
+ sorted_by_class(file_index[:unpositioned_mixins] || {}).each do |cn, sides|
8345
+ sides.sort_by { |side, _| side.to_s }.each do |side, names|
8346
+ parts << "u:#{cn}.#{side}=#{names.sort.join(',')}"
8347
+ end
8348
+ end
8349
+ end
8350
+
6960
8351
  # A class-keyed table's pairs sorted by class name (stringified) for a deterministic signature.
6961
8352
  def sorted_by_class(table)
6962
8353
  table.sort_by { |cn, _| cn.to_s }
@@ -7079,6 +8470,8 @@ module Rigor
7079
8470
  root = Prism.parse(File.read(physical), filepath: path).value
7080
8471
  collect_class_decls(root, [], classes)
7081
8472
  accumulate_project_index(acc, path, root)
8473
+ rescue DeclarationWalk::ContractError
8474
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
7082
8475
  rescue StandardError
7083
8476
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
7084
8477
  next
@@ -7148,6 +8541,8 @@ module Rigor
7148
8541
  classes.merge!(file_classes)
7149
8542
  fold_file_index(acc, file_index)
7150
8543
  end
8544
+ rescue DeclarationWalk::ContractError
8545
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
7151
8546
  rescue StandardError
7152
8547
  # Skip files that fail to parse / read; the per-file analyzer surfaces the parse error separately.
7153
8548
  next
@@ -7170,6 +8565,7 @@ module Rigor
7170
8565
  # Polymorphic over the def-node value: a re-walked file's `file_index` carries live nodes; a cached
7171
8566
  # bundle's carries {DefHandle}s. The merges never deref the value, so both fold identically.
7172
8567
  def fold_file_index(acc, file_index)
8568
+ fold_siblings(acc, file_index)
7173
8569
  fold_def_tables(acc, file_index)
7174
8570
  # Issue #992 — a pre-23 seed bundle carries no envelopes; the SCHEMA bump makes such a blob a cold
7175
8571
  # rebuild, and an absent table only ever withholds a check.
@@ -7190,6 +8586,168 @@ module Rigor
7190
8586
  fold_constant_tables(acc, file_index)
7191
8587
  end
7192
8588
 
8589
+ # ADR-119 WD1 — the `possible_*` / `contested_*` siblings of {Scope::DiscoveryIndex::SIBLINGS}, carried as ONE
8590
+ # `{sibling name => table}` Hash (`acc[:siblings]`) through the accumulator, the fold, the compact-header
8591
+ # rename, the finalize and the seed bundle, so no copy path names a sibling and none can drop one. A pre-36
8592
+ # bundle carries the empty siblings of the walk before WD3's producers, and the SCHEMA bump makes such a blob
8593
+ # a cold rebuild. Called BEFORE the file's members fold, because the `possible` rule reads the accumulator's
8594
+ # member as it stood ({#fold_file_siblings}).
8595
+ def fold_siblings(acc, file_index)
8596
+ fold_file_siblings(acc, file_index[:siblings] || {},
8597
+ methods: file_index[:methods], def_nodes: file_index[:def_nodes],
8598
+ singleton_def_nodes: file_index[:singleton_def_nodes],
8599
+ method_visibilities: file_index[:method_visibilities])
8600
+ end
8601
+
8602
+ # ADR-119 WD3 — folds one file's siblings into the accumulator under each member's own fold, so the result is
8603
+ # the same in any file order and a bundle-served file folds as its live walk would:
8604
+ #
8605
+ # - `possible_discovered_methods` by {#fold_possible_methods} against `acc[:methods]` before this file's
8606
+ # `methods` join it;
8607
+ # - the def-node and visibility slots follow their writer, as the members fold later-wins: the file's contests
8608
+ # replace the accumulator's on every key the file writes ({#fold_slot_contests});
8609
+ # - every other sibling (the joined envelopes' contests among them) by union.
8610
+ def fold_file_siblings(acc, siblings, methods:, def_nodes:, singleton_def_nodes:, method_visibilities: {})
8611
+ target = acc[:siblings]
8612
+ siblings.each do |name, table|
8613
+ target[name] =
8614
+ case name
8615
+ when :possible_discovered_methods
8616
+ fold_possible_methods(target.fetch(name), acc[:methods], table, methods)
8617
+ when :contested_discovered_def_nodes then fold_slot_contests(target.fetch(name), def_nodes, table)
8618
+ when :contested_discovered_singleton_def_nodes
8619
+ fold_slot_contests(target.fetch(name), singleton_def_nodes, table)
8620
+ when :contested_discovered_method_visibilities
8621
+ fold_slot_contests(target.fetch(name), method_visibilities, table)
8622
+ else union_sibling_value(target.fetch(name), table)
8623
+ end
8624
+ end
8625
+ end
8626
+
8627
+ # ADR-119 WD3 — the `possible_discovered_methods` fold, per name and side: `P' = (P − C_f) ∪ (P_f − C)`,
8628
+ # where `C = member − P` is what the accumulator (or seed) answers certainly and `C_f = member_f − P_f` what
8629
+ # the file does. A side some contribution supplies certainly is never possible, whatever the order the files
8630
+ # fold in. Returns `possible` itself when nothing changes; otherwise a copy (the rows are copied once).
8631
+ def fold_possible_methods(possible, members, file_possible, file_members)
8632
+ result = strip_file_certain(possible, file_possible, file_members)
8633
+ add_file_possible(result, possible, members, file_possible)
8634
+ end
8635
+
8636
+ # `P − C_f`: `possible` less the sides the file answers certainly; `possible` itself when nothing goes.
8637
+ def strip_file_certain(possible, file_possible, file_members)
8638
+ result = possible
8639
+ possible.each do |class_name, row|
8640
+ file_row = file_members[class_name]
8641
+ next if file_row.nil?
8642
+
8643
+ row.each do |method_name, kind|
8644
+ certain = kind_minus(file_row[method_name], file_possible.dig(class_name, method_name))
8645
+ remaining = kind_minus(kind, certain)
8646
+ next if remaining == kind
8647
+
8648
+ result = possible.transform_values(&:dup) if result.equal?(possible)
8649
+ remaining.nil? ? result[class_name].delete(method_name) : (result[class_name][method_name] = remaining)
8650
+ end
8651
+ result.delete(class_name) if result[class_name].empty?
8652
+ end
8653
+ result
8654
+ end
8655
+
8656
+ # `∪ (P_f − C)`: the file's possible sides the accumulator (`members` less `possible`) does not answer
8657
+ # certainly. Copies `result` first when it is still `possible`.
8658
+ def add_file_possible(result, possible, members, file_possible)
8659
+ file_possible.each do |class_name, row|
8660
+ row.each do |method_name, kind|
8661
+ added = kind_minus(kind, kind_minus(members.dig(class_name, method_name),
8662
+ possible.dig(class_name, method_name)))
8663
+ next if added.nil?
8664
+
8665
+ merged = merge_sibling_kind(result.dig(class_name, method_name), added)
8666
+ next if merged == result.dig(class_name, method_name)
8667
+
8668
+ result = possible.transform_values(&:dup) if result.equal?(possible)
8669
+ (result[class_name] ||= {})[method_name] = merged
8670
+ end
8671
+ end
8672
+ result
8673
+ end
8674
+
8675
+ # The sides of `kind` that `removed` does not cover (nil when none); either may be nil.
8676
+ def kind_minus(kind, removed)
8677
+ return kind if removed.nil? || kind.nil?
8678
+ return nil if removed == Scope::DiscoveryIndex::METHOD_KIND_BOTH || removed == kind
8679
+
8680
+ kind == Scope::DiscoveryIndex::METHOD_KIND_BOTH ? without_kind(kind, removed) : kind
8681
+ end
8682
+
8683
+ # ADR-119 WD3 — a single-valued slot's contests after a later-wins fold: the slots the file writes take the
8684
+ # file's contests (`file_contested`), every other key keeps the accumulator's (`sitting`). `file_slots` is the
8685
+ # file's `{class => {name => value}}` table. Returns `sitting` itself when nothing changes.
8686
+ def fold_slot_contests(sitting, file_slots, file_contested)
8687
+ stale = sitting.empty? ? nil : sitting.select { |class_name, name| file_slots[class_name]&.key?(name) }
8688
+ return sitting if (stale.nil? || stale.empty?) && file_contested.empty?
8689
+
8690
+ result = stale.nil? || stale.empty? ? sitting.dup : sitting - stale
8691
+ result.merge(file_contested)
8692
+ end
8693
+
8694
+ def union_paths(sitting, arriving)
8695
+ return sitting if arriving.empty?
8696
+ return arriving if sitting.empty?
8697
+
8698
+ sitting | arriving
8699
+ end
8700
+
8701
+ # The union of two sibling tables or entries. A Hash merges per key and a Set or Array unions; two scalars
8702
+ # that disagree are a `discovered_methods`-style kind and combine to `:both`, as {#merge_method_kinds} does.
8703
+ def union_sibling_value(sitting, arriving)
8704
+ return arriving if sitting.nil? || sitting.empty?
8705
+ return sitting if arriving.nil? || arriving.empty?
8706
+
8707
+ case sitting
8708
+ when Hash then sitting.merge(arriving) { |_key, left, right| union_sibling_value(left, right) }
8709
+ when Set, Array then sitting | arriving
8710
+ else sitting == arriving ? sitting : Scope::DiscoveryIndex::METHOD_KIND_BOTH
8711
+ end
8712
+ end
8713
+
8714
+ # The sibling tables keyed by file path rather than by class name, which a compact-header rename leaves alone
8715
+ # exactly as it leaves the `deferred_ranges` member alone.
8716
+ SIBLINGS_KEYED_BY_PATH = %i[possible_discovered_deferred_ranges].freeze
8717
+ private_constant :SIBLINGS_KEYED_BY_PATH
8718
+
8719
+ # {#apply_compact_header_renames!} for the siblings: a class-keyed Hash re-keys (colliding keys union), and a
8720
+ # Set of key paths renames each path's first element, the class.
8721
+ def rename_siblings(siblings, renames)
8722
+ siblings.to_h do |name, table|
8723
+ next [name, table] if SIBLINGS_KEYED_BY_PATH.include?(name) || table.empty?
8724
+
8725
+ renamed = case table
8726
+ when Set then table.to_set { |path| [rename_compact_name(renames, path.first), *path.drop(1)] }
8727
+ else rename_sibling_hash(table, renames)
8728
+ end
8729
+ [name, renamed]
8730
+ end
8731
+ end
8732
+
8733
+ def rename_sibling_hash(table, renames)
8734
+ table.each_with_object({}) do |(class_name, entry), out|
8735
+ renamed = rename_compact_name(renames, class_name)
8736
+ out[renamed] = out.key?(renamed) ? union_sibling_value(out[renamed], entry) : entry
8737
+ end
8738
+ end
8739
+
8740
+ def freeze_siblings(siblings)
8741
+ siblings.each_value { |table| freeze_sibling_table(table) }
8742
+ siblings.freeze
8743
+ end
8744
+
8745
+ def freeze_sibling_table(table)
8746
+ table.each_value { |entry| freeze_sibling_table(entry) } if table.is_a?(Hash)
8747
+ table.each { |entry| entry.freeze if entry.is_a?(Array) } if table.is_a?(Set)
8748
+ table.freeze
8749
+ end
8750
+
7193
8751
  # Issue #1120 — a union, so the fold is order-independent and a bundle-served file folds exactly as its live
7194
8752
  # walk would. The slot stays nil until some file refines something (see {#finalize_def_index}).
7195
8753
  def fold_refinements(acc, file_refinements)
@@ -7264,6 +8822,7 @@ module Rigor
7264
8822
  # resolves the class as it did before the ordering fix, which is this table's empty state.
7265
8823
  accumulate_prepend_lists(acc[:prepends], file_index[:prepends] || {})
7266
8824
  accumulate_extend_lists(acc[:extends], file_index[:extends] || {})
8825
+ accumulate_unpositioned(acc[:unpositioned_mixins], file_index[:unpositioned_mixins] || {})
7267
8826
  end
7268
8827
 
7269
8828
  # The `extends` half stores singleton-ancestor search order ({#record_extend_targets}), so a file
@@ -7288,14 +8847,14 @@ module Rigor
7288
8847
  # class. Shared with {#accumulate_extend_lists}' shape deliberately — the two tables answer the
7289
8848
  # `prepend` question on the two sides of the class object, and both fold nearest-first.
7290
8849
  def accumulate_prepend_lists(target, additions)
7291
- additions.each { |cn, mods| target[cn] = (mods + (target[cn] || [])).uniq }
8850
+ additions.each { |cn, mods| target[cn] = (mods + (target[cn] || [])) }
7292
8851
  end
7293
8852
 
7294
8853
  # ADR-85 WD2 — converts a file's live single-file index + its class table into a Marshal-clean seed
7295
8854
  # bundle: the plain-data tables verbatim, the def-node tables re-expressed as `[node_id, name,
7296
8855
  # fingerprint]` triples (the path is the bundle key), the class-source names (path implicit), and the
7297
8856
  # content digest that gates the bundle's reuse.
7298
- def build_seed_bundle(file_index, file_classes, digest, code_fingerprint)
8857
+ def build_seed_bundle(file_index, file_classes, digest, code_fingerprint) # rubocop:disable Metrics/AbcSize -- one read per bundle slot
7299
8858
  {
7300
8859
  digest: digest,
7301
8860
  # B1 — the comment-stripped code fingerprint, so a recheck can prove this file's edit was
@@ -7324,6 +8883,9 @@ module Rigor
7324
8883
  # the bundle stays Marshal-clean and a warm incremental file orders its prepends the way a cold
7325
8884
  # walk of it does.
7326
8885
  prepends: file_index[:prepends],
8886
+ # The mixin edges whose order is not a fact — plain `{class name => Array[String]}` data, so the
8887
+ # bundle stays Marshal-clean and a warm file flags the edges its cold walk flags.
8888
+ unpositioned_mixins: file_index[:unpositioned_mixins],
7327
8889
  method_visibilities: file_index[:method_visibilities],
7328
8890
  methods: file_index[:methods],
7329
8891
  # Issue #992 — plain `{class name => {[kind, name] => [min, max, required_keywords] | :opaque}}`
@@ -7345,14 +8907,16 @@ module Rigor
7345
8907
  # nothing), so the bundle stays Marshal-clean.
7346
8908
  refinements: file_index[:refinements],
7347
8909
  # Issue #1367 — the file's {GlobalWriteCensus}, a Set of frozen Arrays, which Marshal round-trips.
7348
- global_write_census: file_index[:global_write_census]
8910
+ global_write_census: file_index[:global_write_census],
8911
+ # ADR-119 WD1 — `{sibling name => table}`, plain Hashes, Sets and rows, so the bundle stays Marshal-clean.
8912
+ siblings: file_index[:siblings]
7349
8913
  }
7350
8914
  end
7351
8915
 
7352
8916
  # ADR-85 WD2 — reconstitutes a cached bundle into a single-file index {#fold_file_index} folds: the
7353
8917
  # def-node triples become {DefHandle}s bound to this file's `path`, and the class-source names become a
7354
8918
  # `{name => Set[path]}` table (the shape `accumulate_project_index` produces).
7355
- def bundle_to_file_index(bundle, path)
8919
+ def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity -- one read per bundle slot
7356
8920
  {
7357
8921
  def_nodes: bundle_defs_to_handles(bundle[:def_nodes], path),
7358
8922
  singleton_def_nodes: bundle_defs_to_handles(bundle[:singleton_def_nodes], path),
@@ -7368,6 +8932,8 @@ module Rigor
7368
8932
  prepends: bundle[:prepends] || {},
7369
8933
  # #526 — pre-extends bundles lack the key; default `{}` keeps the fold total.
7370
8934
  extends: bundle[:extends] || {},
8935
+ # A pre-32 bundle lacks the key; the SCHEMA bump rebuilds it cold, and the fold defaults it anyway.
8936
+ unpositioned_mixins: bundle[:unpositioned_mixins],
7371
8937
  method_visibilities: bundle[:method_visibilities],
7372
8938
  methods: bundle[:methods],
7373
8939
  parameter_envelopes: bundle[:parameter_envelopes] || {},
@@ -7384,7 +8950,9 @@ module Rigor
7384
8950
  # Issue #1120 — absent from a pre-28 bundle, which the SCHEMA bump rebuilds cold.
7385
8951
  refinements: bundle[:refinements],
7386
8952
  # Issue #1367 — absent from a pre-29 bundle, which the SCHEMA bump rebuilds cold.
7387
- global_write_census: bundle[:global_write_census] || GlobalWriteCensus::EMPTY
8953
+ global_write_census: bundle[:global_write_census] || GlobalWriteCensus::EMPTY,
8954
+ # ADR-119 WD1 — absent from a pre-35 bundle, which the SCHEMA bump rebuilds cold.
8955
+ siblings: bundle[:siblings] || Scope::DiscoveryIndex::EMPTY_SIBLINGS
7388
8956
  }
7389
8957
  end
7390
8958
 
@@ -7415,7 +8983,8 @@ module Rigor
7415
8983
  def new_def_index_accumulator
7416
8984
  { def_nodes: {}, def_nestings: {}.compare_by_identity,
7417
8985
  singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
7418
- superclasses: {}, header_nestings: {}, includes: {}, prepends: {}, extends: {}, method_visibilities: {},
8986
+ superclasses: {}, header_nestings: {}, includes: {}, prepends: {}, extends: {}, unpositioned_mixins: {},
8987
+ method_visibilities: {},
7419
8988
  methods: {},
7420
8989
  deferred_ranges: {},
7421
8990
  parameter_envelopes: {}, class_sources: {},
@@ -7424,7 +8993,9 @@ module Rigor
7424
8993
  constant_writes: {},
7425
8994
  data_member_layouts: {}, struct_member_layouts: {},
7426
8995
  # Issue #1367 — the project's {GlobalWriteCensus}.
7427
- global_write_census: Set.new }
8996
+ global_write_census: Set.new,
8997
+ # ADR-119 WD1 — the pair siblings, `{sibling name => table}` (see {#fold_siblings}).
8998
+ siblings: Scope::DiscoveryIndex::EMPTY_SIBLINGS.dup }
7428
8999
  end
7429
9000
 
7430
9001
  # Post-processes and freezes a fully-folded def-index accumulator.
@@ -7437,27 +9008,42 @@ module Rigor
7437
9008
  # Issue #644 — resolve the cross-file constant-reassignment rule here, where the whole project's
7438
9009
  # write census is known, and turn the surviving literals into their published `Type::Constant`.
7439
9010
  acc[:constant_values], acc[:constant_sources] = finalize_constant_writes(acc[:constant_writes])
7440
- fold_extends_into_singleton_tables(acc[:extends], acc[:def_nodes], acc[:singleton_def_nodes], acc[:methods])
9011
+ # ADR-119 WD1 — the two passes below rewrite `def_nodes`, `singleton_def_nodes` and `methods`, and each carries
9012
+ # its sibling along. `subtract_def_methods` drops `def`-declared names from `methods`, and
9013
+ # {#subtract_sibling_methods!} applies the same drop to `possible_discovered_methods`. The extends fold copies
9014
+ # an extended module's instance defs onto the extender's singleton, and follows the siblings the same way (a
9015
+ # copy is possible or contested when its edge is listed in `unpositioned_mixins` or its source was).
9016
+ # Until a producer fills a sibling and an edge is listed, there is nothing for either pass to follow.
9017
+ fold_project_extends(acc)
7441
9018
  # Cross-file method suppression is for the project's OWN accessors (attr_* / define_method / alias) — NOT for
7442
9019
  # plain `def`s. A cross-file `def` on a class is exactly the ADR-17 monkey-patch case the undefined-method rule
7443
9020
  # deliberately surfaces (fire + def-site annotation, nudging `pre_eval:`), so dropping the `def`-declared names
7444
9021
  # keeps that contract intact while still letting `attr_reader :x` in one file suppress a false undefined-method
7445
9022
  # for `obj.x` in another.
7446
9023
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
9024
+ subtract_sibling_methods!(acc)
7447
9025
  finalize_call_surface_tables(acc)
7448
- %i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes prepends method_visibilities
7449
- methods parameter_envelopes class_sources constant_sources deferred_ranges].each do |key|
9026
+ %i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes prepends
9027
+ method_visibilities methods parameter_envelopes class_sources constant_sources deferred_ranges].each do |key|
7450
9028
  acc[key].each_value(&:freeze)
7451
9029
  end
7452
9030
  acc.transform_values(&:freeze)
7453
9031
  end
7454
9032
 
9033
+ # The project-wide extends fold over the accumulator's tables and siblings.
9034
+ def fold_project_extends(acc)
9035
+ fold_extends_into_singleton_tables(acc[:extends], acc[:def_nodes], acc[:singleton_def_nodes], acc[:methods],
9036
+ siblings: acc[:siblings], unpositioned: acc[:unpositioned_mixins])
9037
+ end
9038
+
7455
9039
  # The two whole-project tables only call rules read: the issue #992 envelope table gains its project-wide
7456
9040
  # key, and the issue #1120 refinement table, nil until some file refines something, settles to a frozen one.
7457
9041
  def finalize_call_surface_tables(acc)
7458
9042
  acc[:parameter_envelopes][Scope::DiscoveryIndex::ENVELOPE_PROJECT_WIDE] ||= {}
7459
9043
  acc[:refinements] = freeze_refinements(acc[:refinements])
7460
9044
  acc[:global_write_census] = acc[:global_write_census].freeze
9045
+ acc[:unpositioned_mixins].each_value { |sides| sides.each_value(&:freeze).freeze }
9046
+ freeze_siblings(acc[:siblings])
7461
9047
  end
7462
9048
 
7463
9049
  # Removes, per class, the method names that have a project `def` node, leaving only
@@ -7478,6 +9064,30 @@ module Rigor
7478
9064
  end
7479
9065
  end
7480
9066
 
9067
+ # ADR-119 WD1 — the `possible` copy of `methods` loses the INSTANCE half of a name that has a project `def`
9068
+ # (`:instance` goes, `:both` becomes `:singleton`), so `possible_discovered_methods` stays a subset of its
9069
+ # member after the rule above. A `:singleton` entry stays: the `def` is the instance side, and the member
9070
+ # keeps the name's singleton half. Only that sibling is rewritten.
9071
+ def subtract_sibling_methods!(acc)
9072
+ possible = acc[:siblings][:possible_discovered_methods]
9073
+ return if possible.nil?
9074
+
9075
+ acc[:siblings] =
9076
+ acc[:siblings].merge(possible_discovered_methods: subtract_sibling_instance_defs(possible, acc[:def_nodes]))
9077
+ end
9078
+
9079
+ def subtract_sibling_instance_defs(possible, def_nodes)
9080
+ possible.each_with_object({}) do |(class_name, table), out|
9081
+ defs = def_nodes[class_name] || {}
9082
+ kept = table.each_with_object({}) do |(method_name, kind), acc|
9083
+ next acc[method_name] = kind unless defs.key?(method_name)
9084
+
9085
+ acc[method_name] = :singleton unless kind == :instance
9086
+ end
9087
+ out[class_name] = kept unless kept.empty?
9088
+ end
9089
+ end
9090
+
7481
9091
  # Folds one file's class-keyed indexes into the cross-file accumulator. `method_visibilities` (ADR-35) is
7482
9092
  # collected here so the override-visibility-reduced rule can read an ancestor's visibility declared in a sibling
7483
9093
  # file.
@@ -7485,26 +9095,43 @@ module Rigor
7485
9095
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
7486
9096
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
7487
9097
  # def-nodes ×2). See {#build_methods_and_def_nodes}.
7488
- file_methods, file_def_nodes, file_envelopes, file_refinements = build_methods_and_def_nodes(root, path)
9098
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty =
9099
+ walk_def_contributions(acc, path, root)
7489
9100
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
7490
9101
  fold_parameter_envelopes(acc, file_envelopes)
7491
9102
  fold_refinements(acc, file_refinements)
7492
9103
  # Issue #681 — node-identity keyed, so this is a flat union: no two files can contribute the same key.
7493
- acc[:def_nestings].merge!(build_def_nestings(root))
9104
+ walked = declaration_walk_tables(root, path)
9105
+ acc[:def_nestings].merge!(walked.fetch(:def_nestings))
7494
9106
  # ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
7495
9107
  # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
7496
9108
  # produces a changed `"Class.method"` fingerprint pair (and its call sites a symbol edge) instead of
7497
9109
  # silently degrading to the file's full ancestry closure.
7498
9110
  merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
7499
- build_discovered_singleton_def_nodes(root))
7500
- superclasses, header_nestings = build_superclass_tables(root, path)
9111
+ file_singleton_def_nodes)
9112
+ superclasses = walked.fetch(:superclasses)
7501
9113
  acc[:superclasses].merge!(superclasses)
7502
- merge_header_nestings(acc[:header_nestings], header_nestings)
9114
+ merge_header_nestings(acc[:header_nestings], walked.fetch(:header_nestings))
7503
9115
  ancestry_keys = fold_file_mixin_tables(acc, root)
7504
9116
  record_file_positions(acc, path, root, superclasses, ancestry_keys, file_def_nodes)
7505
9117
  merge_constant_literal_tables(acc, root, path)
7506
- merge_class_keyed_index_tables(acc, root, file_methods)
7507
- merge_member_layout_tables(acc, root)
9118
+ merge_class_keyed_index_tables(acc, root, file_methods, certainty)
9119
+ merge_member_layout_tables(acc, walked)
9120
+ end
9121
+
9122
+ # The two def-contribution walks of {#accumulate_project_index}, over ONE {Certainty} classification, with the
9123
+ # file's siblings folded into the accumulator BEFORE its members join it (ADR-119 WD3: the `possible` rule
9124
+ # reads the accumulator's member as it stood). Returns `[methods, def_nodes, envelopes, refinements,
9125
+ # singleton_def_nodes, certainty]`, the classification passed on for the visibility walk.
9126
+ def walk_def_contributions(acc, path, root)
9127
+ certainty = Certainty.possible_nodes(root)
9128
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
9129
+ build_methods_and_def_nodes(root, path, certainty: certainty)
9130
+ file_singleton_def_nodes, file_singleton_contested = build_singleton_def_tables(root, certainty: certainty)
9131
+ fold_file_siblings(acc, file_siblings.merge(contested_discovered_singleton_def_nodes: file_singleton_contested),
9132
+ methods: file_methods, def_nodes: file_def_nodes,
9133
+ singleton_def_nodes: file_singleton_def_nodes)
9134
+ [file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty]
7508
9135
  end
7509
9136
 
7510
9137
  # Issue #1123 — this file's three instance- / singleton-side module lists, folded into the
@@ -7516,12 +9143,24 @@ module Rigor
7516
9143
  # {#accumulate_project_index} to hold its ABC budget.
7517
9144
  def fold_file_mixin_tables(acc, root)
7518
9145
  mixin = mixin_tables(root)
9146
+ extend_side = extend_tables(root)
7519
9147
  accumulate_include_lists(acc[:includes], mixin[:includes])
7520
9148
  accumulate_prepend_lists(acc[:prepends], mixin[:prepends])
7521
- accumulate_extend_lists(acc[:extends], build_discovered_extends(root))
9149
+ accumulate_extend_lists(acc[:extends], extend_side[:extends])
9150
+ accumulate_unpositioned(acc[:unpositioned_mixins], mixin[:unpositioned])
9151
+ accumulate_unpositioned(acc[:unpositioned_mixins], extend_side[:unpositioned])
7522
9152
  mixin[:includes].merge(mixin[:prepends]) { |_cn, included_mods, _prepends| included_mods }
7523
9153
  end
7524
9154
 
9155
+ # {Scope::DiscoveryIndex#unpositioned_mixins} folds by union, so the fold is order-independent and a
9156
+ # bundle-served file folds exactly as its live walk would.
9157
+ def accumulate_unpositioned(target, additions)
9158
+ additions.each do |class_name, sides|
9159
+ sitting = (target[class_name] ||= {})
9160
+ sides.each { |side, names| sitting[side] = sitting.key?(side) ? (sitting[side] | names) : names.dup }
9161
+ end
9162
+ end
9163
+
7525
9164
  # Issue #644 — folds one file's publication census into the cross-file accumulator, keyed by
7526
9165
  # (name, path) (kept out of {#accumulate_project_index} to hold its ABC budget). The conflict rule that
7527
9166
  # makes the fold order irrelevant runs at {#finalize_constant_writes}. Issue #1367 — the same walk carries the
@@ -7536,16 +9175,24 @@ module Rigor
7536
9175
 
7537
9176
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
7538
9177
  # {#accumulate_project_index} to hold its ABC budget).
7539
- def merge_member_layout_tables(acc, root)
7540
- acc[:data_member_layouts].merge!(build_data_member_layouts(root))
7541
- acc[:struct_member_layouts].merge!(build_struct_member_layouts(root))
9178
+ def merge_member_layout_tables(acc, walked)
9179
+ acc[:data_member_layouts].merge!(walked.fetch(:data_member_layouts))
9180
+ acc[:struct_member_layouts].merge!(walked.fetch(:struct_member_layouts))
7542
9181
  end
7543
9182
 
7544
9183
  # Folds the per-class method-visibility and method-existence tables of one file into the cross-file accumulator
7545
9184
  # (kept out of {#accumulate_project_index} to hold its ABC budget). `file_methods` is the existence table from the
7546
9185
  # combined methods/def-nodes descent.
7547
- def merge_class_keyed_index_tables(acc, root, file_methods)
7548
- build_discovered_method_visibilities(root).each do |class_name, table|
9186
+ #
9187
+ # ADR-119 WD3 — the visibility slots fold later-wins, so their contests follow the file that wrote them
9188
+ # ({#fold_slot_contests}), as the def-node slots' do.
9189
+ def merge_class_keyed_index_tables(acc, root, file_methods, certainty)
9190
+ file_visibilities, file_contested = build_method_visibility_tables(root, certainty: certainty)
9191
+ siblings = acc[:siblings]
9192
+ siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
9193
+ siblings[:contested_discovered_method_visibilities], file_visibilities, file_contested
9194
+ )
9195
+ file_visibilities.each do |class_name, table|
7549
9196
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
7550
9197
  end
7551
9198
  file_methods.each do |class_name, table|
@@ -7630,7 +9277,7 @@ module Rigor
7630
9277
 
7631
9278
  def constant_write_census(root)
7632
9279
  tables = CensusTables.new(writes: {}, seen: Set.new, declared: Set.new, aliases: {},
7633
- write_census: GlobalWriteCensus::Collector.new)
9280
+ write_census: GlobalWriteCensus::Collector.new(InEffectRefinements.new(root)))
7634
9281
  walk_constant_write_census(root, [], tables)
7635
9282
  tables
7636
9283
  end
@@ -7657,8 +9304,7 @@ module Rigor
7657
9304
  tables.write_census.visit(node, top_level: qualified_prefix.empty?)
7658
9305
  end
7659
9306
 
7660
- rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner,
7661
- rebound_self_base(self_owner))
9307
+ rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner, self_owner)
7662
9308
  # A `ConstantWriteNode`'s only child is its rvalue, so this reaches exactly the call whose block the
7663
9309
  # constant names — and nil everywhere else, leaving every other descent as it was.
7664
9310
  #
@@ -8181,6 +9827,16 @@ module Rigor
8181
9827
  end
8182
9828
  end
8183
9829
 
9830
+ # {#rekey_class_table} for `unpositioned_mixins`: two bodies landing on one key keep BOTH sides' names,
9831
+ # where the Hash arm of {#combine_rekeyed_entries} would replace one side's list with the other's.
9832
+ def rekey_unpositioned(table, renames)
9833
+ table.each_with_object({}) do |(name, sides), out|
9834
+ out.merge!(rename_compact_name(renames, name) => sides) do |_key, sitting, arriving|
9835
+ sitting.merge(arriving) { |_side, a, b| a | b }
9836
+ end
9837
+ end
9838
+ end
9839
+
8184
9840
  def rekey_parameter_envelopes(table, renames)
8185
9841
  table.each_with_object({}) do |(name, entries), out|
8186
9842
  out.merge!(rename_compact_name(renames, name) => entries) do |_key, sitting, arriving|
@@ -8221,7 +9877,7 @@ module Rigor
8221
9877
  # definition — the pre-#728 answer for a name no site recorded — so it keeps unioning. A KEYED entry is
8222
9878
  # the cref of the site that wrote that exact name, and the two sides are two different sites, so
8223
9879
  # unioning their chains would hand each site's ancestor the other's namespace: `Wrap::Mixin` sorts
8224
- # ahead of `::Mixin`, and `Scope#compute_ancestor_class_name` takes the first known class as the sole
9880
+ # ahead of `::Mixin`, and the ancestor-name resolution takes the first known class as the sole
8225
9881
  # resolution, so the top-level site's `include Mixin` silently became the `Wrap` one. That is not just
8226
9882
  # a wrong class, it is a FALSE POSITIVE source — the two modules' same-named methods can differ in
8227
9883
  # arity, and `call.wrong-arity` then fires on a correct program.
@@ -8309,6 +9965,8 @@ module Rigor
8309
9965
  struct_member_layouts constant_writes].each do |key|
8310
9966
  acc[key] = rekey_class_table(acc[key], renames)
8311
9967
  end
9968
+ acc[:unpositioned_mixins] = rekey_unpositioned(acc[:unpositioned_mixins], renames)
9969
+ acc[:siblings] = rename_siblings(acc[:siblings], renames)
8312
9970
  # Issue #992 — the envelope table cannot take {#combine_rekeyed_entries}' later-wins Hash merge: two
8313
9971
  # bodies of one class landing on the same key are exactly the reopening whose disagreement must
8314
9972
  # make a name opaque.
@@ -8486,10 +10144,11 @@ module Rigor
8486
10144
  Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
8487
10145
  return false unless child_prefix
8488
10146
 
8489
- if singleton_cref && !decl_nameable_under_cref?(node) && !self_decl&.any?
10147
+ if self_decl&.empty? || (singleton_cref && !decl_nameable_under_cref?(node) && !self_decl&.any?)
8490
10148
  # `class D` below `class <<` opens `#<singleton>::D` — a real class nothing can
8491
10149
  # name — so nothing is registered; the body still walks so nameable headers
8492
- # below it re-anchor at a real cref.
10150
+ # below it re-anchor at a real cref. The same holds for `class self::D` where
10151
+ # `self` is opaque (`REGISTRY.first.class_eval { … }`): `self_decl` is `[]`.
8493
10152
  if node.body
8494
10153
  record_declarations(node.body, [], identity_table, discovered, renames,
8495
10154
  nil, singleton_cref: true)
@@ -8551,7 +10210,7 @@ module Rigor
8551
10210
  record_declarations(part, qualified_prefix, identity_table, discovered,
8552
10211
  EMPTY_RENAMES, self_prefix, singleton_cref: singleton_cref)
8553
10212
  end
8554
- if (body = rvalue.block&.body)
10213
+ if (body = rvalue.block.is_a?(Prism::BlockNode) && rvalue.block.body)
8555
10214
  record_declarations(body, qualified_prefix, identity_table, discovered,
8556
10215
  EMPTY_RENAMES, child_prefix, singleton_cref: singleton_cref)
8557
10216
  end
@@ -8786,11 +10445,22 @@ module Rigor
8786
10445
  propagate(node.expression, table, current_scope)
8787
10446
  propagate(node.rescue_expression, table, current_scope.forget_error_info.forget_last_status)
8788
10447
  when Prism::PostExecutionNode then propagate_end_body(node, table, current_scope)
8789
- else
8790
- node.rigor_each_child { |child| propagate(child, table, current_scope) }
10448
+ else propagate_children(node, table, current_scope)
8791
10449
  end
8792
10450
  end
8793
10451
 
10452
+ SELF_OPENING_BODIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
10453
+ private_constant :SELF_OPENING_BODIES
10454
+
10455
+ # Issue #1717 — a `def` / `class` / `module` body opens its own `self`, so an enclosing unentered block's
10456
+ # unknown-`self` mark ({Scope#block_self_unknown?}) stops at it, as the evaluator's fresh body scope does.
10457
+ # Every other node hands its children the scope as is.
10458
+ def propagate_children(node, table, current_scope)
10459
+ child_scope =
10460
+ SELF_OPENING_BODIES.include?(node.class) ? current_scope.with_block_self_unknown(false) : current_scope
10461
+ node.rigor_each_child { |child| propagate(child, table, child_scope) }
10462
+ end
10463
+
8794
10464
  # Issue #1361 — the block of `Thread.new`, `Fiber.new`, `define_method` and the other calls
8795
10465
  # {FreshFrameBlocks.fresh_entry?} names does not read the match-global narrowing of the body it is written in.
8796
10466
  # The evaluator enters it as {FreshFrameBlocks.entry} gives ({MatchRebinding.block_entry}), but a block in a
@@ -8837,12 +10507,117 @@ module Rigor
8837
10507
  def unentered_block_entry(node, block, table, current_scope)
8838
10508
  return current_scope unless block.is_a?(Prism::BlockNode) && !table.key?(block)
8839
10509
 
10510
+ narrowing = unentered_block_narrowing(node, current_scope)
8840
10511
  current_scope = Narrowing.safe_navigation_block_scope(node, current_scope)
8841
10512
  # Issue #1429 — nor a guard's narrowing of a global or constant the body may run after code rebinds.
8842
10513
  current_scope = GuardRebinding.block_entry(current_scope, block, node)
8843
- return FreshFrameBlocks.entry(current_scope, node) if FreshFrameBlocks.fresh_entry?(node, current_scope)
10514
+ entry =
10515
+ if FreshFrameBlocks.fresh_entry?(node, current_scope)
10516
+ FreshFrameBlocks.entry(current_scope, node)
10517
+ else
10518
+ LastLine.block_entry(FreshFrameBlocks.closure_entry(current_scope, block, node), block, node)
10519
+ end
10520
+ anonymous = AnonymousMetaClass.name_for(node, current_scope.source_path)
10521
+ return unentered_meta_class_body_entry(block, entry, anonymous, table) if anonymous
10522
+
10523
+ refined = refined_body_name(node, current_scope)
10524
+ record_class_body_defs(block, entry, table, Type::Combinator.nominal_of(refined), nil) if refined
10525
+
10526
+ # Issue #1717 — the body's `self` is unknown unless narrowed, as on the evaluator's entry; only the
10527
+ # `call.undefined-method` mark is set here, never #316's opaque mark (its bind gate reads the evaluator's).
10528
+ entry = entry.with_block_self_unknown(true) unless narrowing&.self_type
10529
+ return entry unless narrowing
10530
+
10531
+ MacroBlockSelfType.apply(entry, narrowing, keeps_unknown: current_scope.block_self_narrowing_unknown?(node))
10532
+ end
10533
+
10534
+ # Issue #1120 — the class a `refine X do … end` call's block refines, or nil when the call is not that shape
10535
+ # or is a `refine` where `self` is a class (issue #1689: the class's own method, its block an ordinary one).
10536
+ # The evaluator asks the same two questions ({StatementEvaluator#refined_class_context}).
10537
+ def refined_body_name(node, scope)
10538
+ target = refine_target(node)
10539
+ return nil if target.nil? || InEffectRefinements.class_body_refine?(scope, node)
10540
+
10541
+ refined_class_name(target, scope)
10542
+ end
10543
+
10544
+ # Issue #1120 — the name of the class `refine target do … end` refines: the class `target` types as, or the
10545
+ # constant as written when it does not type as a class object (a gem class with no RBS). Shared by
10546
+ # {StatementEvaluator#refined_class_context} and {#refined_body_name}.
10547
+ def refined_class_name(target, scope)
10548
+ refined = scope.type_of(target)
10549
+ name = refined.is_a?(Type::Singleton) ? refined.class_name : Source::ConstantPath.qualified_name(target)
10550
+ name&.delete_prefix("::")
10551
+ end
10552
+
10553
+ # Issue #1696 — the entry of a `Class.new` / `Module.new` / `Struct.new` / `Data.define` block the evaluator
10554
+ # never entered because it sits in a value position: `Object.const_set(:K, Class.new { attr_reader :x })`,
10555
+ # `[Struct.new(:a) { def b = a }]`. Its `self` is the class `name` ({AnonymousMetaClass.name_for}) names, as
10556
+ # {StatementEvaluator#enter_meta_class_body} gives the statement form; left on the enclosing `self`, a
10557
+ # top-level one read every macro in it as `call.unresolved-toplevel`. Its `def`s are recorded as
10558
+ # {#record_class_body_defs} says.
10559
+ def unentered_meta_class_body_entry(block, entry, name, table)
10560
+ entry = entry.with_block_self_type(Type::Combinator.singleton_of(name)).with_singleton_class_body(false)
10561
+ record_class_body_defs(block, entry, table, Type::Combinator.nominal_of(name), entry.self_type)
10562
+ entry
10563
+ end
10564
+
10565
+ # Issue #1696 — records each `def` a value-position class-body block defines ({#each_class_body_def}) with the
10566
+ # `self` its body runs on, since no evaluator pass enters it: `instance_self` for a plain `def`, `class_self`
10567
+ # for a `def self.m` (skipped when nil). A `refine X` block passes only `Nominal[X]`: Ruby runs its defs on an
10568
+ # instance of X, but the block itself on the refinement module, which Rigor does not model, so the block keeps
10569
+ # the unknown `self` an unnarrowed block has. A `def obj.m` is left to the walk.
10570
+ def record_class_body_defs(block, entry, table, instance_self, class_self)
10571
+ each_class_body_def(block.body) do |def_node|
10572
+ next if table.key?(def_node)
10573
+
10574
+ receiver = def_node.receiver
10575
+ def_self =
10576
+ if receiver.nil? then instance_self
10577
+ elsif receiver.is_a?(Prism::SelfNode) then class_self
10578
+ end
10579
+ table[def_node] = entry.with_block_self_type(def_self) if def_self
10580
+ end
10581
+ end
10582
+
10583
+ # The `def`s a class-body block defines on its class: every one in the body, `private def m` included, short
10584
+ # of a nested block, lambda or `class` / `module` / `class << …` body, each of which opens its own.
10585
+ def each_class_body_def(node, &)
10586
+ return unless node.is_a?(Prism::Node)
10587
+ return yield(node) if node.is_a?(Prism::DefNode)
10588
+ return if CLASS_BODY_DEF_BARRIERS.include?(node.class)
8844
10589
 
8845
- LastLine.block_entry(FreshFrameBlocks.closure_entry(current_scope, block, node), block, node)
10590
+ node.rigor_each_child { |child| each_class_body_def(child, &) }
10591
+ end
10592
+
10593
+ CLASS_BODY_DEF_BARRIERS = [
10594
+ Prism::BlockNode, Prism::LambdaNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
10595
+ ].freeze
10596
+ private_constant :CLASS_BODY_DEF_BARRIERS
10597
+
10598
+ # The narrowing ({MacroBlockSelfType::Match}) an unentered block's body runs with — its `self` and, for a
10599
+ # `block_as_methods:` entry, the declared refinements (issue #1667) — when the call is one whose block the
10600
+ # engine narrows: an ADR-16 Tier A `block_as_methods:` match ({MacroBlockSelfType}) or `define_method` on the
10601
+ # lexical `self` ({DefineMethodBlockSelf}, issue #963). The evaluator applies the same two narrowings to
10602
+ # the blocks it enters ({StatementEvaluator#build_block_entry_scope}, {StatementEvaluator#enter_call_block})
10603
+ # and the value pass to every block it types ({ExpressionTyper#block_body_self_narrowing}); without this a
10604
+ # block in a value position — `puts(MyApp.get("/x") { redirect "/y" })`, `render json: Alba.serialize(x) {
10605
+ # attributes :id }`, `private define_method(:m) { helper }` — kept the enclosing `self`, and every DSL call
10606
+ # in it read as a call on that. The receiver is typed only when some plugin declares an entry for the
10607
+ # call's name, so a call no entry names costs one table lookup. A miss, or a raise, keeps the entry as
10608
+ # built — the false-positive-safe direction.
10609
+ def unentered_block_narrowing(node, scope)
10610
+ registry = scope.environment&.plugin_registry
10611
+ if registry && !registry.empty? && !registry.contribution_index.block_entries_for(node.name).empty?
10612
+ receiver_type =
10613
+ node.receiver ? scope.type_of(node.receiver) : MacroBlockSelfType.implicit_receiver_type(scope)
10614
+ match = MacroBlockSelfType.match_for(scope: scope, call_node: node, receiver_type: receiver_type)
10615
+ return match if match
10616
+ end
10617
+ defined = DefineMethodBlockSelf.narrow_self_type_for(scope: scope, call_node: node)
10618
+ defined && MacroBlockSelfType::Match.new(self_type: defined, refinements: InEffectRefinements::EMPTY)
10619
+ rescue StandardError
10620
+ nil
8846
10621
  end
8847
10622
 
8848
10623
  # The scope the children of an unentered block or lambda inherit. The evaluator enters a statement-level