rigortype 0.4.0 → 0.4.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -20,6 +20,7 @@ require_relative "last_status"
20
20
  require_relative "error_info"
21
21
  require_relative "hash_lookup_mutation"
22
22
  require_relative "index_write_widening"
23
+ require_relative "module_function_state"
23
24
  require_relative "multi_target_binder"
24
25
  require_relative "mutation_widening"
25
26
  require_relative "narrowing"
@@ -27,6 +28,14 @@ require_relative "operand_effects"
27
28
  require_relative "statement_evaluator"
28
29
  require_relative "struct_fold_safety"
29
30
  require_relative "unknown_store_widening"
31
+ require_relative "declaration_walk/traversal"
32
+ require_relative "scope_indexer/class_cvars_collector"
33
+ require_relative "scope_indexer/superclasses_collector"
34
+ require_relative "scope_indexer/def_nestings_collector"
35
+ require_relative "scope_indexer/layered_def_nestings"
36
+ require_relative "scope_indexer/mixin_accumulator"
37
+ require_relative "scope_indexer/certainty"
38
+ require_relative "scope_indexer/member_layouts_collector"
30
39
 
31
40
  module Rigor
32
41
  module Inference
@@ -98,8 +107,8 @@ module Rigor
98
107
  # propagates it across every derived scope.
99
108
  # Issue #722 residue 2 — the project's settled declaration names are the oracle the file's own
100
109
  # 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.
110
+ # reading, but it names files rather than settled declarations, and the fold has already applied its
111
+ # own re-anchoring to this table.
103
112
  declared_types, discovered_classes =
104
113
  build_declaration_artifacts(root, default_scope.discovered_classes.keys.to_set)
105
114
  # Merge the indexer's findings on top of whatever the base scope already carries so callers that seed cross-file
@@ -152,7 +161,9 @@ module Rigor
152
161
  # discovered-methods existence table and the instance def-node table — see {#build_methods_and_def_nodes}.
153
162
  # `seed_discovered_methods` seeds the former onto the scope and returns the def-node table for
154
163
  # `merge_project_method_indexes` below.
155
- seeded_scope, file_def_nodes, file_envelopes = seed_discovered_methods(seeded_scope, default_scope, root)
164
+ # ADR-119 WD3 — the file's possible contributions, classified once for both def-contribution walks.
165
+ certainty = Certainty.possible_nodes(root)
166
+ seeded_scope, file_tables = seed_discovered_methods(seeded_scope, default_scope, root, certainty)
156
167
 
157
168
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method def nodes, the class -> superclass map, and the
158
169
  # class/module -> included-modules map, each merged under the cross-file pre-pass seed (see below). v0.1.2 —
@@ -160,7 +171,7 @@ module Rigor
160
171
  # `def.method-visibility-mismatch` and ADR-35 `def.override-visibility-reduced` CheckRules consult the table.
161
172
  # Seeded inside `merge_project_method_indexes` so the per-file visibilities merge OVER the cross-file project
162
173
  # seed rather than overwriting it.
163
- seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root, file_def_nodes, file_envelopes)
174
+ seeded_scope = merge_project_method_indexes(seeded_scope, default_scope, root, file_tables, certainty)
164
175
 
165
176
  table = {}.compare_by_identity
166
177
  table.default = seeded_scope
@@ -187,11 +198,22 @@ module Rigor
187
198
  # onto `seeded_scope` (merged UNDER the cross-file pre-pass seed `default_scope` carries), and returns `[scope,
188
199
  # file_def_nodes, file_envelopes]` so the caller can thread the def-node and issue #992 envelope tables into
189
200
  # {#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)
201
+ #
202
+ # ADR-119 WD3 — the file's `possible_discovered_methods` folds over the seed's by {#fold_possible_methods}, so a
203
+ # name the file defines certainly is no longer possible and a name only the file's possible contribution
204
+ # supplies becomes possible. The file's other siblings are returned for {#merge_project_method_indexes}.
205
+ def seed_discovered_methods(seeded_scope, default_scope, root, certainty = Certainty.possible_nodes(root))
206
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
207
+ build_methods_and_def_nodes(root, default_scope.source_path, certainty: certainty)
208
+ seed_methods = default_scope.discovered_methods
209
+ discovered_methods = deep_merge_class_methods(seed_methods, file_methods)
210
+ discovery = seeded_scope.discovery.with(
211
+ discovered_methods: discovered_methods,
212
+ possible_discovered_methods: fold_possible_methods(
213
+ seeded_scope.discovery.possible_discovered_methods, seed_methods,
214
+ file_siblings[:possible_discovered_methods], file_methods
215
+ )
216
+ )
195
217
  # Issue #1120 — only a file that refines something pays the overlay; the cross-file seed already carries
196
218
  # every other file's refinements.
197
219
  unless file_refinements.empty?
@@ -199,7 +221,7 @@ module Rigor
199
221
  discovered_refinements: merge_refinement_tables(default_scope.discovered_refinements, file_refinements)
200
222
  )
201
223
  end
202
- [seeded_scope.with_discovery(discovery), file_def_nodes, file_envelopes]
224
+ [seeded_scope.with_discovery(discovery), [file_def_nodes, file_envelopes, file_siblings]]
203
225
  end
204
226
 
205
227
  # ADR-48 Struct slice 3 — installs the top-level fold-safe-local set ({Inference::StructFoldSafety}). Struct
@@ -220,19 +242,22 @@ module Rigor
220
242
  # Issue #992 — the envelope table is JOINED with the seed rather than overlaid: the seed already carries this
221
243
  # file's own contribution (identical, so the join keeps it), and a reopening in a sibling file must still
222
244
  # 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)
245
+ #
246
+ # `file_tables` is `[file_def_nodes, file_envelopes, file_siblings]` from {#seed_discovered_methods}.
247
+ def merge_project_method_indexes(seeded_scope, default_scope, root, file_tables,
248
+ certainty = Certainty.possible_nodes(root))
249
+ # ADR-116 WD5 — the superclass, def-nesting and member-layout tables come from ONE shared walk.
250
+ walked = declaration_walk_tables(root, default_scope.source_path)
251
+ def_nodes, def_nestings = merge_def_node_tables(default_scope, walked, file_tables[0])
252
+ singleton_def_nodes, file_singleton = merge_singleton_def_nodes(default_scope, root, certainty)
253
+ superclasses, header_nestings = merge_ancestry_tables(default_scope, walked)
254
+ includes, prepends, *mixin_unpositioned = merge_mixin_tables(default_scope, root)
228
255
  # ADR-35 — per-file visibilities merged OVER the cross-file seed (the current file is authoritative for its own
229
256
  # 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) }
257
+ method_visibilities, file_visibilities = merge_method_visibilities(default_scope, root, certainty)
233
258
  # ADR-48 — per-file Data + Struct member layouts merged OVER the cross-file seed (same-file declaration is
234
259
  # authoritative).
235
- data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, root)
260
+ data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, walked)
236
261
 
237
262
  # #526 — this file's extends folded against the MERGED instance def-nodes (so `extend M` sees a
238
263
  # sibling-file M through the cross-file seed). The project-wide fold in {#finalize_def_index}
@@ -240,8 +265,11 @@ module Rigor
240
265
  #
241
266
  # Issue #898 — and the same walk's table is now kept, merged over the cross-file seed the way
242
267
  # `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)
268
+ siblings = per_file_siblings(seeded_scope.discovery.sibling_tables, file_tables[0], file_tables[2],
269
+ file_singleton, file_visibilities)
270
+ extends, methods_table, unpositioned =
271
+ merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
272
+ mixin_unpositioned)
245
273
 
246
274
  seeded_scope.with_discovery(
247
275
  seeded_scope.discovery.with(
@@ -252,23 +280,60 @@ module Rigor
252
280
  discovered_superclasses: superclasses,
253
281
  discovered_header_nestings: header_nestings,
254
282
  discovered_includes: includes,
255
- discovered_prepends: prepends,
256
- discovered_extends: extends,
283
+ discovered_prepends: prepends, discovered_extends: extends,
284
+ unpositioned_mixins: unpositioned,
257
285
  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)
286
+ discovered_parameter_envelopes: merge_envelope_seed(default_scope, file_tables[1]),
287
+ data_member_layouts: data_member_layouts, struct_member_layouts: struct_member_layouts,
288
+ discovered_deferred_ranges: merge_deferred_ranges_seed(default_scope, root),
289
+ # ADR-119 WD1/WD3 — the seed's siblings with this file's folded in ({#per_file_siblings}: the file wins
290
+ # every slot it writes), which the extends fold above then followed (copy on write, so the seed's
291
+ # tables are never written).
292
+ **siblings
262
293
  )
263
294
  )
264
295
  end
265
296
 
297
+ # Returns `[merged table, [file table, file contested slots]]` ({#build_method_visibility_tables}).
298
+ def merge_method_visibilities(default_scope, root, certainty)
299
+ file_visibilities = build_method_visibility_tables(root, certainty: certainty)
300
+ merged = default_scope.discovered_method_visibilities.merge(
301
+ file_visibilities.first
302
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
303
+ [merged, file_visibilities]
304
+ end
305
+
266
306
  # Per-file singleton def nodes merged OVER the cross-file seed (same-file declaration is
267
307
  # 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)
308
+ #
309
+ # Returns `[merged table, [file table, file contested slots]]` ({#build_singleton_def_tables}).
310
+ def merge_singleton_def_nodes(default_scope, root, certainty)
311
+ file_singleton = build_singleton_def_tables(root, certainty: certainty)
312
+ merged = default_scope.discovered_singleton_def_nodes.merge(
313
+ file_singleton.first
271
314
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
315
+ [merged, file_singleton]
316
+ end
317
+
318
+ # ADR-119 WD3 — the per-file merge of the slot siblings, under the members' own merges: this file's
319
+ # `def_nodes`, singleton defs and visibilities win every slot they write, so the seed's contest on those keys
320
+ # is replaced by the file's ({#fold_slot_contests}); the envelopes are joined, so their contests union. The
321
+ # possible methods were folded already ({#seed_discovered_methods}).
322
+ def per_file_siblings(siblings, file_def_nodes, file_siblings, file_singleton, file_visibilities)
323
+ file_singleton_def_nodes, file_singleton_contested = file_singleton
324
+ siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
325
+ siblings[:contested_discovered_method_visibilities], *file_visibilities
326
+ )
327
+ siblings[:contested_discovered_def_nodes] = fold_slot_contests(
328
+ siblings[:contested_discovered_def_nodes], file_def_nodes, file_siblings[:contested_discovered_def_nodes]
329
+ )
330
+ siblings[:contested_discovered_singleton_def_nodes] = fold_slot_contests(
331
+ siblings[:contested_discovered_singleton_def_nodes], file_singleton_def_nodes, file_singleton_contested
332
+ )
333
+ siblings[:contested_discovered_parameter_envelopes] = union_paths(
334
+ siblings[:contested_discovered_parameter_envelopes], file_siblings[:contested_discovered_parameter_envelopes]
335
+ )
336
+ siblings
272
337
  end
273
338
 
274
339
  # Issue #1123 — the two instance-side mixin tables, from ONE descent of this file. Each merges over
@@ -284,28 +349,60 @@ module Rigor
284
349
  end,
285
350
  default_scope.discovered_prepends.merge(file[:prepends]) do |_class, cross_file, per_file|
286
351
  (per_file + cross_file).uniq
287
- end
352
+ end,
353
+ file[:unpositioned]
288
354
  ]
289
355
  end
290
356
 
357
+ # The union of `{class => {side => [names]}}` tables — how {Scope::DiscoveryIndex#unpositioned_mixins}
358
+ # folds: an edge any contribution cannot position stays unpositioned. The per-file index folds its own
359
+ # walk over a seed that already carries this file's edges, so the first table itself comes back unless a
360
+ # contribution holds a name the seed lacks: the whole project table is copied only then.
361
+ def union_unpositioned(first, *others)
362
+ result = nil
363
+ others.each do |table|
364
+ table.each do |owner, sides|
365
+ sides.each do |side, names|
366
+ have = (result || first).dig(owner, side)
367
+ next if have && names.all? { |name| have.include?(name) }
368
+
369
+ result ||= first.dup
370
+ result[owner] = (result[owner] || {}).merge(side => ((have || []) | names).freeze).freeze
371
+ end
372
+ end
373
+ end
374
+ result ? result.freeze : first
375
+ end
376
+
291
377
  # The `extend`-edge half of {#merge_project_method_indexes}: merges this file's `extend`s over the
292
378
  # cross-file seed AND folds them against the merged def tables — the #526 fold that turns an
293
379
  # 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]
380
+ #
381
+ # ADR-119 WD1 — the unpositioned union (the seed's, the mixin walks' and this file's extend side) is settled
382
+ # first, because the fold reads it to tell a certain extend edge from a listed one. `siblings` is written by
383
+ # the fold.
384
+ def merge_and_fold_extends(default_scope, root, def_nodes, singleton_def_nodes, seeded_scope, siblings,
385
+ mixin_unpositioned)
386
+ file_extends, extends, extend_unpositioned = merge_extend_tables(default_scope, root)
387
+ unpositioned = union_unpositioned(default_scope.discovery.unpositioned_mixins, *mixin_unpositioned,
388
+ extend_unpositioned)
389
+ methods_table = fold_per_file_extends(file_extends, def_nodes, singleton_def_nodes, seeded_scope, siblings,
390
+ unpositioned)
391
+ [extends, methods_table, unpositioned]
298
392
  end
299
393
 
300
394
  # Issue #1097 — this file's def / block / lambda ranges merged over the cross-file seed; the
301
395
  # 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.
396
+ # that never built the project pre-pass. A seeded entry is only the identical walk product when
397
+ # the pre-pass read the same bytes (a mutant analysed under a seed of the unmutated files does
398
+ # not), so the file is always re-walked and the seed is kept only when the rows are equal — which
399
+ # spares the project-wide Hash copy per file. Issue #1548.
304
400
  def merge_deferred_ranges_seed(default_scope, root)
305
401
  seeded = default_scope.discovered_deferred_ranges
306
- return seeded if seeded.key?(default_scope.source_path)
402
+ fresh = build_deferred_ranges(root)
403
+ return seeded if seeded[default_scope.source_path] == fresh
307
404
 
308
- seeded.merge(default_scope.source_path => build_deferred_ranges(root))
405
+ seeded.merge(default_scope.source_path => fresh)
309
406
  end
310
407
 
311
408
  def merge_envelope_seed(default_scope, file_envelopes)
@@ -315,70 +412,69 @@ module Rigor
315
412
  # The as-written superclass table and its issue #682 header-nesting twin, each merged over the cross-file
316
413
  # seed. Returned as a pair for the same reason {#merge_def_node_tables} is: both come from ONE walk of the
317
414
  # 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),
415
+ def merge_ancestry_tables(default_scope, walked)
416
+ [default_scope.discovered_superclasses.merge(walked.fetch(:superclasses)),
321
417
  merge_header_nestings(default_scope.discovery.discovered_header_nestings.dup,
322
- file_header_nestings).freeze]
418
+ walked.fetch(:header_nestings)).freeze]
323
419
  end
324
420
 
325
421
  # The instance-side def-node table and its issue #681 nesting twin, each merged over the cross-file seed.
326
422
  # Returned as a pair so the two stay written together: a node the merge keeps must be the same object the
327
423
  # 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)
424
+ def merge_def_node_tables(default_scope, walked, file_def_nodes)
329
425
  def_nodes = default_scope.discovered_def_nodes.merge(
330
426
  file_def_nodes
331
427
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
332
428
  [def_nodes,
333
- merge_def_nestings(default_scope.discovery.discovered_def_nestings, build_def_nestings(root))]
429
+ merge_def_nestings(default_scope.discovery.discovered_def_nestings, walked.fetch(:def_nestings))]
334
430
  end
335
431
 
336
432
  # 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.
433
+ # a same-file declaration and its cross-file twin are distinct keys and the layering order is immaterial.
434
+ # Issue #1507 — layered by lookup ({LayeredDefNestings}) instead of copied: the copy was one insert per
435
+ # `def` in the project for every analysed file. Either table alone is returned as it is when the other is
436
+ # empty — since issue #716 a top-level `def` records its empty chain, so only a file declaring no `def` at
437
+ # all hands back the seed itself.
341
438
  def merge_def_nestings(seed, file_nestings)
342
439
  return seed if file_nestings.empty?
343
440
  return file_nestings if seed.empty?
344
441
 
345
- merged = {}.compare_by_identity
346
- merged.merge!(seed)
347
- merged.merge!(file_nestings)
348
- merged.freeze
442
+ LayeredDefNestings.new(file_nestings, seed)
349
443
  end
350
444
 
351
445
  # Issue #898 — one walk, two consumers: the raw per-file table the #526 method fold reads, and the
352
446
  # same table merged over the cross-file seed for the scope. Returned as a pair so a caller cannot
353
447
  # pair the fold with a table a different parse produced, as {#merge_ancestry_tables} is.
354
448
  def merge_extend_tables(default_scope, root)
355
- file_extends = build_discovered_extends(root)
449
+ file = extend_tables(root)
450
+ file_extends = file.fetch(:extends)
356
451
  # The table stores singleton-ancestor search order, so the file under analysis — the
357
452
  # later-loading contribution for a reopened class — prepends over the cross-file seed.
358
453
  merged = default_scope.discovered_extends.merge(
359
454
  file_extends
360
455
  ) { |_class, cross_file, per_file| (per_file + cross_file).uniq }
361
- [file_extends, merged]
456
+ [file_extends, merged, file.fetch(:unpositioned)]
362
457
  end
363
458
 
364
459
  # The per-file half of the #526 fold: mutable copies of the merged tables take the extends, and the
365
460
  # 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)
461
+ def fold_per_file_extends(extends, def_nodes, singleton_def_nodes, seeded_scope, siblings, unpositioned)
367
462
  methods_table = seeded_scope.discovered_methods
368
463
  return methods_table if extends.empty?
369
464
 
370
465
  mutable_methods = methods_table.transform_values(&:dup)
371
- fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods)
466
+ fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods,
467
+ siblings: siblings, unpositioned: unpositioned)
372
468
  mutable_methods
373
469
  end
374
470
 
375
471
  # ADR-48 — the per-file Data + Struct member-layout tables, each merged OVER the cross-file seed so a same-file
376
472
  # declaration wins for its own classes. Returned as a pair to keep {#merge_project_method_indexes} under the
377
473
  # method-size budget.
378
- def merge_member_layouts(default_scope, root)
474
+ def merge_member_layouts(default_scope, walked)
379
475
  [
380
- default_scope.data_member_layouts.merge(build_data_member_layouts(root)),
381
- default_scope.struct_member_layouts.merge(build_struct_member_layouts(root))
476
+ default_scope.data_member_layouts.merge(walked.fetch(:data_member_layouts)),
477
+ default_scope.struct_member_layouts.merge(walked.fetch(:struct_member_layouts))
382
478
  ]
383
479
  end
384
480
 
@@ -1946,7 +2042,20 @@ module Rigor
1946
2042
  # `Prism::ClassVariableWriteNode` writes inside ANY def body (instance or singleton) of the enclosing class,
1947
2043
  # because Ruby cvars are shared across both facets. The resulting table is seeded into both instance and singleton
1948
2044
  # method bodies through `Scope#class_cvars_for`.
2045
+ #
2046
+ # ADR-116 WD5 — built by {ClassCvarsCollector} on the shared {DeclarationWalk}; `RIGOR_SHADOW_RULE_WALK`
2047
+ # checks it against {#legacy_class_cvar_index}.
1949
2048
  def build_class_cvar_index(root, default_scope)
2049
+ collector = ClassCvarsCollector.new
2050
+ DeclarationWalk.run(root, [collector], DeclarationWalk::Context.root(scope: default_scope))
2051
+ DeclarationWalk::Shadow.verified(:class_cvars, default_scope.source_path, collector.table) do
2052
+ legacy_class_cvar_index(root, default_scope)
2053
+ end
2054
+ end
2055
+
2056
+ # The walker {ClassCvarsCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
2057
+ # ported every table walker onto {DeclarationWalk}.
2058
+ def legacy_class_cvar_index(root, default_scope)
1950
2059
  accumulator = {}
1951
2060
  walk_class_cvars(root, [], default_scope, accumulator)
1952
2061
  accumulator.transform_values(&:freeze).freeze
@@ -2867,8 +2976,7 @@ module Rigor
2867
2976
  # `ArgumentsNode`, and a `&blk` pass-through is a `BlockArgumentNode`.
2868
2977
  def walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner,
2869
2978
  singleton_cref)
2870
- rebound = rebound_block_self(node, qualified_prefix, default_scope, nil,
2871
- rebound_self_base(self_owner))
2979
+ rebound = rebound_block_self(node, qualified_prefix, default_scope, nil, self_owner)
2872
2980
  node.rigor_each_child do |child|
2873
2981
  rebinds_self = rebound && child.is_a?(Prism::BlockNode)
2874
2982
  owner = rebinds_self ? rebound : self_owner
@@ -2894,14 +3002,18 @@ module Rigor
2894
3002
  # caller ({#meta_new_block_owner}) ([#710](https://github.com/rigortype/rigor/issues/710)). It is
2895
3003
  # supplied only by the publication census; the typed walk returns at a `ConstantWriteNode` without
2896
3004
  # descending into its rvalue, so no `self::` write inside one reaches that walk at all.
3005
+ #
3006
+ # `self_owner` is the enclosing body's self as the walk carries it, unsplit. Every walk asks this at
3007
+ # every node it visits, and only an eval call reads the self, so the split waits until one does
3008
+ # ([#1502](https://github.com/rigortype/rigor/issues/1502)).
2897
3009
  def rebound_block_self(node, qualified_prefix, default_scope = nil, meta_owner = nil,
2898
- self_prefix = nil)
3010
+ self_owner = nil)
2899
3011
  return nil unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
2900
3012
  return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name)
2901
3013
  return meta_owner || OPAQUE_SELF if meta_new_constant_rvalue?(node)
2902
3014
  return nil unless RECEIVER_EVAL_CALLS.include?(node.name)
2903
3015
 
2904
- eval_receiver_self(node.receiver, qualified_prefix, default_scope, self_prefix)
3016
+ eval_receiver_self(node.receiver, qualified_prefix, default_scope, rebound_self_base(self_owner))
2905
3017
  end
2906
3018
 
2907
3019
  # Issue #710 — the qualified name a `Klass = Class.new { … }` gives the block's class, or nil when
@@ -3128,20 +3240,59 @@ module Rigor
3128
3240
  # Issue #1120 — and a fourth, `refinements`: `{refined class => {method => [refining modules]}}`, the
3129
3241
  # `def`s of every `refine X do … end` body ({#record_refinement_defs}). Those are kept OUT of the first
3130
3242
  # 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({}, {})
3243
+ #
3244
+ # ADR-119 WD3 — and a fifth, the file's def-contribution siblings ({MethodTables#siblings}):
3245
+ # `possible_discovered_methods`, `contested_discovered_def_nodes` and
3246
+ # `contested_discovered_parameter_envelopes`, from the {Certainty} classification `certainty` (computed here
3247
+ # when the caller has none).
3248
+ def build_methods_and_def_nodes(root, source_path = nil, certainty: Certainty.possible_nodes(root))
3249
+ tables = MethodTables.new({}, {}, nil, certainty, true)
3133
3250
  def_nodes = {}
3134
3251
  walk_methods_and_def_nodes(root, [], false, tables, def_nodes, source_path)
3135
- apply_alias_def_nodes(root, def_nodes)
3252
+ apply_alias_def_nodes(root, def_nodes, tables)
3136
3253
  [tables.existence.transform_values(&:freeze).freeze, def_nodes.transform_values(&:freeze).freeze,
3137
- tables.envelopes.transform_values(&:freeze).freeze, freeze_refinements(tables.refinements)]
3254
+ tables.envelopes.transform_values(&:freeze).freeze, freeze_refinements(tables.refinements),
3255
+ tables.siblings]
3138
3256
  end
3139
3257
 
3140
3258
  # The accumulator {#walk_methods_and_def_nodes} threads: the existence table and its issue #992
3141
3259
  # envelope twin, which only {#record_method} and {#record_surface_mark} write, and the issue #1120
3142
3260
  # refinement table, which only {#record_refinement_defs} writes. That last one stays nil until a
3143
3261
  # `refine` block is seen, so a file without one allocates nothing for it.
3144
- MethodTables = Struct.new(:existence, :envelopes, :refinements)
3262
+ #
3263
+ # ADR-119 WD3 — `certainty` is the file's {Certainty} answer and `certain` whether the contribution being
3264
+ # recorded is certain ({#at} sets it before each recorder). `possible`, `contested_envelopes` and
3265
+ # `contested_def_nodes` are the siblings the recorders fill, each nil until a possible contribution writes it.
3266
+ MethodTables = Struct.new(:existence, :envelopes, :refinements, :certainty, :certain, :possible,
3267
+ :contested_envelopes, :contested_def_nodes) do
3268
+ # Sets {#certain} for the contribution `node` and returns the tables.
3269
+ def at(node)
3270
+ self.certain = !Certainty.possible?(certainty, node)
3271
+ self
3272
+ end
3273
+
3274
+ # The `{sibling name => table}` part this walk fills, frozen, with the shared empty value for each
3275
+ # sibling nothing wrote.
3276
+ def siblings
3277
+ empty = Scope::DiscoveryIndex::EMPTY_SIBLINGS
3278
+ {
3279
+ possible_discovered_methods:
3280
+ possible ? possible.transform_values(&:freeze).freeze : empty[:possible_discovered_methods],
3281
+ contested_discovered_def_nodes:
3282
+ frozen_paths(contested_def_nodes) || empty[:contested_discovered_def_nodes],
3283
+ contested_discovered_parameter_envelopes:
3284
+ frozen_paths(contested_envelopes) || empty[:contested_discovered_parameter_envelopes]
3285
+ }.freeze
3286
+ end
3287
+
3288
+ private
3289
+
3290
+ def frozen_paths(set)
3291
+ return nil if set.nil? || set.empty?
3292
+
3293
+ set.each(&:freeze).freeze
3294
+ end
3295
+ end
3145
3296
 
3146
3297
  EMPTY_REFINEMENTS = {}.freeze
3147
3298
  private_constant :EMPTY_REFINEMENTS
@@ -3215,9 +3366,58 @@ module Rigor
3215
3366
 
3216
3367
  # The walk's single existence writer. Everything except a `def` passes no envelope and so records
3217
3368
  # {Source::ParameterEnvelope::OPAQUE}.
3369
+ #
3370
+ # ADR-119 WD3 — the existence table's `possible` sibling follows the contribution's certainty
3371
+ # ({MethodTables#certain}): a certain contribution removes its kind from the name's possible kind, and a
3372
+ # possible one adds its kind unless the table already answered that kind certainly. A possible contribution
3373
+ # also contests the envelope entry it joins (envelopes are joined, so one possible joiner is enough).
3218
3374
  def record_method(tables, class_name, method_name, kind, envelope = Source::ParameterEnvelope::OPAQUE)
3375
+ follow_method_certainty(tables, class_name, method_name, kind) unless tables.certain && tables.possible.nil?
3219
3376
  record_method_kind(tables.existence, class_name, method_name, kind)
3220
3377
  record_envelope(tables.envelopes, class_name, [kind, method_name], envelope)
3378
+ contest_envelope(tables, class_name, [kind, method_name]) unless tables.certain
3379
+ end
3380
+
3381
+ # The `possible_discovered_methods` half of {#record_method}, read BEFORE the existence table is written.
3382
+ def follow_method_certainty(tables, class_name, method_name, kind)
3383
+ if tables.certain
3384
+ settle_possible_kind(tables.possible, class_name, method_name, kind)
3385
+ else
3386
+ sitting = tables.possible&.dig(class_name, method_name)
3387
+ return if kind_covers?(tables.existence[class_name]&.[](method_name), kind) && !kind_covers?(sitting, kind)
3388
+
3389
+ row = ((tables.possible ||= {})[class_name] ||= {})
3390
+ row[method_name] = merge_sibling_kind(sitting, kind)
3391
+ end
3392
+ end
3393
+
3394
+ # A certain contribution: `kind` leaves the name's possible kind, and an emptied row goes.
3395
+ def settle_possible_kind(possible, class_name, method_name, kind)
3396
+ row = possible[class_name]
3397
+ return if row.nil?
3398
+
3399
+ remaining = without_kind(row[method_name], kind)
3400
+ return if remaining == row[method_name]
3401
+
3402
+ remaining.nil? ? row.delete(method_name) : (row[method_name] = remaining)
3403
+ possible.delete(class_name) if row.empty?
3404
+ end
3405
+
3406
+ # Whether a recorded kind (`:instance`, `:singleton`, `:both`, or nil) answers `kind`.
3407
+ def kind_covers?(recorded, kind)
3408
+ !recorded.nil? && (recorded == kind || recorded == Scope::DiscoveryIndex::METHOD_KIND_BOTH)
3409
+ end
3410
+
3411
+ # `recorded` less `kind`: nil when nothing remains.
3412
+ def without_kind(recorded, kind)
3413
+ return nil if recorded.nil? || recorded == kind
3414
+ return recorded unless recorded == Scope::DiscoveryIndex::METHOD_KIND_BOTH
3415
+
3416
+ kind == :instance ? :singleton : :instance
3417
+ end
3418
+
3419
+ def contest_envelope(tables, class_name, key)
3420
+ (tables.contested_envelopes ||= Set.new) << [class_name, key]
3221
3421
  end
3222
3422
 
3223
3423
  def record_envelope(envelopes, class_name, key, envelope)
@@ -3303,7 +3503,7 @@ module Rigor
3303
3503
  # `self::` header under a REBOUND self (eval/meta-new body) names
3304
3504
  # `owner::Name` instead.
3305
3505
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
3306
- record_declaration_facts(node, child_prefix, methods_acc) unless child_cref
3506
+ record_declaration_facts(node, child_prefix, methods_acc.at(node)) unless child_cref
3307
3507
  body_prefix = child_cref ? [] : child_prefix
3308
3508
  if node.body
3309
3509
  walk_methods_and_def_nodes(node.body, body_prefix, false, methods_acc, def_nodes_acc,
@@ -3346,7 +3546,9 @@ module Rigor
3346
3546
  child_prefix = meta_new_child_prefix(node, qualified_prefix, def_owner_prefix)
3347
3547
  meta_ownerless = singleton_cref &&
3348
3548
  !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
3549
+ if child_prefix && !meta_ownerless
3550
+ record_meta_new_facts(meta_new_rvalue(node), child_prefix, methods_acc.at(node))
3551
+ end
3350
3552
  enclosing.each do |part|
3351
3553
  walk_methods_and_def_nodes(part, qualified_prefix, in_singleton_class, methods_acc,
3352
3554
  def_nodes_acc, source_path, def_owner_prefix,
@@ -3373,16 +3575,16 @@ module Rigor
3373
3575
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3374
3576
  (singleton_cref && owner_prefix.empty?)
3375
3577
  singleton_def = in_singleton_class || defs_singleton
3376
- record_def_method(node, owner_prefix, singleton_def, methods_acc)
3578
+ record_def_method(node, owner_prefix, singleton_def, methods_acc.at(node))
3377
3579
  record_def_body_evidence(node, owner_prefix, methods_acc)
3378
- record_def_node(node, owner_prefix, singleton_def, def_nodes_acc)
3580
+ record_def_node(node, owner_prefix, singleton_def, def_nodes_acc, methods_acc)
3379
3581
  end
3380
3582
  return
3381
3583
  when Prism::AliasMethodNode, Prism::UndefNode
3382
3584
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3383
3585
  (singleton_cref && owner_prefix.empty?)
3384
3586
  record_alias_or_undef(node, owner_prefix, in_singleton_class || defs_singleton,
3385
- methods_acc)
3587
+ methods_acc.at(node))
3386
3588
  end
3387
3589
  return
3388
3590
  when Prism::CallNode
@@ -3392,7 +3594,8 @@ module Rigor
3392
3594
  singleton_cref: singleton_cref,
3393
3595
  defs_singleton: defs_singleton)
3394
3596
  end
3395
- anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc, source_path)
3597
+ anonymous = record_call_node_methods(node, owner_prefix, in_singleton_class, methods_acc.at(node),
3598
+ source_path)
3396
3599
  # Issue #1120 — a refine body's defs go to the refinement table and nowhere else.
3397
3600
  if (target = refine_target(node))
3398
3601
  return record_refinement_defs(node, target, qualified_prefix, owner_prefix, methods_acc)
@@ -3514,7 +3717,7 @@ module Rigor
3514
3717
  def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc, # rubocop:disable Metrics/ParameterLists
3515
3718
  def_nodes_acc, source_path, def_owner_prefix = nil,
3516
3719
  singleton_cref: false, defs_singleton: false)
3517
- record_meta_members(call_node, [name], methods_acc)
3720
+ record_meta_members(call_node, [name], methods_acc.at(call_node))
3518
3721
  call_node.rigor_each_child do |child|
3519
3722
  if child.equal?(call_node.block)
3520
3723
  body = call_node.block.body
@@ -3928,13 +4131,30 @@ module Rigor
3928
4131
  # same-named local method.
3929
4132
  TOP_LEVEL_DEF_KEY = "<toplevel>"
3930
4133
 
3931
- def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
4134
+ #
4135
+ # ADR-119 WD3 — the slot follows its writer (later-wins): a certain write clears the key from
4136
+ # `contested_discovered_def_nodes` and a possible one adds it ({MethodTables#certain}, set for `def_node`).
4137
+ def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator, tables = nil)
3932
4138
  return if def_singleton?(def_node, qualified_prefix, in_singleton_class)
3933
4139
 
3934
4140
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
3935
4141
  accumulator[class_name] ||= {}
3936
4142
  accumulator[class_name][def_node.name] = def_node
3937
- record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator)
4143
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, def_node.name], tables.nil? || tables.certain)
4144
+ record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables)
4145
+ end
4146
+
4147
+ # ADR-119 WD3 — a single-valued slot's contest follows the write that holds it: `certain` clears `key` from
4148
+ # the `slot` Set of `tables`, a possible write adds it. Nil `tables` (a caller with no certainty) is a no-op.
4149
+ def follow_slot_writer(tables, slot, key, certain)
4150
+ return if tables.nil?
4151
+
4152
+ if certain
4153
+ set = tables[slot]
4154
+ set&.delete(key)
4155
+ else
4156
+ (tables[slot] ||= Set.new) << key
4157
+ end
3938
4158
  end
3939
4159
 
3940
4160
  # #319 — a `def` inside an anonymous `Class.new` / `Module.new` body ALSO stays in the `<toplevel>` table.
@@ -3946,12 +4166,16 @@ module Rigor
3946
4166
  # `spawn(...) { start }` block. Giving the body a class of its own must not silently retract it: the call
3947
4167
  # resolves at runtime, and `call.unresolved-toplevel` firing on it would be a new false positive traded
3948
4168
  # 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)
4169
+ def record_anonymous_body_def_as_toplevel(def_node, qualified_prefix, accumulator, tables = nil)
3950
4170
  return unless qualified_prefix.length == 1
3951
4171
  return unless Type::AnonymousClassName.match?(qualified_prefix.first)
3952
4172
 
3953
4173
  table = (accumulator[TOP_LEVEL_DEF_KEY] ||= {})
3954
- table[def_node.name] ||= def_node
4174
+ return if table.key?(def_node.name)
4175
+
4176
+ table[def_node.name] = def_node
4177
+ follow_slot_writer(tables, :contested_def_nodes, [TOP_LEVEL_DEF_KEY, def_node.name],
4178
+ tables.nil? || tables.certain)
3955
4179
  end
3956
4180
 
3957
4181
  # Module-singleton call resolution (ADR-57 follow-up) — the SINGLETON-side mirror of `build_discovered_def_nodes`.
@@ -3963,9 +4187,43 @@ module Rigor
3963
4187
  # singleton defs (`def self.x` outside any class — `self` is `main`) are not recorded; they have no constant
3964
4188
  # receiver to dispatch through.
3965
4189
  def build_discovered_singleton_def_nodes(root)
3966
- accumulator = {}
4190
+ build_singleton_def_tables(root).first
4191
+ end
4192
+
4193
+ # ADR-119 WD3 — {#build_discovered_singleton_def_nodes} with its sibling: `[table,
4194
+ # contested_discovered_singleton_def_nodes]`, the second a frozen Set of `[class, name]` keys whose slot a
4195
+ # possible `def self.x`, `class << self` def or `module_function` copy wrote last ({Certainty} `certainty`).
4196
+ def build_singleton_def_tables(root, certainty: Certainty.possible_nodes(root))
4197
+ accumulator = SingletonDefTable.new.track(certainty)
3967
4198
  walk_singleton_def_nodes(root, [], false, accumulator)
3968
- accumulator.transform_values(&:freeze).freeze
4199
+ [accumulator.transform_values(&:freeze).freeze, accumulator.contested_paths]
4200
+ end
4201
+
4202
+ # The singleton walk's accumulator: the `{class => {name => DefNode}}` table, plus the contested slots, which
4203
+ # follow each slot's writer ({#follow_slot_writer}'s rule).
4204
+ class SingletonDefTable < Hash
4205
+ def track(certainty)
4206
+ @certainty = certainty
4207
+ @contested = nil
4208
+ self
4209
+ end
4210
+
4211
+ # Writes the slot; `node` and `other` are the contributions the write rests on, possible when either is.
4212
+ def write(class_name, name, def_node, node, other = nil)
4213
+ (self[class_name] ||= {})[name] = def_node
4214
+ if Certainty.possible?(@certainty, node) || (other && Certainty.possible?(@certainty, other))
4215
+ (@contested ||= Set.new) << [class_name, name]
4216
+ else
4217
+ @contested&.delete([class_name, name])
4218
+ end
4219
+ end
4220
+
4221
+ def contested_paths
4222
+ contested = @contested
4223
+ return contested.each(&:freeze).freeze if contested && !contested.empty?
4224
+
4225
+ Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_singleton_def_nodes]
4226
+ end
3969
4227
  end
3970
4228
 
3971
4229
  # Issue #1097 — `[[start_offset, end_offset, name, kind, owner], ...]` for every `def` / block /
@@ -4004,12 +4262,14 @@ module Rigor
4004
4262
  defs_singleton: false)
4005
4263
  return unless node.is_a?(Prism::Node)
4006
4264
 
4007
- if CLASS_BODY_NODES.any? { |kind| node.is_a?(kind) }
4265
+ # Tested by exact class (Prism's node classes have no subclasses): the three `any?` scans this
4266
+ # replaced ran a block per list member at every node ([#1507](https://github.com/rigortype/rigor/issues/1507)).
4267
+ if CLASS_BODY_NODES.include?(node.class)
4008
4268
  return walk_deferred_lexical_body(node, qualified_prefix, in_singleton_class,
4009
4269
  inside_deferred, mf_offsets, ranges, def_owner_prefix,
4010
4270
  singleton_cref: singleton_cref)
4011
4271
  end
4012
- if META_WRITE_NODES.any? { |kind| node.is_a?(kind) }
4272
+ if META_WRITE_NODES.include?(node.class)
4013
4273
  return walk_deferred_meta_new(node, qualified_prefix, in_singleton_class, inside_deferred,
4014
4274
  mf_offsets, ranges, def_owner_prefix,
4015
4275
  singleton_cref: singleton_cref)
@@ -4019,7 +4279,7 @@ module Rigor
4019
4279
  mf_offsets, ranges, def_owner_prefix, singleton_cref,
4020
4280
  defs_singleton)
4021
4281
  end
4022
- if DEFERRED_RANGE_NODES.any? { |kind| node.is_a?(kind) }
4282
+ if DEFERRED_RANGE_NODES.include?(node.class)
4023
4283
  # Deferred: a call inside runs at invocation / interpreter-exit time. `BEGIN`
4024
4284
  # (PreExecutionNode) is the opposite — eager, before the class body — and stays unlisted.
4025
4285
  ranges << [node.location.start_offset, node.location.end_offset, nil, nil, nil]
@@ -4052,10 +4312,10 @@ module Rigor
4052
4312
  defs_singleton: defs_singleton)
4053
4313
  end
4054
4314
 
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
4315
+ DEFERRED_RANGE_NODES = Set[Prism::BlockNode, Prism::LambdaNode, Prism::PostExecutionNode].freeze
4316
+ META_WRITE_NODES = Set[Prism::ConstantWriteNode, Prism::ConstantPathWriteNode,
4317
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode].freeze
4318
+ CLASS_BODY_NODES = Set[Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
4059
4319
  private_constant :DEFERRED_RANGE_NODES, :META_WRITE_NODES, :CLASS_BODY_NODES
4060
4320
 
4061
4321
  def walk_deferred_children(node, qualified_prefix, in_singleton_class, inside_deferred, # rubocop:disable Metrics/ParameterLists
@@ -4156,7 +4416,7 @@ module Rigor
4156
4416
  end
4157
4417
 
4158
4418
  # 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
4419
+ # the body's `module_function` state first ({ModuleFunctionState.prescan_deferred} — bare-call
4160
4420
  # offsets plus the retro-install and `module_function def x` rows), then walks each statement
4161
4421
  # with the offsets in hand. The prescan looks through nested containers (`if` / `begin` /
4162
4422
  # `rescue` / blocks) because a `module_function` that RAN there still flips later defs —
@@ -4173,8 +4433,8 @@ module Rigor
4173
4433
  # `module_function` under an unnameable definee (`class <<` + `instance_eval`)
4174
4434
  # toggles the metaclass — its rows must not name the enclosing class.
4175
4435
  prescan_owner = defs_singleton == :unnameable ? EMPTY_PREFIX : owner_prefix
4176
- collect_module_function_state(body, prescan_owner, in_singleton_class, mf_offsets,
4177
- ranges)
4436
+ ModuleFunctionState.prescan_deferred(body, prescan_owner, in_singleton_class, mf_offsets,
4437
+ ranges)
4178
4438
  end
4179
4439
  statements_of(body).each do |stmt|
4180
4440
  walk_deferred_ranges(stmt, qualified_prefix, in_singleton_class, inside_deferred,
@@ -4183,49 +4443,6 @@ module Rigor
4183
4443
  end
4184
4444
  end
4185
4445
 
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
4446
  # The wider form: any call whose block rebinds `self` to the receiver — `instance_eval` and
4230
4447
  # `instance_exec` included. Walks that read `self`-anchored facts use this gate; walks that
4231
4448
  # own `def` leaves additionally split on {INSTANCE_EVAL_CALLS}, whose `def`s bind on the
@@ -4234,29 +4451,6 @@ module Rigor
4234
4451
  node.block.is_a?(Prism::BlockNode) && RECEIVER_EVAL_CALLS.include?(node.name)
4235
4452
  end
4236
4453
 
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
4454
  # A `class_eval` / `module_eval` / `class_exec` / `module_exec` block is NOT deferred: it runs
4261
4455
  # eagerly during the receiver's call, as a class-ish body — its `module_function` state gets
4262
4456
  # its own prescan, and its defs belong to the RECEIVER's surface. A nameable receiver gives
@@ -4620,7 +4814,7 @@ module Rigor
4620
4814
 
4621
4815
  kind = if def_singleton?(def_node, qualified_prefix, in_singleton_class)
4622
4816
  :singleton
4623
- elsif mf_offsets.any? { |offset| offset < start }
4817
+ elsif ModuleFunctionState.deferred_module_function?(mf_offsets, start)
4624
4818
  :both
4625
4819
  else
4626
4820
  :instance
@@ -4773,12 +4967,27 @@ module Rigor
4773
4967
  # CompactBase` calls it, and the bare `Post` inside `make` names `::Post` — the constant the compact
4774
4968
  # declaration that OWNS the body reaches, not the one the receiver's spelling would suggest.
4775
4969
  #
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.
4970
+ # Not a leaf of the fused methods/def-nodes walk: it needs the CHAIN threaded, which the fused walk does
4971
+ # not carry (a singleton-class body and a `Class.new` block body both push a qualified prefix while
4972
+ # pushing no `Module.nesting` entry). {DefNestingsCollector} declines at every `Prism::DefNode`, so a
4973
+ # def-dense file pays only the declaration spine.
4974
+ #
4975
+ # ADR-116 WD5 — production builds this table in {#declaration_walk_tables}' shared run, and nothing in
4976
+ # `lib` calls this builder. It runs the collector alone and stays for the specs, which compare it with
4977
+ # the shared run; `RIGOR_SHADOW_RULE_WALK` checks it against {#legacy_def_nestings}.
4781
4978
  def build_def_nestings(root)
4979
+ collector = DefNestingsCollector.new
4980
+ DeclarationWalk.run(root, [collector], superclass_walk_root(nil))
4981
+ verified_def_nestings(root, collector.table)
4982
+ end
4983
+
4984
+ def verified_def_nestings(root, table, source_path = nil)
4985
+ DeclarationWalk::Shadow.verified(:def_nestings, source_path, table) { legacy_def_nestings(root) }
4986
+ end
4987
+
4988
+ # The walker {DefNestingsCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
4989
+ # ported every table walker onto {DeclarationWalk}.
4990
+ def legacy_def_nestings(root)
4782
4991
  accumulator = {}.compare_by_identity
4783
4992
  walk_def_nestings(root, EMPTY_NESTING, accumulator)
4784
4993
  accumulator.freeze
@@ -4904,24 +5113,21 @@ module Rigor
4904
5113
  end
4905
5114
 
4906
5115
  # 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.
5116
+ # bare-`module_function` toggle ({ModuleFunctionState.each_singleton_sibling}): once a bare
5117
+ # `module_function` is seen, every subsequent `def` in the body registers as a singleton method. Nested
5118
+ # classes/modules/defs recurse through the general walker so the toggle stays scoped to its own body, and a
5119
+ # `module_function :a, :b` named form records through {#record_module_function_names}.
4910
5120
  def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator,
4911
5121
  def_owner_prefix = nil, singleton_cref: false,
4912
5122
  defs_singleton: false)
4913
5123
  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
5124
+ statements = statements_of(body)
5125
+ ModuleFunctionState.each_singleton_sibling(statements) do |stmt, module_function_on, named_call|
5126
+ if named_call
5127
+ # `:unnameable` — the named defs the call copies live on the metaclass,
5128
+ # which this table cannot name either.
5129
+ record_module_function_names(stmt, owner_prefix, statements, accumulator) unless
5130
+ defs_singleton == :unnameable
4925
5131
  next
4926
5132
  end
4927
5133
  if stmt.is_a?(Prism::DefNode)
@@ -4972,42 +5178,30 @@ module Rigor
4972
5178
  return if qualified_prefix.empty?
4973
5179
 
4974
5180
  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?
5181
+ write_singleton_slot(accumulator, class_name, def_node.name, def_node, def_node)
4982
5182
  end
4983
5183
 
4984
- def bare_module_function?(node)
4985
- node.arguments.nil? || node.arguments.arguments.empty?
5184
+ # One singleton slot write, through {SingletonDefTable#write} when the walk tracks certainty.
5185
+ def write_singleton_slot(accumulator, class_name, name, def_node, node, other = nil)
5186
+ if accumulator.is_a?(SingletonDefTable)
5187
+ accumulator.write(class_name, name, def_node, node, other)
5188
+ else
5189
+ (accumulator[class_name] ||= {})[name] = def_node
5190
+ end
4986
5191
  end
4987
5192
 
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)
5193
+ # `module_function :a, :b` retro-marks named siblings as module-functions: registers on the module's singleton
5194
+ # side each `DefNode` {ModuleFunctionState.each_singleton_copy} resolves among the body's direct `statements`.
5195
+ # Non-symbol arguments and names with no matching `def` are skipped (a miss degrades to today's `Dynamic`).
5196
+ def record_module_function_names(node, qualified_prefix, statements, accumulator)
4993
5197
  return if qualified_prefix.empty?
4994
5198
 
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
5199
  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
5200
+ ModuleFunctionState.each_singleton_copy(node, statements) do |name, def_node|
5201
+ write_singleton_slot(accumulator, class_name, name, def_node, node, def_node)
5003
5202
  end
5004
5203
  end
5005
5204
 
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
5205
  # ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the
5012
5206
  # `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
5013
5207
  # non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
@@ -5021,13 +5215,57 @@ module Rigor
5021
5215
  # the enclosing cref — `[]` for the first spelling, `["Admin"]` for the second. A class reopened under two
5022
5216
  # spellings keeps the LAST one walked, matching how `superclasses` itself merges.
5023
5217
  #
5218
+ # ADR-116 WD5 — built by {SuperclassesCollector} on the shared {DeclarationWalk}, following the two
5219
+ # legacy variants it declares; `RIGOR_SHADOW_RULE_WALK` checks both tables against
5220
+ # {#legacy_superclass_tables}. Production builds both in {#declaration_walk_tables}' shared run, and
5221
+ # nothing in `lib` calls this builder: it runs the collector alone and stays for the specs, which
5222
+ # compare it with the shared run.
5223
+ #
5024
5224
  # @return the `[superclasses, header_nestings]` pair
5025
5225
  def build_superclass_tables(root, source_path = nil)
5226
+ collector = SuperclassesCollector.new
5227
+ DeclarationWalk.run(root, [collector], superclass_walk_root(source_path))
5228
+ verified_superclass_tables(root, source_path, collector.tables)
5229
+ end
5230
+
5231
+ # The root context the superclass tables are walked from: the ancestry nesting starts empty and the
5232
+ # anonymous-class key carries the file's path.
5233
+ def superclass_walk_root(source_path, scope: nil)
5234
+ DeclarationWalk::Context.root(scope: scope, nesting: EMPTY_NESTING, source_path: source_path)
5235
+ end
5236
+
5237
+ # `tables` once the shadow harness has compared them with the legacy walker's, when it is on.
5238
+ def verified_superclass_tables(root, source_path, tables)
5239
+ DeclarationWalk::Shadow.verified(:superclass_tables, source_path, tables) do
5240
+ legacy_superclass_tables(root, source_path)
5241
+ end
5242
+ end
5243
+
5244
+ # The walker {SuperclassesCollector} replaced, kept as the shadow harness's oracle until ADR-116 WD5 has
5245
+ # ported every table walker onto {DeclarationWalk}.
5246
+ def legacy_superclass_tables(root, source_path = nil)
5026
5247
  accumulator = { superclasses: {}, header_nestings: {} }
5027
5248
  walk_class_superclasses(root, [], accumulator, source_path)
5028
5249
  [accumulator[:superclasses].freeze, accumulator[:header_nestings].freeze]
5029
5250
  end
5030
5251
 
5252
+ # ADR-116 WD5 — the file's superclass, header-nesting, def-nesting and member-layout tables from ONE
5253
+ # shared {DeclarationWalk}: the file is descended once for all of them, where the legacy walkers
5254
+ # descended it once per table. `RIGOR_SHADOW_RULE_WALK` checks each table against its legacy walker.
5255
+ #
5256
+ # @return `{ superclasses:, header_nestings:, def_nestings:, data_member_layouts:, struct_member_layouts: }`
5257
+ def declaration_walk_tables(root, source_path = nil)
5258
+ superclasses = SuperclassesCollector.new
5259
+ layouts = MemberLayoutsCollector.new
5260
+ def_nestings = DefNestingsCollector.new
5261
+ DeclarationWalk.run(root, [superclasses, layouts, def_nestings], superclass_walk_root(source_path))
5262
+ supers, header_nestings = verified_superclass_tables(root, source_path, superclasses.tables)
5263
+ data, struct = verified_member_layouts(root, layouts.tables, source_path)
5264
+ { superclasses: supers, header_nestings: header_nestings,
5265
+ def_nestings: verified_def_nestings(root, def_nestings.table, source_path),
5266
+ data_member_layouts: data, struct_member_layouts: struct }
5267
+ end
5268
+
5031
5269
  # `self_base` names a rebound `self` for `self::`-anchored headers — a meta-new
5032
5270
  # block's class; `[]` marks a self no name covers.
5033
5271
  def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil,
@@ -5342,7 +5580,31 @@ module Rigor
5342
5580
  # `Data.define` is recorded: `Struct.new` instances are mutable, so member-value folding would be unsound (the
5343
5581
  # Struct follow-up is deferred — see ADR-48 § "Struct follow-up"). Consumed by
5344
5582
  # {Inference::MethodDispatcher::DataFolding} via {Scope#data_member_layout}.
5583
+ #
5584
+ # ADR-116 WD5 — built with its Struct sibling by {MemberLayoutsCollector}. Production builds both in
5585
+ # {#declaration_walk_tables}' shared run, and nothing in `lib` calls this builder: it stays for the specs,
5586
+ # and walks both layout tables to return this one.
5345
5587
  def build_data_member_layouts(root)
5588
+ member_layout_tables(root).first
5589
+ end
5590
+
5591
+ # `[data_member_layouts, struct_member_layouts]` from a {MemberLayoutsCollector} run of its own, checked by
5592
+ # `RIGOR_SHADOW_RULE_WALK` against the two legacy walkers. Spec-only, like the two builders on it.
5593
+ def member_layout_tables(root)
5594
+ collector = MemberLayoutsCollector.new
5595
+ DeclarationWalk.run(root, [collector], superclass_walk_root(nil))
5596
+ verified_member_layouts(root, collector.tables)
5597
+ end
5598
+
5599
+ def verified_member_layouts(root, tables, source_path = nil)
5600
+ DeclarationWalk::Shadow.verified(:member_layouts, source_path, tables) do
5601
+ [legacy_data_member_layouts(root), legacy_struct_member_layouts(root)]
5602
+ end
5603
+ end
5604
+
5605
+ # The walker {MemberLayoutsCollector} replaced for the `Data.define` table, kept as the shadow harness's
5606
+ # oracle until ADR-116 WD5 has ported every table walker onto {DeclarationWalk}.
5607
+ def legacy_data_member_layouts(root)
5346
5608
  accumulator = {}
5347
5609
  walk_data_member_layouts(root, [], accumulator)
5348
5610
  accumulator.freeze
@@ -5445,7 +5707,16 @@ module Rigor
5445
5707
  # table so the existing `Data.define` value-shape contract (a bare `[Symbol]`) is untouched: a Struct entry
5446
5708
  # carries `{ members:, keyword_init: }` because the dispatcher needs the flag to fold the matching `.new` call
5447
5709
  # form (positional vs keyword) without manufacturing a wrong map.
5710
+ #
5711
+ # ADR-116 WD5 — production builds it in {#declaration_walk_tables}' shared run; this builder is spec-only,
5712
+ # like {#build_data_member_layouts}.
5448
5713
  def build_struct_member_layouts(root)
5714
+ member_layout_tables(root).last
5715
+ end
5716
+
5717
+ # The walker {MemberLayoutsCollector} replaced for the `Struct.new` table, kept as the shadow harness's
5718
+ # oracle until ADR-116 WD5 has ported every table walker onto {DeclarationWalk}.
5719
+ def legacy_struct_member_layouts(root)
5449
5720
  accumulator = {}
5450
5721
  walk_struct_member_layouts(root, [], accumulator)
5451
5722
  accumulator.freeze
@@ -5589,7 +5860,7 @@ module Rigor
5589
5860
  # stored in instance-ancestor SEARCH order: a later `prepend` statement puts its module NEARER than
5590
5861
  # an earlier one (`prepend A; prepend B` searches B before A) while the arguments of ONE
5591
5862
  # `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
5863
+ # `extend`, and the order `Scope::ResolutionChain` reads a body's prepends in. Ruby inserts a
5593
5864
  # prepended module, and its own ancestry, immediately BEFORE the class that prepends it, so these
5594
5865
  # names outrank the class's own `def`s; without the kind recorded separately from {#build_discovered_includes}
5595
5866
  # there was nothing to order on and `prepend` was silently an `include`.
@@ -5599,13 +5870,16 @@ module Rigor
5599
5870
 
5600
5871
  # One descent, both instance-side mixin tables: the walk classifies each mixin call it sees, so the
5601
5872
  # 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.
5873
+ # single-table builder always did. `:unpositioned` names the recorded edges whose order is not a fact
5874
+ # ({MixinAccumulator}).
5603
5875
  def mixin_tables(root)
5604
- accumulator = {}
5876
+ accumulator = MixinAccumulator.new
5877
+ accumulator.direct_body(program_statements(root))
5605
5878
  walk_class_includes(root, [], nil, accumulator)
5606
5879
  {
5607
5880
  includes: freeze_mixin_lists(accumulator, :include),
5608
- prepends: freeze_mixin_lists(accumulator, :prepend)
5881
+ prepends: freeze_mixin_lists(accumulator, :prepend),
5882
+ unpositioned: accumulator.unpositioned
5609
5883
  }
5610
5884
  end
5611
5885
 
@@ -5620,7 +5894,10 @@ module Rigor
5620
5894
  # already store nearest-first (see {#write_mixin_targets}), so the concat preserves MRO.
5621
5895
  def freeze_mixin_lists(accumulator, kind)
5622
5896
  accumulator.each_with_object({}) do |(class_name, kinds), out|
5623
- names = mixin_names_for(kinds, kind).uniq.freeze
5897
+ names = mixin_names_for(kinds, kind)
5898
+ # The instance-side projection is a SET in search order; the prepend table keeps every statement.
5899
+ names = names.uniq if kind == :include
5900
+ names = names.freeze
5624
5901
  out[class_name] = names unless names.empty?
5625
5902
  end.freeze
5626
5903
  end
@@ -5635,6 +5912,12 @@ module Rigor
5635
5912
  end
5636
5913
  end
5637
5914
 
5915
+ # The top-level statements of a parsed file: the one body whose declarations need no enclosing
5916
+ # declaration to be direct ({MixinAccumulator}).
5917
+ def program_statements(root)
5918
+ root.is_a?(Prism::ProgramNode) ? root.statements : root
5919
+ end
5920
+
5638
5921
  def walk_class_includes(node, qualified_prefix, current_class, accumulator,
5639
5922
  singleton_self: false, singleton_cref: false)
5640
5923
  return unless node.is_a?(Prism::Node)
@@ -5665,6 +5948,25 @@ module Rigor
5665
5948
  singleton_self, singleton_cref)
5666
5949
  end
5667
5950
 
5951
+ walk_includes_children(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
5952
+ end
5953
+
5954
+ # The generic descent of {#walk_class_includes}; a `self.included(base)`-style hook's body walks with
5955
+ # its parameters in scope ({MixinAccumulator#with_hook_params}).
5956
+ def walk_includes_children(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
5957
+ hook_params = hook_def_params(node)
5958
+ if hook_params
5959
+ return accumulator.with_hook_params(hook_params) do
5960
+ walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self,
5961
+ singleton_cref)
5962
+ end
5963
+ end
5964
+
5965
+ walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self, singleton_cref)
5966
+ end
5967
+
5968
+ def walk_includes_children_plain(node, qualified_prefix, current_class, accumulator, singleton_self,
5969
+ singleton_cref)
5668
5970
  node.rigor_each_child do |child|
5669
5971
  walk_class_includes(child, qualified_prefix, current_class, accumulator,
5670
5972
  singleton_self: singleton_self, singleton_cref: singleton_cref)
@@ -5682,6 +5984,7 @@ module Rigor
5682
5984
  return true unless node.body
5683
5985
 
5684
5986
  _self_decl, child_prefix, child_cref = ctx
5987
+ accumulator.direct_body(node.body) if accumulator.direct?(node)
5685
5988
  walk_class_includes(node.body, child_cref ? [] : child_prefix,
5686
5989
  child_cref ? nil : child_prefix.join("::"), accumulator,
5687
5990
  singleton_cref: child_cref)
@@ -5752,7 +6055,7 @@ module Rigor
5752
6055
  # names, so it declines rather than re-anchoring to the lexical class.
5753
6056
  unnameable_self = singleton_self || (singleton_cref && current_class.nil?)
5754
6057
  rebound = rebound_block_self(node, qualified_prefix, nil, nil,
5755
- rebound_self_base(unnameable_self ? OPAQUE_SELF : current_class))
6058
+ unnameable_self ? OPAQUE_SELF : current_class)
5756
6059
  node.rigor_each_child do |child|
5757
6060
  rebinds_self = rebound && child.is_a?(Prism::BlockNode)
5758
6061
  owner =
@@ -5776,15 +6079,150 @@ module Rigor
5776
6079
  # it or the set-shaped consumers (arity, visibility, undefined-method suppression) would answer
5777
6080
  # differently for the two spellings of one edge.
5778
6081
  def record_mixin_call(node, qualified_prefix, current_class, accumulator)
5779
- return unless mixin_call_recorded?(node, current_class)
6082
+ taint_opaque_eval(node, current_class, accumulator)
6083
+ kind, _arguments, via_send = mixin_call_view(node)
6084
+ return if kind.nil?
6085
+
6086
+ effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: false)
6087
+ .select { |_owner, side| side == :include }
6088
+ return if effects.empty?
6089
+ return if !via_send && instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6090
+
6091
+ # A mixin call the tables cannot hold still reshapes the ancestry: taint the whole owner side.
6092
+ effects.each { |owner, side| accumulator.taint(owner, side) }
6093
+ end
6094
+
6095
+ # Records the instance-side edges of a call {#mixin_call_recorded?} accepts. False when it recorded
6096
+ # nothing — the caller then taints the owner instead.
6097
+ def instance_mixin_recorded?(node, qualified_prefix, current_class, accumulator)
6098
+ return false unless mixin_call_recorded?(node, current_class)
5780
6099
 
5781
- targets = node.arguments&.arguments&.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
5782
- return if targets.nil? || targets.empty?
6100
+ arguments = node.arguments&.arguments || []
6101
+ targets = arguments.filter_map { |arg| Source::ConstantPath.qualified_name(arg) }
6102
+ return false if targets.empty?
5783
6103
 
5784
6104
  owner = node.receiver.nil? ? current_class : prepend_call_receiver(node, qualified_prefix)
5785
- return if owner.nil?
6105
+ return false if owner.nil?
5786
6106
 
5787
6107
  write_mixin_targets(accumulator, owner, targets, prepend: node.name == :prepend)
6108
+ accumulator.note(node, owner, :include, targets, complete: targets.size == arguments.size)
6109
+ true
6110
+ end
6111
+
6112
+ MIXIN_KINDS = %i[include prepend extend].freeze
6113
+ SEND_CALL_NAMES = %i[send public_send __send__].freeze
6114
+ private_constant :MIXIN_KINDS, :SEND_CALL_NAMES
6115
+
6116
+ # `[kind, arguments, via_send]` for a call that mixes modules in — `include` / `prepend` / `extend`, or
6117
+ # `send(:include, M)` and its `public_send` / `__send__` twins with a literal first argument — else nil.
6118
+ # A computed `send` name is not read as a mixin: it names nothing this walk could taint by.
6119
+ def mixin_call_view(node)
6120
+ return [node.name, node.arguments&.arguments || [], false] if MIXIN_KINDS.include?(node.name)
6121
+ return unless SEND_CALL_NAMES.include?(node.name)
6122
+
6123
+ arguments = node.arguments&.arguments || []
6124
+ first = arguments.first
6125
+ return unless first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
6126
+
6127
+ # By string: a name literal need not be a valid symbol in its encoding.
6128
+ kind = MIXIN_KINDS.find { |candidate| candidate.name == first.unescaped }
6129
+ [kind, arguments.drop(1), true] if kind
6130
+ end
6131
+
6132
+ # `[[owner, side], …]` — the ancestry sides a mixin call reshapes. `self` and the implicit receiver
6133
+ # are the enclosing class (`:include` for `include` / `prepend`, `:extend` for `extend`, and both
6134
+ # flip to `:extend` inside `class << self`); `singleton_class` is the singleton side; a constant is
6135
+ # that class; any other receiver is unknown, so it may be the class that includes this one from a
6136
+ # hook (`base.include M`) and taints both sides of the enclosing class.
6137
+ def mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton:)
6138
+ receiver = node.receiver
6139
+ side = kind == :extend ? :extend : :include
6140
+ pairs =
6141
+ if receiver.nil? || receiver.is_a?(Prism::SelfNode)
6142
+ own_mixin_effects(kind, side, current_class, in_singleton)
6143
+ else
6144
+ receiver_mixin_effects(node, side, qualified_prefix, current_class, accumulator)
6145
+ end
6146
+ pairs.reject { |owner, _side| owner.nil? }
6147
+ end
6148
+
6149
+ # The implicit-receiver / `self` arm of {#mixin_effects}: inside `class << self`, `include` / `prepend`
6150
+ # are singleton-side and `extend` reaches the singleton's own singleton, which nothing names.
6151
+ def own_mixin_effects(kind, side, current_class, in_singleton)
6152
+ return [] if in_singleton && kind == :extend
6153
+
6154
+ [[current_class, in_singleton ? :extend : side]]
6155
+ end
6156
+
6157
+ # A receiver that is not `self` reshapes the class it names, or the singleton of `singleton_class`. Any
6158
+ # other receiver is an object of its own (`ids.prepend(1)`, `obj.extend(Decorator)`) and reshapes none of
6159
+ # the enclosing class's ancestry — except in a `self.included(base)`-style hook, where `base` IS the
6160
+ # class that includes the enclosing module and a call on it taints both sides of the enclosing class.
6161
+ def receiver_mixin_effects(node, side, qualified_prefix, current_class, accumulator)
6162
+ receiver = node.receiver
6163
+ return [[current_class, :extend]] if singleton_class_receiver?(receiver)
6164
+
6165
+ owner = prepend_call_receiver(node, qualified_prefix)
6166
+ return [[owner, side]] if owner
6167
+ return [] unless hook_receiver?(receiver, accumulator)
6168
+
6169
+ [[current_class, :include], [current_class, :extend]]
6170
+ end
6171
+
6172
+ HOOK_DEFS = %i[included extended prepended inherited].freeze
6173
+ MIXIN_EVAL_CALLS = %i[class_eval module_eval class_exec module_exec instance_eval instance_exec].freeze
6174
+ private_constant :HOOK_DEFS, :MIXIN_EVAL_CALLS
6175
+
6176
+ # The parameter names of a `def included(base)`-style hook (a hook is written `def self.included`, or
6177
+ # `def included` inside `class << self`), else nil.
6178
+ def hook_def_params(node)
6179
+ return nil unless node.is_a?(Prism::DefNode) && HOOK_DEFS.include?(node.name)
6180
+
6181
+ parameters = node.parameters
6182
+ return [] unless parameters
6183
+
6184
+ (parameters.requireds + parameters.optionals).filter_map do |parameter|
6185
+ parameter.name if parameter.respond_to?(:name)
6186
+ end
6187
+ end
6188
+
6189
+ # Whether `receiver` is a hook parameter, or `singleton_class` / a no-argument call chain off one.
6190
+ def hook_receiver?(receiver, accumulator)
6191
+ case receiver
6192
+ when Prism::LocalVariableReadNode then accumulator.hook_local?(receiver.name)
6193
+ when Prism::CallNode
6194
+ receiver.arguments.nil? && receiver.receiver && hook_receiver?(receiver.receiver, accumulator)
6195
+ else false
6196
+ end
6197
+ end
6198
+
6199
+ # `base.class_eval { include X }` in a hook, and `opaque.module_eval { include B }` anywhere: the block
6200
+ # opens a class this walk cannot name, so its mixin calls record nowhere, yet the enclosing class's
6201
+ # ancestry is what the reader was asked about. Taint both sides of the enclosing class. Outside a hook
6202
+ # the block is scanned for a mixin call first, so an ordinary `obj.instance_eval { … }` DSL taints nothing.
6203
+ def taint_opaque_eval(node, current_class, accumulator)
6204
+ return unless current_class && node.block.is_a?(Prism::BlockNode) && MIXIN_EVAL_CALLS.include?(node.name)
6205
+
6206
+ receiver = node.receiver
6207
+ return if receiver.nil? || receiver.is_a?(Prism::SelfNode)
6208
+ return if Source::ConstantPath.qualified_name_or_nil(receiver)
6209
+ return unless hook_receiver?(receiver, accumulator) || block_mixes_in?(node.block)
6210
+
6211
+ accumulator.taint(current_class, :include)
6212
+ accumulator.taint(current_class, :extend)
6213
+ end
6214
+
6215
+ def block_mixes_in?(node)
6216
+ return true if node.is_a?(Prism::CallNode) && (MIXIN_KINDS.include?(node.name) || mixin_call_view(node))
6217
+
6218
+ found = false
6219
+ node.rigor_each_child { |child| found ||= block_mixes_in?(child) }
6220
+ found
6221
+ end
6222
+
6223
+ def singleton_class_receiver?(receiver)
6224
+ receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil? &&
6225
+ (receiver.receiver.nil? || receiver.receiver.is_a?(Prism::SelfNode))
5788
6226
  end
5789
6227
 
5790
6228
  # Issue #1123 — one class's contribution to the two tables. A prepended module lands in BOTH: the
@@ -5802,12 +6240,15 @@ module Rigor
5802
6240
  # freeze time — ahead of every include, where Ruby puts them.
5803
6241
  def write_mixin_targets(accumulator, owner, targets, prepend:)
5804
6242
  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
6243
+ kind, other = prepend ? %i[prepend include] : %i[include prepend]
6244
+ list = (bucket[kind] ||= [])
6245
+ # A target the OTHER kind already carries (`include M; prepend M`) is one body the two tables cannot
6246
+ # represent (Ruby's `[M, C, M]`), so the instance side is named unpositioned.
6247
+ accumulator.taint(owner, :include) if targets.any? { |target| bucket[other]&.include?(target) }
6248
+ # A repeated `include` keeps its first position (Ruby skips it). A repeated `prepend` is KEPT: the
6249
+ # prepend table holds every statement, nearest first, so the readers that walk the raw list see what
6250
+ # they always saw, and the resolution chain dedupes per owner, first statement winning.
6251
+ list.unshift(*(prepend ? targets : targets.reject { |target| list.include?(target) }))
5811
6252
  end
5812
6253
 
5813
6254
  # Whether a mixin call contributes to the tables at all: a receiverless `include` / `prepend` needs an
@@ -5851,9 +6292,19 @@ module Rigor
5851
6292
  # Issue #915 — plus `class << self; include M; end`, the same singleton ancestor spelled through the
5852
6293
  # singleton-class body. It folds like an `extend` because Ruby makes it one.
5853
6294
  def build_discovered_extends(root)
5854
- accumulator = {}
6295
+ extend_tables(root).fetch(:extends)
6296
+ end
6297
+
6298
+ # {#build_discovered_extends} and the `extend` edges whose order is not a fact ({MixinAccumulator}), from
6299
+ # one descent.
6300
+ def extend_tables(root)
6301
+ accumulator = MixinAccumulator.new
6302
+ accumulator.direct_body(program_statements(root))
5855
6303
  walk_class_extends(root, [], nil, accumulator)
5856
- accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
6304
+ {
6305
+ extends: accumulator.transform_values { |mods| mods.uniq.freeze }.freeze,
6306
+ unpositioned: accumulator.unpositioned
6307
+ }
5857
6308
  end
5858
6309
 
5859
6310
  def walk_class_extends(node, qualified_prefix, current_class, accumulator, in_singleton: false,
@@ -5880,18 +6331,40 @@ module Rigor
5880
6331
  return if walk_extends_meta_new?(node, qualified_prefix, current_class, accumulator,
5881
6332
  in_singleton, singleton_self, singleton_cref)
5882
6333
  when Prism::CallNode
5883
- record_extend_call(node, current_class, accumulator, in_singleton: in_singleton)
6334
+ record_extend_call(node, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
5884
6335
  if receiver_eval_call?(node)
5885
6336
  return walk_eval_extends_call(node, qualified_prefix, current_class, accumulator,
5886
6337
  in_singleton: in_singleton, singleton_self: singleton_self,
5887
6338
  singleton_cref: singleton_cref)
5888
6339
  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)
6340
+ if node.block.is_a?(Prism::BlockNode) &&
6341
+ extends_block_handled?(node, qualified_prefix, current_class, accumulator,
6342
+ in_singleton, singleton_self, singleton_cref)
6343
+ return
5892
6344
  end
5893
6345
  end
5894
6346
 
6347
+ walk_extends_children(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6348
+ singleton_cref)
6349
+ end
6350
+
6351
+ # The generic descent of {#walk_class_extends}; a hook's body walks with its parameters in scope.
6352
+ def walk_extends_children(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6353
+ singleton_cref)
6354
+ hook_params = hook_def_params(node)
6355
+ if hook_params
6356
+ return accumulator.with_hook_params(hook_params) do
6357
+ walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton,
6358
+ singleton_self, singleton_cref)
6359
+ end
6360
+ end
6361
+
6362
+ walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6363
+ singleton_cref)
6364
+ end
6365
+
6366
+ def walk_extends_children_plain(node, qualified_prefix, current_class, accumulator, in_singleton,
6367
+ singleton_self, singleton_cref)
5895
6368
  node.rigor_each_child do |child|
5896
6369
  walk_class_extends(child, qualified_prefix, current_class, accumulator,
5897
6370
  in_singleton: in_singleton, singleton_self: singleton_self,
@@ -5915,6 +6388,7 @@ module Rigor
5915
6388
  # singleton body, `self` IS the singleton and `class << self` opens the
5916
6389
  # singleton's own singleton (`#<Class:#<Class:C>>`), which nothing names.
5917
6390
  opens_self = node.expression.is_a?(Prism::SelfNode) && !in_singleton
6391
+ accumulator.direct_body(node.body) if opens_self && accumulator.direct?(node)
5918
6392
  walk_class_extends(node.body, qualified_prefix, opens_self ? current_class : nil,
5919
6393
  accumulator, in_singleton: opens_self,
5920
6394
  singleton_self: true, singleton_cref: true)
@@ -5931,6 +6405,8 @@ module Rigor
5931
6405
  return unless child_prefix && node.body
5932
6406
 
5933
6407
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
6408
+ accumulator.direct_body(node.body) if accumulator.direct?(node)
6409
+ accumulator.body_statements(node.body)
5934
6410
  walk_class_extends(node.body, child_cref ? [] : child_prefix,
5935
6411
  child_cref ? nil : child_prefix.join("::"), accumulator,
5936
6412
  singleton_cref: child_cref)
@@ -5954,6 +6430,110 @@ module Rigor
5954
6430
  in_singleton, singleton_self, singleton_cref)
5955
6431
  end
5956
6432
 
6433
+ # What the extends walk does with a block-carrying call: `:rebound` (its own eval / `define_method` / meta-new
6434
+ # handling), `:skip` (the block runs on an object this walk cannot vouch for, as an instance's `synchronize`
6435
+ # or an `on_load` hook, or may never run: `Registry.each { … }`), or `:descend` (it provably runs once with the
6436
+ # body's `self`, #1592, and its direct statements are positioned when the call itself is).
6437
+ def extends_block_plan(node, accumulator)
6438
+ return :rebound if rebinding_extends_block?(node)
6439
+ return :skip unless runs_body_block_once?(node, accumulator)
6440
+
6441
+ accumulator.direct_body(node.block.body) if accumulator.direct?(node)
6442
+ :descend
6443
+ end
6444
+
6445
+ # True when {#extends_block_plan} settled the block (walked as rebound, or skipped); false leaves it to the
6446
+ # generic descent.
6447
+ def extends_block_handled?(node, qualified_prefix, current_class, accumulator, in_singleton, singleton_self,
6448
+ singleton_cref)
6449
+ case extends_block_plan(node, accumulator)
6450
+ when :skip then true
6451
+ when :rebound
6452
+ walk_extends_block_call(node, qualified_prefix, current_class, accumulator,
6453
+ in_singleton, singleton_self, singleton_cref)
6454
+ true
6455
+ else false
6456
+ end
6457
+ end
6458
+
6459
+ ARRAY_ITERATORS = %i[each each_with_index map].freeze
6460
+ HASH_ITERATORS = %i[each each_pair each_key each_value map].freeze
6461
+ private_constant :ARRAY_ITERATORS, :HASH_ITERATORS
6462
+
6463
+ # A block that provably runs at least once with the class body's `self`: the call is a statement of the
6464
+ # body itself (not nested in a `def` or another block) and either `tap` / `then` on a literal (they
6465
+ # always yield once, self preserved) or an iterator over a literal that is known non-empty (a
6466
+ # non-empty Array or Hash literal, a positive `Integer#times`, an Integer-literal Range that is not empty,
6467
+ # `upto` / `downto` in the right order). A constant or variable receiver (`Registry.each`, a lazy `map`) may
6468
+ # never yield, so it is not one. The walk then records the block's direct statements as positioned: the
6469
+ # block runs where it is written, and a repeated `extend` is idempotent (#1592).
6470
+ def runs_body_block_once?(node, accumulator)
6471
+ return false unless accumulator.body_block?(node)
6472
+ return literal_or_self?(node.receiver) if %i[tap then].include?(node.name)
6473
+
6474
+ non_empty_literal_iteration?(node.name, node.receiver, node.arguments&.arguments || [])
6475
+ end
6476
+
6477
+ TAP_RECEIVERS = [Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode, Prism::ArrayNode,
6478
+ Prism::HashNode, Prism::RangeNode, Prism::NilNode, Prism::TrueNode, Prism::FalseNode].freeze
6479
+ private_constant :TAP_RECEIVERS
6480
+
6481
+ # `tap` / `then` yield once only on a receiver whose method nobody overrode: a literal. A constant or `self`
6482
+ # may define its own `tap` (`def self.tap`), a promise's `then` defers, and a receiverless call may reach a
6483
+ # class-level DSL method, so none of those is read as running.
6484
+ def literal_or_self?(receiver)
6485
+ TAP_RECEIVERS.any? { |kind| receiver.is_a?(kind) }
6486
+ end
6487
+
6488
+ def non_empty_literal_iteration?(name, receiver, arguments)
6489
+ case receiver
6490
+ when Prism::ArrayNode
6491
+ ARRAY_ITERATORS.include?(name) && !receiver.elements.empty? &&
6492
+ receiver.elements.none?(Prism::SplatNode)
6493
+ when Prism::HashNode
6494
+ HASH_ITERATORS.include?(name) && !receiver.elements.empty? &&
6495
+ receiver.elements.all?(Prism::AssocNode)
6496
+ when Prism::IntegerNode then integer_iteration_runs?(name, receiver.value, arguments)
6497
+ when Prism::ParenthesesNode then non_empty_range_iteration?(name, receiver, arguments)
6498
+ else false
6499
+ end
6500
+ end
6501
+
6502
+ def integer_iteration_runs?(name, value, arguments)
6503
+ limit = arguments.first
6504
+ case name
6505
+ when :times then arguments.empty? && value.positive?
6506
+ when :upto then arguments.size == 1 && limit.is_a?(Prism::IntegerNode) && value <= limit.value
6507
+ when :downto then arguments.size == 1 && limit.is_a?(Prism::IntegerNode) && value >= limit.value
6508
+ else false
6509
+ end
6510
+ end
6511
+
6512
+ def positive_integer_literal?(node) = node.is_a?(Prism::IntegerNode) && node.value.positive?
6513
+
6514
+ # `(1..3).each` and `(1..3).map`, and `(1..3).step(2)` with a positive literal step.
6515
+ def non_empty_range_iteration?(name, parens, arguments)
6516
+ range = parens.body.is_a?(Prism::StatementsNode) && parens.body.body.size == 1 ? parens.body.body.first : nil
6517
+ return false unless range.is_a?(Prism::RangeNode)
6518
+ return false unless range.left.is_a?(Prism::IntegerNode) && range.right.is_a?(Prism::IntegerNode)
6519
+
6520
+ low = range.left.value
6521
+ high = range.right.value
6522
+ return false unless range.exclude_end? ? low < high : low <= high
6523
+
6524
+ case name
6525
+ when :each, :map then arguments.empty?
6526
+ when :step then positive_integer_literal?(arguments.first) && arguments.size == 1
6527
+ else false
6528
+ end
6529
+ end
6530
+
6531
+ # Whether {#walk_extends_block_call} owns this block-carrying call. Any other block (`[1].each { extend M }`,
6532
+ # `included do extend M end`) keeps the enclosing `self`, so the generic descent walks it (#1592).
6533
+ def rebinding_extends_block?(node)
6534
+ INSTANCE_EVAL_CALLS.include?(node.name) || node.name == :define_method || meta_new_constant_rvalue?(node)
6535
+ end
6536
+
5957
6537
  # A block whose `self` is an object this walk cannot name — a `define_method` body (its
5958
6538
  # self is the receiver's instance at call time) or an unnamed `Class.new { … }`-family
5959
6539
  # block — walks ownerless with an unnameable self, while the call's receiver and
@@ -6067,19 +6647,39 @@ module Rigor
6067
6647
  # `class << self; extend M; end` puts M on the singleton's OWN singleton, one level further out than
6068
6648
  # anything this table describes, and `module_function` in a singleton body is not the scope toggle
6069
6649
  # {#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?
6650
+ # Inside `class << self` the instance walk is ownerless, so this walk also keeps the instance-side taints
6651
+ # (`base.include X` in a hook written there).
6652
+ def record_extend_call(node, qualified_prefix, current_class, accumulator, in_singleton: false)
6653
+ return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
6072
6654
 
6073
- if in_singleton
6074
- record_extend_targets(node, current_class, accumulator) if MIXIN_CALL_NAMES.include?(node.name)
6075
- return
6076
- end
6655
+ taint_opaque_eval(node, current_class, accumulator)
6077
6656
 
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
6657
+ kind, _arguments, via_send = mixin_call_view(node)
6658
+ return if kind.nil?
6659
+
6660
+ effects = mixin_effects(node, kind, qualified_prefix, current_class, accumulator, in_singleton: in_singleton)
6661
+ .select { |_owner, side| side == :extend || in_singleton }
6662
+ return if effects.empty?
6663
+ return if !via_send && singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
6664
+
6665
+ effects.each { |owner, side| accumulator.taint(owner, side) }
6666
+ end
6667
+
6668
+ # The bare `module_function` toggle: the module extends itself. A direct statement only.
6669
+ def record_module_function(node, current_class, accumulator, in_singleton)
6670
+ return unless current_class && node.receiver.nil? && !in_singleton && ModuleFunctionState.extends_self?(node)
6671
+
6672
+ (accumulator[current_class] ||= []) << current_class
6673
+ accumulator.note(node, current_class, :extend, [current_class], complete: true)
6674
+ end
6675
+
6676
+ # Records the singleton-side edges of a receiverless `extend` (or, inside `class << self`, `include` /
6677
+ # `prepend`). False when it recorded nothing.
6678
+ def singleton_mixin_recorded?(node, kind, current_class, accumulator, in_singleton)
6679
+ return false unless current_class && node.receiver.nil?
6680
+ return false unless in_singleton ? MIXIN_CALL_NAMES.include?(kind) : kind == :extend
6681
+
6682
+ !record_extend_targets(node, current_class, accumulator).empty?
6083
6683
  end
6084
6684
 
6085
6685
  # The table stores search order, not call order: a later `extend` statement prepends its module
@@ -6088,11 +6688,22 @@ module Rigor
6088
6688
  # and consumers iterating the list forward read the same order the singleton ancestry searches.
6089
6689
  def record_extend_targets(node, current_class, accumulator)
6090
6690
  targets = []
6091
- node.arguments&.arguments&.each do |arg|
6691
+ arguments = node.arguments&.arguments || []
6692
+ arguments.each do |arg|
6092
6693
  target = arg.is_a?(Prism::SelfNode) ? current_class : Source::ConstantPath.qualified_name(arg)
6093
6694
  targets << target if target
6094
6695
  end
6095
- (accumulator[current_class] ||= []).unshift(*targets) unless targets.empty?
6696
+ return targets if targets.empty?
6697
+
6698
+ list = (accumulator[current_class] ||= [])
6699
+ # A repeated `extend` keeps the table position it always had (the later statement's: the freeze `uniq`s
6700
+ # the nearest-first list), which is not Ruby's (Ruby skips the repeat and keeps the first, #1573), and
6701
+ # the folded singleton tables read that order. The order is not a fact, so the singleton side is named
6702
+ # unpositioned and the chain settles to the readers' own answer.
6703
+ accumulator.taint(current_class, :extend) if targets.any? { |target| list.include?(target) }
6704
+ list.unshift(*targets)
6705
+ accumulator.note(node, current_class, :extend, targets, complete: targets.size == arguments.size)
6706
+ targets
6096
6707
  end
6097
6708
 
6098
6709
  # The materialization half of #526: for every `C extends M`, M's INSTANCE defs become C's
@@ -6100,36 +6711,133 @@ module Rigor
6100
6711
  # call-site return inference runs with `self = Singleton[C]`, which is what Ruby binds). A name C
6101
6712
  # already defines on its own singleton wins (`||=`); an extend target with no discovered defs
6102
6713
  # contributes nothing (RBS-module extends stay on the dispatch tiers).
6103
- def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods)
6714
+ #
6715
+ # ADR-119 WD1 — the copy follows its siblings, which this writes (copy on write; a table the caller passed
6716
+ # is never mutated). `unpositioned` is the project's `unpositioned_mixins`; an edge it lists, or taints
6717
+ # with "*", is not a fact, so what it copies is `possible`:
6718
+ #
6719
+ # - a slot the copy WROTE is contested when the edge is listed or the source slot is contested (a slot some
6720
+ # other copy wrote was decided by that copy, which the `||=` lets win);
6721
+ # - the singleton name is `possible` when the edge is listed or the source name is possible on the instance
6722
+ # side, unless C's singleton already answers it certainly (never `:both` from here: the instance side is
6723
+ # not touched);
6724
+ # - a certain copy makes the singleton name certain, so `:singleton` leaves the name's possible kind.
6725
+ #
6726
+ # A contested source with a certain edge contests the slot and leaves the name certain: a value-only
6727
+ # contest does not make the name possible.
6728
+ def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods, siblings:,
6729
+ unpositioned:)
6730
+ owned = {}.compare_by_identity
6104
6731
  extends.each do |class_name, mods|
6732
+ listed_mods = unpositioned.dig(class_name, :extend)
6105
6733
  mods.each do |mod_name|
6106
- source_defs = extend_source_defs(def_nodes, class_name, mod_name)
6734
+ resolved, source_defs = extend_source_defs(def_nodes, class_name, mod_name)
6107
6735
  next if source_defs.nil?
6108
6736
 
6109
6737
  # An inner table inherited unchanged from a frozen seed must be thawed before the fold writes.
6110
6738
  target = singleton_def_nodes[class_name]
6111
6739
  target = singleton_def_nodes[class_name] = (target ? target.dup : {}) if target.nil? || target.frozen?
6740
+ listed = extend_edge_listed?(listed_mods, mod_name)
6112
6741
  source_defs.each do |method_name, def_node|
6742
+ wrote = target[method_name].nil?
6113
6743
  target[method_name] ||= def_node
6744
+ certain_before = singleton_name_certain?(methods, siblings, class_name, method_name)
6114
6745
  record_method_kind(methods, class_name, method_name, :singleton)
6746
+ follow_extend_siblings(siblings, owned, [class_name, resolved, method_name],
6747
+ wrote: wrote, listed: listed, certain_before: certain_before)
6115
6748
  end
6116
6749
  end
6117
6750
  end
6118
6751
  end
6119
6752
 
6753
+ # Whether `unpositioned_mixins` lists the extend edge to `mod_name` (or taints the whole side).
6754
+ def extend_edge_listed?(listed_mods, mod_name)
6755
+ !listed_mods.nil? && (listed_mods.include?(mod_name) || listed_mods.include?(MixinAccumulator::WILDCARD))
6756
+ end
6757
+
6758
+ # Whether `class_name`'s singleton already answers `method_name` through a certain fact: the existence table
6759
+ # covers the singleton side and the `possible` copy does not.
6760
+ def singleton_name_certain?(methods, siblings, class_name, method_name)
6761
+ return false unless covers_singleton?(methods.dig(class_name, method_name))
6762
+
6763
+ !covers_singleton?(siblings[:possible_discovered_methods].dig(class_name, method_name))
6764
+ end
6765
+
6766
+ SINGLETON_KINDS = [:singleton, Scope::DiscoveryIndex::METHOD_KIND_BOTH].freeze
6767
+ INSTANCE_KINDS = [:instance, Scope::DiscoveryIndex::METHOD_KIND_BOTH].freeze
6768
+ private_constant :SINGLETON_KINDS, :INSTANCE_KINDS
6769
+
6770
+ def covers_singleton?(kind)
6771
+ SINGLETON_KINDS.include?(kind)
6772
+ end
6773
+
6774
+ # The sibling half of one copied name; see {#fold_extends_into_singleton_tables}. `names` is
6775
+ # `[extender, resolved source module, method name]`.
6776
+ def follow_extend_siblings(siblings, owned, names, wrote:, listed:, certain_before:)
6777
+ class_name, resolved, method_name = names
6778
+ contested_source = !siblings[:contested_discovered_def_nodes].empty? &&
6779
+ siblings[:contested_discovered_def_nodes].include?([resolved, method_name])
6780
+ if wrote && (listed || contested_source)
6781
+ own_sibling_table(siblings, owned, :contested_discovered_singleton_def_nodes) << [class_name, method_name]
6782
+ end
6783
+ if listed || source_name_possible?(siblings, resolved, method_name)
6784
+ return if certain_before
6785
+
6786
+ row = (own_sibling_table(siblings, owned, :possible_discovered_methods)[class_name] ||= {})
6787
+ row[method_name] = merge_sibling_kind(row[method_name], :singleton)
6788
+ else
6789
+ strip_possible_singleton(siblings, owned, class_name, method_name)
6790
+ end
6791
+ end
6792
+
6793
+ def source_name_possible?(siblings, resolved, method_name)
6794
+ INSTANCE_KINDS.include?(siblings[:possible_discovered_methods].dig(resolved, method_name))
6795
+ end
6796
+
6797
+ def merge_sibling_kind(sitting, arriving)
6798
+ sitting.nil? || sitting == arriving ? arriving : Scope::DiscoveryIndex::METHOD_KIND_BOTH
6799
+ end
6800
+
6801
+ # A certain copy: `:singleton` leaves the `possible` kind (`:both` becomes `:instance`), and an emptied row goes.
6802
+ def strip_possible_singleton(siblings, owned, class_name, method_name)
6803
+ return unless covers_singleton?(siblings[:possible_discovered_methods].dig(class_name, method_name))
6804
+
6805
+ possible = own_sibling_table(siblings, owned, :possible_discovered_methods)
6806
+ row = possible[class_name]
6807
+ if row[method_name] == :singleton
6808
+ row.delete(method_name)
6809
+ possible.delete(class_name) if row.empty?
6810
+ else
6811
+ row[method_name] = :instance
6812
+ end
6813
+ end
6814
+
6815
+ # The sibling `key` as a table this fold owns: the first write copies it (a Set, or a Hash of Hashes) and
6816
+ # keeps the copy in `siblings`, so a table shared with a seed, a bundle or an empty constant is never written.
6817
+ def own_sibling_table(siblings, owned, key)
6818
+ table = siblings.fetch(key)
6819
+ return table if owned.key?(table)
6820
+
6821
+ copy = table.is_a?(Set) ? table.dup : table.transform_values(&:dup)
6822
+ owned[copy] = true
6823
+ siblings[key] = copy
6824
+ end
6825
+
6120
6826
  # `extend CustomSig` inside `module Outer; class F` stores the as-written name, while
6121
6827
  # `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.
6828
+ # innermost-first so the fold finds the same module Ruby constant-lookup would. Returns
6829
+ # `[the key the defs were found under, the defs]`, or nil.
6123
6830
  def extend_source_defs(def_nodes, class_name, mod_name)
6124
6831
  found = def_nodes[mod_name]
6125
- return found if found
6832
+ return [mod_name, found] if found
6126
6833
  return nil if mod_name.nil? || mod_name.start_with?("::")
6127
6834
 
6128
6835
  parts = class_name.to_s.split("::")
6129
6836
  while parts.size > 1
6130
6837
  parts.pop
6131
- found = def_nodes["#{parts.join('::')}::#{mod_name}"]
6132
- return found if found
6838
+ key = "#{parts.join('::')}::#{mod_name}"
6839
+ found = def_nodes[key]
6840
+ return [key, found] if found
6133
6841
  end
6134
6842
  nil
6135
6843
  end
@@ -6155,9 +6863,83 @@ module Rigor
6155
6863
  # Top-level (no surrounding class) defs do not contribute — Ruby's top-level visibility nuances (private at
6156
6864
  # top-level marks the method on `Object`) are out of scope for v0.1.2.
6157
6865
  def build_discovered_method_visibilities(root)
6158
- accumulator = {}
6866
+ build_method_visibility_tables(root).first
6867
+ end
6868
+
6869
+ # ADR-119 WD3 — the visibility table and its `contested_discovered_method_visibilities` sibling, from ONE walk
6870
+ # over the file's {Certainty} classification `certainty`: `[table, contested key paths]`. The recorded values
6871
+ # are {#build_discovered_method_visibilities}'s; a slot is contested when its last writer is ({VisibilityTable}).
6872
+ def build_method_visibility_tables(root, certainty: Certainty.possible_nodes(root))
6873
+ accumulator = VisibilityTable.new.track(certainty)
6159
6874
  walk_method_visibilities(root, [], false, :public, accumulator)
6160
- accumulator.transform_values(&:freeze).freeze
6875
+ [accumulator.transform_values(&:freeze).freeze, accumulator.contested_paths]
6876
+ end
6877
+
6878
+ # The bare calls that set the running default visibility of the body they run in: the modifiers and
6879
+ # `module_function`, which {#walk_method_visibilities} does not track as a value (#1569) but which moves the
6880
+ # default all the same.
6881
+ VISIBILITY_TOGGLES = (VISIBILITY_MODIFIERS + %i[module_function]).freeze
6882
+
6883
+ # The `{class => {name => visibility}}` accumulator of {#walk_method_visibilities}, which also carries the
6884
+ # ADR-119 WD3 state of the slots it writes. A slot is contested when the write that holds it is possible: a
6885
+ # `def` or a named target (`private :x`) the {Certainty} classification reads as possible, or a `def` written
6886
+ # after an UNCERTAIN bare toggle of its body. A toggle is uncertain when it is possible itself or when the walk
6887
+ # does not carry it into the body's running default: one under any child of the body that is not its
6888
+ # statement list (`if c then private end`, `begin private end`, a block, `(private)`), since Ruby applies a
6889
+ # bare toggle there to the body's later `def`s while the walk keeps the default it had. A certain toggle
6890
+ # written directly in the body settles the default again. A named target does not read the running default,
6891
+ # so a toggle does not contest it. A certain write clears the slot's contest.
6892
+ class VisibilityTable < Hash
6893
+ # The bare toggles the walk has met so far; a body's statement loop compares it around each child.
6894
+ attr_accessor :toggles
6895
+ # Whether the running default of the body being walked rests on an uncertain toggle.
6896
+ attr_accessor :uncertain
6897
+ # The file's {Certainty} answer.
6898
+ attr_reader :certainty
6899
+
6900
+ def track(certainty)
6901
+ @certainty = certainty
6902
+ @contested = nil
6903
+ @toggles = 0
6904
+ @uncertain = false
6905
+ self
6906
+ end
6907
+
6908
+ # Records that `node` wrote the `[class_name, name]` slot; `toggled` when the write reads the running
6909
+ # default (a `def`).
6910
+ def wrote(class_name, name, node, toggled:)
6911
+ if Certainty.possible?(@certainty, node) || (toggled && @uncertain)
6912
+ (@contested ||= Set.new) << [class_name, name].freeze
6913
+ else
6914
+ @contested&.delete([class_name, name])
6915
+ end
6916
+ end
6917
+
6918
+ # Runs the block as a fresh body (a class or module, `class << self`, a meta-new or eval block), whose
6919
+ # default starts certain and whose toggles do not reach the enclosing body.
6920
+ def body
6921
+ saved_toggles = @toggles
6922
+ saved_uncertain = @uncertain
6923
+ @uncertain = false
6924
+ yield
6925
+ ensure
6926
+ @toggles = saved_toggles
6927
+ @uncertain = saved_uncertain
6928
+ end
6929
+
6930
+ def contested_paths
6931
+ contested = @contested
6932
+ return Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_method_visibilities] if contested.nil?
6933
+ return Scope::DiscoveryIndex::EMPTY_SIBLINGS[:contested_discovered_method_visibilities] if contested.empty?
6934
+
6935
+ contested.freeze
6936
+ end
6937
+ end
6938
+
6939
+ # A bare toggle ({VISIBILITY_TOGGLES}): receiverless, with no arguments.
6940
+ def bare_visibility_toggle?(node)
6941
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && VISIBILITY_TOGGLES.include?(node.name) &&
6942
+ (node.arguments.nil? || node.arguments.arguments.empty?) && node.block.nil?
6161
6943
  end
6162
6944
 
6163
6945
  # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/AbcSize
@@ -6179,8 +6961,10 @@ module Rigor
6179
6961
  child_cref = unnameable_decl?(node, self_decl, singleton_cref)
6180
6962
  body_prefix = child_cref ? [] : child_prefix
6181
6963
  if node.body
6182
- walk_method_visibilities(node.body, body_prefix, false, :public,
6183
- accumulator, nil, singleton_cref: child_cref)
6964
+ accumulator.body do
6965
+ walk_method_visibilities(node.body, body_prefix, false, :public,
6966
+ accumulator, nil, singleton_cref: child_cref)
6967
+ end
6184
6968
  end
6185
6969
  return current_visibility
6186
6970
  end
@@ -6200,9 +6984,11 @@ module Rigor
6200
6984
  defs_singleton: defs_singleton)
6201
6985
  end
6202
6986
  if body
6203
- walk_method_visibilities(body, qualified_prefix, false, :public,
6204
- accumulator, body_self,
6205
- singleton_cref: singleton_cref)
6987
+ accumulator.body do
6988
+ walk_method_visibilities(body, qualified_prefix, false, :public,
6989
+ accumulator, body_self,
6990
+ singleton_cref: singleton_cref)
6991
+ end
6206
6992
  end
6207
6993
  return current_visibility
6208
6994
  end
@@ -6217,6 +7003,7 @@ module Rigor
6217
7003
  defs_singleton: defs_singleton)
6218
7004
  return current_visibility
6219
7005
  end
7006
+ accumulator.toggles += 1 if bare_visibility_toggle?(node)
6220
7007
  # The visibility table is instance-side only — `private :x` inside `class <<`
6221
7008
  # (or a receiver-eval body that re-evaluates a singleton self) marks the
6222
7009
  # SINGLETON method, which this table cannot express.
@@ -6230,13 +7017,23 @@ module Rigor
6230
7017
 
6231
7018
  # Statement-position StatementsNode preserves left-to-right visibility flow; everything else recurses with the
6232
7019
  # entry visibility unchanged.
7020
+ #
7021
+ # ADR-119 WD3 — the statement loop also settles whether the running default is uncertain
7022
+ # ({VisibilityTable}): a bare toggle written here sets it from its own certainty, and any other statement
7023
+ # under which the walk met a toggle makes it uncertain.
6233
7024
  if node.is_a?(Prism::StatementsNode)
6234
7025
  local_visibility = current_visibility
6235
7026
  node.rigor_each_child do |child|
7027
+ toggles = accumulator.toggles
6236
7028
  local_visibility = walk_method_visibilities(child, qualified_prefix, in_singleton_class,
6237
7029
  local_visibility, accumulator, def_owner_prefix,
6238
7030
  singleton_cref: singleton_cref,
6239
7031
  defs_singleton: defs_singleton)
7032
+ if bare_visibility_toggle?(child)
7033
+ accumulator.uncertain = Certainty.possible?(accumulator.certainty, child)
7034
+ elsif accumulator.toggles != toggles
7035
+ accumulator.uncertain = true
7036
+ end
6240
7037
  end
6241
7038
  else
6242
7039
  node.rigor_each_child do |child|
@@ -6263,8 +7060,10 @@ module Rigor
6263
7060
  defs_singleton: defs_singleton)
6264
7061
  return unless node.body
6265
7062
 
6266
- walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator,
6267
- singleton_prefix, singleton_cref: true)
7063
+ accumulator.body do
7064
+ walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator,
7065
+ singleton_prefix, singleton_cref: true)
7066
+ end
6268
7067
  end
6269
7068
 
6270
7069
  # The eval-block arm of {#walk_method_visibilities}: the block is a fresh class body under
@@ -6295,10 +7094,12 @@ module Rigor
6295
7094
  # receiver-as-module and stay instance-side (`in_singleton_class` off) for a class
6296
7095
  # receiver.
6297
7096
  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)
7097
+ accumulator.body do
7098
+ node.block.rigor_each_child do |child|
7099
+ walk_method_visibilities(child, qualified_prefix, call_singleton, :public, accumulator,
7100
+ eval_prefix, singleton_cref: singleton_cref,
7101
+ defs_singleton: defs_flag)
7102
+ end
6302
7103
  end
6303
7104
  end
6304
7105
 
@@ -6309,6 +7110,7 @@ module Rigor
6309
7110
  class_name = qualified_prefix.join("::")
6310
7111
  accumulator[class_name] ||= {}
6311
7112
  accumulator[class_name][def_node.name] = current_visibility
7113
+ accumulator.wrote(class_name, def_node.name, def_node, toggled: true)
6312
7114
  end
6313
7115
 
6314
7116
  # Recognises modifier calls on the implicit-self receiver inside a class body. Returns the (possibly updated)
@@ -6329,19 +7131,20 @@ module Rigor
6329
7131
  if args.empty?
6330
7132
  call_node.name
6331
7133
  else
6332
- apply_named_visibility(args, qualified_prefix, call_node.name, accumulator)
7134
+ apply_named_visibility(args, qualified_prefix, call_node, accumulator)
6333
7135
  current_visibility
6334
7136
  end
6335
7137
  end
6336
7138
 
6337
- def apply_named_visibility(args, qualified_prefix, visibility, accumulator)
7139
+ def apply_named_visibility(args, qualified_prefix, call_node, accumulator)
6338
7140
  class_name = qualified_prefix.join("::")
6339
7141
  args.each do |arg|
6340
7142
  name = visibility_target_name(arg)
6341
7143
  next if name.nil?
6342
7144
 
6343
7145
  accumulator[class_name] ||= {}
6344
- accumulator[class_name][name] = visibility
7146
+ accumulator[class_name][name] = call_node.name
7147
+ accumulator.wrote(class_name, name, call_node, toggled: false)
6345
7148
  end
6346
7149
  end
6347
7150
 
@@ -6385,6 +7188,7 @@ module Rigor
6385
7188
  def record_method_envelope_opaque(tables, class_name, method_name)
6386
7189
  %i[instance singleton].each do |kind|
6387
7190
  record_envelope(tables.envelopes, class_name, [kind, method_name], Source::ParameterEnvelope::OPAQUE)
7191
+ contest_envelope(tables, class_name, [kind, method_name]) unless tables.certain
6388
7192
  end
6389
7193
  end
6390
7194
 
@@ -6505,8 +7309,11 @@ module Rigor
6505
7309
  # Post-pass over the `def_nodes` accumulator: for every `alias` declaration inside a class body, if the original
6506
7310
  # method name maps to a `Prism::DefNode`, register the new name pointing to the same node so inter-procedural
6507
7311
  # return-type inference works for the aliased name.
6508
- def apply_alias_def_nodes(root, accumulator)
6509
- alias_map = collect_class_alias_map(root, [], {})
7312
+ #
7313
+ # ADR-119 WD3 — the alias is the slot's writer: the new name's slot is contested when the alias is possible
7314
+ # or the slot it copies is contested, and cleared otherwise.
7315
+ def apply_alias_def_nodes(root, accumulator, tables = nil)
7316
+ alias_map = collect_class_alias_map(root, [], AliasMap.new.track(tables&.certainty))
6510
7317
  alias_map.each do |class_name, aliases|
6511
7318
  class_defs = accumulator[class_name]
6512
7319
  next unless class_defs
@@ -6516,10 +7323,40 @@ module Rigor
6516
7323
  next unless def_node.is_a?(Prism::DefNode)
6517
7324
 
6518
7325
  (accumulator[class_name] ||= {})[new_name] = def_node
7326
+ next if tables.nil?
7327
+
7328
+ certain = !alias_map.possible?(class_name, new_name) &&
7329
+ !tables.contested_def_nodes&.include?([class_name, old_name])
7330
+ follow_slot_writer(tables, :contested_def_nodes, [class_name, new_name], certain)
6519
7331
  end
6520
7332
  end
6521
7333
  end
6522
7334
 
7335
+ # The `{class => {new name => old name}}` map {#collect_class_alias_map} fills, which also remembers, per
7336
+ # entry, whether the alias that wrote it last is possible ({Certainty}).
7337
+ class AliasMap < Hash
7338
+ def track(certainty)
7339
+ @certainty = certainty || Certainty::EMPTY
7340
+ @possible = nil
7341
+ self
7342
+ end
7343
+
7344
+ # Records that `node` wrote the `[class_name, new_name]` entry.
7345
+ def wrote(class_name, new_name, node)
7346
+ return if @certainty.nil?
7347
+
7348
+ if Certainty.possible?(@certainty, node)
7349
+ (@possible ||= Set.new) << [class_name, new_name]
7350
+ else
7351
+ @possible&.delete([class_name, new_name])
7352
+ end
7353
+ end
7354
+
7355
+ def possible?(class_name, new_name)
7356
+ !@possible.nil? && @possible.include?([class_name, new_name])
7357
+ end
7358
+ end
7359
+
6523
7360
  # Builds a map `{class_name => {new_name_sym => old_name_sym}}` by walking the tree for `AliasMethodNode` nodes
6524
7361
  # inside class bodies. `leaf_owner` is the meta-new override: inside `K = Class.new { … }` the
6525
7362
  # block's aliases bind on the class the write names — it overrides `qualified_prefix`, which
@@ -6579,7 +7416,7 @@ module Rigor
6579
7416
  # class bodies (`Class.new do … end`), so the walk continues below it either way.
6580
7417
  names = alias_method_call_names(node)
6581
7418
  if names && !rec_prefix.empty? && !(singleton_cref && leaf_owner.nil?)
6582
- (accumulator[rec_prefix.join("::")] ||= {})[names.first] = names.last
7419
+ record_alias_call_entry(accumulator, rec_prefix.join("::"), names, node)
6583
7420
  end
6584
7421
  false
6585
7422
  end
@@ -6688,6 +7525,11 @@ module Rigor
6688
7525
  accumulator
6689
7526
  end
6690
7527
 
7528
+ def record_alias_call_entry(accumulator, class_name, names, node)
7529
+ (accumulator[class_name] ||= {})[names.first] = names.last
7530
+ accumulator.wrote(class_name, names.first, node) if accumulator.is_a?(AliasMap)
7531
+ end
7532
+
6691
7533
  def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
6692
7534
  return if qualified_prefix.empty?
6693
7535
  return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)
@@ -6696,6 +7538,7 @@ module Rigor
6696
7538
  new_name = alias_node.new_name.unescaped.to_sym
6697
7539
  old_name = alias_node.old_name.unescaped.to_sym
6698
7540
  (accumulator[class_name] ||= {})[new_name] = old_name
7541
+ accumulator.wrote(class_name, new_name, alias_node) if accumulator.is_a?(AliasMap)
6699
7542
  end
6700
7543
 
6701
7544
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
@@ -6848,6 +7691,8 @@ module Rigor
6848
7691
  physical = buffer ? buffer.resolve(path) : path
6849
7692
  root = Prism.parse(File.read(physical), filepath: path).value
6850
7693
  accumulate_project_index(acc, path, root)
7694
+ rescue DeclarationWalk::ContractError
7695
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
6851
7696
  rescue StandardError
6852
7697
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
6853
7698
  next
@@ -6880,6 +7725,8 @@ module Rigor
6880
7725
  code_fingerprints[path] = code_fingerprint(source, parsed.comments)
6881
7726
  declaration_signatures[path] = declaration_signature(file_index)
6882
7727
  refinements[path] = file_index[:refinements] if file_index[:refinements]
7728
+ rescue DeclarationWalk::ContractError
7729
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
6883
7730
  rescue StandardError
6884
7731
  next
6885
7732
  end
@@ -6910,9 +7757,29 @@ module Rigor
6910
7757
  append_def_signatures(parts, file_index[:def_nodes], "#")
6911
7758
  append_def_signatures(parts, file_index[:singleton_def_nodes], ".")
6912
7759
  append_envelope_signature(parts, file_index[:parameter_envelopes] || {})
7760
+ append_certainty_signature(parts, file_index[:siblings] || {})
6913
7761
  Digest::SHA256.hexdigest(parts.join("\x00"))
6914
7762
  end
6915
7763
 
7764
+ # ADR-119 WD3 — which of the file's contributions are possible is a declaration surface too: a `… end if X`
7765
+ # modifier, or moving a `def` into a block, keeps every def line and envelope and moves only this, while a
7766
+ # dependent's candidate-set read now declines where it answered.
7767
+ def append_certainty_signature(parts, siblings)
7768
+ certainty = []
7769
+ siblings.each do |name, table|
7770
+ next if table.empty?
7771
+
7772
+ if name == :possible_discovered_methods
7773
+ table.each do |class_name, row|
7774
+ row.each { |method_name, kind| certainty << "P:#{class_name}##{method_name}=#{kind}" }
7775
+ end
7776
+ else
7777
+ table.each { |path| certainty << "C:#{name}:#{path.inspect}" }
7778
+ end
7779
+ end
7780
+ parts.concat(certainty.sort)
7781
+ end
7782
+
6916
7783
  # Issue #992 — the joined envelope table is a declaration surface the per-def signatures above cannot
6917
7784
  # reconstruct: they keep the LAST `def` of a name, and they see neither a second `def` with another
6918
7785
  # shape nor a `memoize :f` beside it, either of which moves a dependent's `call.wrong-arity` verdict.
@@ -6950,13 +7817,26 @@ module Rigor
6950
7817
  def append_ancestry_signature(parts, file_index)
6951
7818
  parts.concat(file_index[:class_sources].keys.sort_by(&:to_s).map { |cn| "c:#{cn}" })
6952
7819
  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)
7820
+ append_mixin_signature(parts, file_index)
6956
7821
  parts.concat(sorted_by_class(file_index[:data_member_layouts]).map { |cn, l| "d:#{cn}=#{l.inspect}" })
6957
7822
  parts.concat(sorted_by_class(file_index[:struct_member_layouts]).map { |cn, l| "t:#{cn}=#{l.inspect}" })
6958
7823
  end
6959
7824
 
7825
+ # The mixin surface of {#append_ancestry_signature}. The lists keep SOURCE order: this is one file's own
7826
+ # index, so the order is deterministic, and it is a fact an ancestor-order reader consumes, so an edit
7827
+ # that only reorders an `include` must move the signature. The unpositioned table is there for the same
7828
+ # reason: guarding an `include` changes no name and no order, only whether the order is known.
7829
+ def append_mixin_signature(parts, file_index)
7830
+ { "i" => :includes, "p" => :prepends, "x" => :extends }.each do |tag, key|
7831
+ sorted_by_class(file_index[key] || {}).each { |cn, mods| parts << "#{tag}:#{cn}=#{Array(mods).join(',')}" }
7832
+ end
7833
+ sorted_by_class(file_index[:unpositioned_mixins] || {}).each do |cn, sides|
7834
+ sides.sort_by { |side, _| side.to_s }.each do |side, names|
7835
+ parts << "u:#{cn}.#{side}=#{names.sort.join(',')}"
7836
+ end
7837
+ end
7838
+ end
7839
+
6960
7840
  # A class-keyed table's pairs sorted by class name (stringified) for a deterministic signature.
6961
7841
  def sorted_by_class(table)
6962
7842
  table.sort_by { |cn, _| cn.to_s }
@@ -7079,6 +7959,8 @@ module Rigor
7079
7959
  root = Prism.parse(File.read(physical), filepath: path).value
7080
7960
  collect_class_decls(root, [], classes)
7081
7961
  accumulate_project_index(acc, path, root)
7962
+ rescue DeclarationWalk::ContractError
7963
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
7082
7964
  rescue StandardError
7083
7965
  # Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
7084
7966
  next
@@ -7148,6 +8030,8 @@ module Rigor
7148
8030
  classes.merge!(file_classes)
7149
8031
  fold_file_index(acc, file_index)
7150
8032
  end
8033
+ rescue DeclarationWalk::ContractError
8034
+ raise # a broken walk contract, not an unreadable file: skipping it would hide the failure
7151
8035
  rescue StandardError
7152
8036
  # Skip files that fail to parse / read; the per-file analyzer surfaces the parse error separately.
7153
8037
  next
@@ -7170,6 +8054,7 @@ module Rigor
7170
8054
  # Polymorphic over the def-node value: a re-walked file's `file_index` carries live nodes; a cached
7171
8055
  # bundle's carries {DefHandle}s. The merges never deref the value, so both fold identically.
7172
8056
  def fold_file_index(acc, file_index)
8057
+ fold_siblings(acc, file_index)
7173
8058
  fold_def_tables(acc, file_index)
7174
8059
  # Issue #992 — a pre-23 seed bundle carries no envelopes; the SCHEMA bump makes such a blob a cold
7175
8060
  # rebuild, and an absent table only ever withholds a check.
@@ -7190,6 +8075,168 @@ module Rigor
7190
8075
  fold_constant_tables(acc, file_index)
7191
8076
  end
7192
8077
 
8078
+ # ADR-119 WD1 — the `possible_*` / `contested_*` siblings of {Scope::DiscoveryIndex::SIBLINGS}, carried as ONE
8079
+ # `{sibling name => table}` Hash (`acc[:siblings]`) through the accumulator, the fold, the compact-header
8080
+ # rename, the finalize and the seed bundle, so no copy path names a sibling and none can drop one. A pre-36
8081
+ # bundle carries the empty siblings of the walk before WD3's producers, and the SCHEMA bump makes such a blob
8082
+ # a cold rebuild. Called BEFORE the file's members fold, because the `possible` rule reads the accumulator's
8083
+ # member as it stood ({#fold_file_siblings}).
8084
+ def fold_siblings(acc, file_index)
8085
+ fold_file_siblings(acc, file_index[:siblings] || {},
8086
+ methods: file_index[:methods], def_nodes: file_index[:def_nodes],
8087
+ singleton_def_nodes: file_index[:singleton_def_nodes],
8088
+ method_visibilities: file_index[:method_visibilities])
8089
+ end
8090
+
8091
+ # ADR-119 WD3 — folds one file's siblings into the accumulator under each member's own fold, so the result is
8092
+ # the same in any file order and a bundle-served file folds as its live walk would:
8093
+ #
8094
+ # - `possible_discovered_methods` by {#fold_possible_methods} against `acc[:methods]` before this file's
8095
+ # `methods` join it;
8096
+ # - the def-node and visibility slots follow their writer, as the members fold later-wins: the file's contests
8097
+ # replace the accumulator's on every key the file writes ({#fold_slot_contests});
8098
+ # - every other sibling (the joined envelopes' contests among them) by union.
8099
+ def fold_file_siblings(acc, siblings, methods:, def_nodes:, singleton_def_nodes:, method_visibilities: {})
8100
+ target = acc[:siblings]
8101
+ siblings.each do |name, table|
8102
+ target[name] =
8103
+ case name
8104
+ when :possible_discovered_methods
8105
+ fold_possible_methods(target.fetch(name), acc[:methods], table, methods)
8106
+ when :contested_discovered_def_nodes then fold_slot_contests(target.fetch(name), def_nodes, table)
8107
+ when :contested_discovered_singleton_def_nodes
8108
+ fold_slot_contests(target.fetch(name), singleton_def_nodes, table)
8109
+ when :contested_discovered_method_visibilities
8110
+ fold_slot_contests(target.fetch(name), method_visibilities, table)
8111
+ else union_sibling_value(target.fetch(name), table)
8112
+ end
8113
+ end
8114
+ end
8115
+
8116
+ # ADR-119 WD3 — the `possible_discovered_methods` fold, per name and side: `P' = (P − C_f) ∪ (P_f − C)`,
8117
+ # where `C = member − P` is what the accumulator (or seed) answers certainly and `C_f = member_f − P_f` what
8118
+ # the file does. A side some contribution supplies certainly is never possible, whatever the order the files
8119
+ # fold in. Returns `possible` itself when nothing changes; otherwise a copy (the rows are copied once).
8120
+ def fold_possible_methods(possible, members, file_possible, file_members)
8121
+ result = strip_file_certain(possible, file_possible, file_members)
8122
+ add_file_possible(result, possible, members, file_possible)
8123
+ end
8124
+
8125
+ # `P − C_f`: `possible` less the sides the file answers certainly; `possible` itself when nothing goes.
8126
+ def strip_file_certain(possible, file_possible, file_members)
8127
+ result = possible
8128
+ possible.each do |class_name, row|
8129
+ file_row = file_members[class_name]
8130
+ next if file_row.nil?
8131
+
8132
+ row.each do |method_name, kind|
8133
+ certain = kind_minus(file_row[method_name], file_possible.dig(class_name, method_name))
8134
+ remaining = kind_minus(kind, certain)
8135
+ next if remaining == kind
8136
+
8137
+ result = possible.transform_values(&:dup) if result.equal?(possible)
8138
+ remaining.nil? ? result[class_name].delete(method_name) : (result[class_name][method_name] = remaining)
8139
+ end
8140
+ result.delete(class_name) if result[class_name].empty?
8141
+ end
8142
+ result
8143
+ end
8144
+
8145
+ # `∪ (P_f − C)`: the file's possible sides the accumulator (`members` less `possible`) does not answer
8146
+ # certainly. Copies `result` first when it is still `possible`.
8147
+ def add_file_possible(result, possible, members, file_possible)
8148
+ file_possible.each do |class_name, row|
8149
+ row.each do |method_name, kind|
8150
+ added = kind_minus(kind, kind_minus(members.dig(class_name, method_name),
8151
+ possible.dig(class_name, method_name)))
8152
+ next if added.nil?
8153
+
8154
+ merged = merge_sibling_kind(result.dig(class_name, method_name), added)
8155
+ next if merged == result.dig(class_name, method_name)
8156
+
8157
+ result = possible.transform_values(&:dup) if result.equal?(possible)
8158
+ (result[class_name] ||= {})[method_name] = merged
8159
+ end
8160
+ end
8161
+ result
8162
+ end
8163
+
8164
+ # The sides of `kind` that `removed` does not cover (nil when none); either may be nil.
8165
+ def kind_minus(kind, removed)
8166
+ return kind if removed.nil? || kind.nil?
8167
+ return nil if removed == Scope::DiscoveryIndex::METHOD_KIND_BOTH || removed == kind
8168
+
8169
+ kind == Scope::DiscoveryIndex::METHOD_KIND_BOTH ? without_kind(kind, removed) : kind
8170
+ end
8171
+
8172
+ # ADR-119 WD3 — a single-valued slot's contests after a later-wins fold: the slots the file writes take the
8173
+ # file's contests (`file_contested`), every other key keeps the accumulator's (`sitting`). `file_slots` is the
8174
+ # file's `{class => {name => value}}` table. Returns `sitting` itself when nothing changes.
8175
+ def fold_slot_contests(sitting, file_slots, file_contested)
8176
+ stale = sitting.empty? ? nil : sitting.select { |class_name, name| file_slots[class_name]&.key?(name) }
8177
+ return sitting if (stale.nil? || stale.empty?) && file_contested.empty?
8178
+
8179
+ result = stale.nil? || stale.empty? ? sitting.dup : sitting - stale
8180
+ result.merge(file_contested)
8181
+ end
8182
+
8183
+ def union_paths(sitting, arriving)
8184
+ return sitting if arriving.empty?
8185
+ return arriving if sitting.empty?
8186
+
8187
+ sitting | arriving
8188
+ end
8189
+
8190
+ # The union of two sibling tables or entries. A Hash merges per key and a Set or Array unions; two scalars
8191
+ # that disagree are a `discovered_methods`-style kind and combine to `:both`, as {#merge_method_kinds} does.
8192
+ def union_sibling_value(sitting, arriving)
8193
+ return arriving if sitting.nil? || sitting.empty?
8194
+ return sitting if arriving.nil? || arriving.empty?
8195
+
8196
+ case sitting
8197
+ when Hash then sitting.merge(arriving) { |_key, left, right| union_sibling_value(left, right) }
8198
+ when Set, Array then sitting | arriving
8199
+ else sitting == arriving ? sitting : Scope::DiscoveryIndex::METHOD_KIND_BOTH
8200
+ end
8201
+ end
8202
+
8203
+ # The sibling tables keyed by file path rather than by class name, which a compact-header rename leaves alone
8204
+ # exactly as it leaves the `deferred_ranges` member alone.
8205
+ SIBLINGS_KEYED_BY_PATH = %i[possible_discovered_deferred_ranges].freeze
8206
+ private_constant :SIBLINGS_KEYED_BY_PATH
8207
+
8208
+ # {#apply_compact_header_renames!} for the siblings: a class-keyed Hash re-keys (colliding keys union), and a
8209
+ # Set of key paths renames each path's first element, the class.
8210
+ def rename_siblings(siblings, renames)
8211
+ siblings.to_h do |name, table|
8212
+ next [name, table] if SIBLINGS_KEYED_BY_PATH.include?(name) || table.empty?
8213
+
8214
+ renamed = case table
8215
+ when Set then table.to_set { |path| [rename_compact_name(renames, path.first), *path.drop(1)] }
8216
+ else rename_sibling_hash(table, renames)
8217
+ end
8218
+ [name, renamed]
8219
+ end
8220
+ end
8221
+
8222
+ def rename_sibling_hash(table, renames)
8223
+ table.each_with_object({}) do |(class_name, entry), out|
8224
+ renamed = rename_compact_name(renames, class_name)
8225
+ out[renamed] = out.key?(renamed) ? union_sibling_value(out[renamed], entry) : entry
8226
+ end
8227
+ end
8228
+
8229
+ def freeze_siblings(siblings)
8230
+ siblings.each_value { |table| freeze_sibling_table(table) }
8231
+ siblings.freeze
8232
+ end
8233
+
8234
+ def freeze_sibling_table(table)
8235
+ table.each_value { |entry| freeze_sibling_table(entry) } if table.is_a?(Hash)
8236
+ table.each { |entry| entry.freeze if entry.is_a?(Array) } if table.is_a?(Set)
8237
+ table.freeze
8238
+ end
8239
+
7193
8240
  # Issue #1120 — a union, so the fold is order-independent and a bundle-served file folds exactly as its live
7194
8241
  # walk would. The slot stays nil until some file refines something (see {#finalize_def_index}).
7195
8242
  def fold_refinements(acc, file_refinements)
@@ -7264,6 +8311,7 @@ module Rigor
7264
8311
  # resolves the class as it did before the ordering fix, which is this table's empty state.
7265
8312
  accumulate_prepend_lists(acc[:prepends], file_index[:prepends] || {})
7266
8313
  accumulate_extend_lists(acc[:extends], file_index[:extends] || {})
8314
+ accumulate_unpositioned(acc[:unpositioned_mixins], file_index[:unpositioned_mixins] || {})
7267
8315
  end
7268
8316
 
7269
8317
  # The `extends` half stores singleton-ancestor search order ({#record_extend_targets}), so a file
@@ -7288,14 +8336,14 @@ module Rigor
7288
8336
  # class. Shared with {#accumulate_extend_lists}' shape deliberately — the two tables answer the
7289
8337
  # `prepend` question on the two sides of the class object, and both fold nearest-first.
7290
8338
  def accumulate_prepend_lists(target, additions)
7291
- additions.each { |cn, mods| target[cn] = (mods + (target[cn] || [])).uniq }
8339
+ additions.each { |cn, mods| target[cn] = (mods + (target[cn] || [])) }
7292
8340
  end
7293
8341
 
7294
8342
  # ADR-85 WD2 — converts a file's live single-file index + its class table into a Marshal-clean seed
7295
8343
  # bundle: the plain-data tables verbatim, the def-node tables re-expressed as `[node_id, name,
7296
8344
  # fingerprint]` triples (the path is the bundle key), the class-source names (path implicit), and the
7297
8345
  # content digest that gates the bundle's reuse.
7298
- def build_seed_bundle(file_index, file_classes, digest, code_fingerprint)
8346
+ def build_seed_bundle(file_index, file_classes, digest, code_fingerprint) # rubocop:disable Metrics/AbcSize -- one read per bundle slot
7299
8347
  {
7300
8348
  digest: digest,
7301
8349
  # B1 — the comment-stripped code fingerprint, so a recheck can prove this file's edit was
@@ -7324,6 +8372,9 @@ module Rigor
7324
8372
  # the bundle stays Marshal-clean and a warm incremental file orders its prepends the way a cold
7325
8373
  # walk of it does.
7326
8374
  prepends: file_index[:prepends],
8375
+ # The mixin edges whose order is not a fact — plain `{class name => Array[String]}` data, so the
8376
+ # bundle stays Marshal-clean and a warm file flags the edges its cold walk flags.
8377
+ unpositioned_mixins: file_index[:unpositioned_mixins],
7327
8378
  method_visibilities: file_index[:method_visibilities],
7328
8379
  methods: file_index[:methods],
7329
8380
  # Issue #992 — plain `{class name => {[kind, name] => [min, max, required_keywords] | :opaque}}`
@@ -7345,14 +8396,16 @@ module Rigor
7345
8396
  # nothing), so the bundle stays Marshal-clean.
7346
8397
  refinements: file_index[:refinements],
7347
8398
  # Issue #1367 — the file's {GlobalWriteCensus}, a Set of frozen Arrays, which Marshal round-trips.
7348
- global_write_census: file_index[:global_write_census]
8399
+ global_write_census: file_index[:global_write_census],
8400
+ # ADR-119 WD1 — `{sibling name => table}`, plain Hashes, Sets and rows, so the bundle stays Marshal-clean.
8401
+ siblings: file_index[:siblings]
7349
8402
  }
7350
8403
  end
7351
8404
 
7352
8405
  # ADR-85 WD2 — reconstitutes a cached bundle into a single-file index {#fold_file_index} folds: the
7353
8406
  # def-node triples become {DefHandle}s bound to this file's `path`, and the class-source names become a
7354
8407
  # `{name => Set[path]}` table (the shape `accumulate_project_index` produces).
7355
- def bundle_to_file_index(bundle, path)
8408
+ def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity -- one read per bundle slot
7356
8409
  {
7357
8410
  def_nodes: bundle_defs_to_handles(bundle[:def_nodes], path),
7358
8411
  singleton_def_nodes: bundle_defs_to_handles(bundle[:singleton_def_nodes], path),
@@ -7368,6 +8421,8 @@ module Rigor
7368
8421
  prepends: bundle[:prepends] || {},
7369
8422
  # #526 — pre-extends bundles lack the key; default `{}` keeps the fold total.
7370
8423
  extends: bundle[:extends] || {},
8424
+ # A pre-32 bundle lacks the key; the SCHEMA bump rebuilds it cold, and the fold defaults it anyway.
8425
+ unpositioned_mixins: bundle[:unpositioned_mixins],
7371
8426
  method_visibilities: bundle[:method_visibilities],
7372
8427
  methods: bundle[:methods],
7373
8428
  parameter_envelopes: bundle[:parameter_envelopes] || {},
@@ -7384,7 +8439,9 @@ module Rigor
7384
8439
  # Issue #1120 — absent from a pre-28 bundle, which the SCHEMA bump rebuilds cold.
7385
8440
  refinements: bundle[:refinements],
7386
8441
  # Issue #1367 — absent from a pre-29 bundle, which the SCHEMA bump rebuilds cold.
7387
- global_write_census: bundle[:global_write_census] || GlobalWriteCensus::EMPTY
8442
+ global_write_census: bundle[:global_write_census] || GlobalWriteCensus::EMPTY,
8443
+ # ADR-119 WD1 — absent from a pre-35 bundle, which the SCHEMA bump rebuilds cold.
8444
+ siblings: bundle[:siblings] || Scope::DiscoveryIndex::EMPTY_SIBLINGS
7388
8445
  }
7389
8446
  end
7390
8447
 
@@ -7415,7 +8472,8 @@ module Rigor
7415
8472
  def new_def_index_accumulator
7416
8473
  { def_nodes: {}, def_nestings: {}.compare_by_identity,
7417
8474
  singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
7418
- superclasses: {}, header_nestings: {}, includes: {}, prepends: {}, extends: {}, method_visibilities: {},
8475
+ superclasses: {}, header_nestings: {}, includes: {}, prepends: {}, extends: {}, unpositioned_mixins: {},
8476
+ method_visibilities: {},
7419
8477
  methods: {},
7420
8478
  deferred_ranges: {},
7421
8479
  parameter_envelopes: {}, class_sources: {},
@@ -7424,7 +8482,9 @@ module Rigor
7424
8482
  constant_writes: {},
7425
8483
  data_member_layouts: {}, struct_member_layouts: {},
7426
8484
  # Issue #1367 — the project's {GlobalWriteCensus}.
7427
- global_write_census: Set.new }
8485
+ global_write_census: Set.new,
8486
+ # ADR-119 WD1 — the pair siblings, `{sibling name => table}` (see {#fold_siblings}).
8487
+ siblings: Scope::DiscoveryIndex::EMPTY_SIBLINGS.dup }
7428
8488
  end
7429
8489
 
7430
8490
  # Post-processes and freezes a fully-folded def-index accumulator.
@@ -7437,27 +8497,42 @@ module Rigor
7437
8497
  # Issue #644 — resolve the cross-file constant-reassignment rule here, where the whole project's
7438
8498
  # write census is known, and turn the surviving literals into their published `Type::Constant`.
7439
8499
  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])
8500
+ # ADR-119 WD1 — the two passes below rewrite `def_nodes`, `singleton_def_nodes` and `methods`, and each carries
8501
+ # its sibling along. `subtract_def_methods` drops `def`-declared names from `methods`, and
8502
+ # {#subtract_sibling_methods!} applies the same drop to `possible_discovered_methods`. The extends fold copies
8503
+ # an extended module's instance defs onto the extender's singleton, and follows the siblings the same way (a
8504
+ # copy is possible or contested when its edge is listed in `unpositioned_mixins` or its source was).
8505
+ # Until a producer fills a sibling and an edge is listed, there is nothing for either pass to follow.
8506
+ fold_project_extends(acc)
7441
8507
  # Cross-file method suppression is for the project's OWN accessors (attr_* / define_method / alias) — NOT for
7442
8508
  # plain `def`s. A cross-file `def` on a class is exactly the ADR-17 monkey-patch case the undefined-method rule
7443
8509
  # deliberately surfaces (fire + def-site annotation, nudging `pre_eval:`), so dropping the `def`-declared names
7444
8510
  # keeps that contract intact while still letting `attr_reader :x` in one file suppress a false undefined-method
7445
8511
  # for `obj.x` in another.
7446
8512
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
8513
+ subtract_sibling_methods!(acc)
7447
8514
  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|
8515
+ %i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes prepends
8516
+ method_visibilities methods parameter_envelopes class_sources constant_sources deferred_ranges].each do |key|
7450
8517
  acc[key].each_value(&:freeze)
7451
8518
  end
7452
8519
  acc.transform_values(&:freeze)
7453
8520
  end
7454
8521
 
8522
+ # The project-wide extends fold over the accumulator's tables and siblings.
8523
+ def fold_project_extends(acc)
8524
+ fold_extends_into_singleton_tables(acc[:extends], acc[:def_nodes], acc[:singleton_def_nodes], acc[:methods],
8525
+ siblings: acc[:siblings], unpositioned: acc[:unpositioned_mixins])
8526
+ end
8527
+
7455
8528
  # The two whole-project tables only call rules read: the issue #992 envelope table gains its project-wide
7456
8529
  # key, and the issue #1120 refinement table, nil until some file refines something, settles to a frozen one.
7457
8530
  def finalize_call_surface_tables(acc)
7458
8531
  acc[:parameter_envelopes][Scope::DiscoveryIndex::ENVELOPE_PROJECT_WIDE] ||= {}
7459
8532
  acc[:refinements] = freeze_refinements(acc[:refinements])
7460
8533
  acc[:global_write_census] = acc[:global_write_census].freeze
8534
+ acc[:unpositioned_mixins].each_value { |sides| sides.each_value(&:freeze).freeze }
8535
+ freeze_siblings(acc[:siblings])
7461
8536
  end
7462
8537
 
7463
8538
  # Removes, per class, the method names that have a project `def` node, leaving only
@@ -7478,6 +8553,30 @@ module Rigor
7478
8553
  end
7479
8554
  end
7480
8555
 
8556
+ # ADR-119 WD1 — the `possible` copy of `methods` loses the INSTANCE half of a name that has a project `def`
8557
+ # (`:instance` goes, `:both` becomes `:singleton`), so `possible_discovered_methods` stays a subset of its
8558
+ # member after the rule above. A `:singleton` entry stays: the `def` is the instance side, and the member
8559
+ # keeps the name's singleton half. Only that sibling is rewritten.
8560
+ def subtract_sibling_methods!(acc)
8561
+ possible = acc[:siblings][:possible_discovered_methods]
8562
+ return if possible.nil?
8563
+
8564
+ acc[:siblings] =
8565
+ acc[:siblings].merge(possible_discovered_methods: subtract_sibling_instance_defs(possible, acc[:def_nodes]))
8566
+ end
8567
+
8568
+ def subtract_sibling_instance_defs(possible, def_nodes)
8569
+ possible.each_with_object({}) do |(class_name, table), out|
8570
+ defs = def_nodes[class_name] || {}
8571
+ kept = table.each_with_object({}) do |(method_name, kind), acc|
8572
+ next acc[method_name] = kind unless defs.key?(method_name)
8573
+
8574
+ acc[method_name] = :singleton unless kind == :instance
8575
+ end
8576
+ out[class_name] = kept unless kept.empty?
8577
+ end
8578
+ end
8579
+
7481
8580
  # Folds one file's class-keyed indexes into the cross-file accumulator. `method_visibilities` (ADR-35) is
7482
8581
  # collected here so the override-visibility-reduced rule can read an ancestor's visibility declared in a sibling
7483
8582
  # file.
@@ -7485,26 +8584,43 @@ module Rigor
7485
8584
  # One combined descent yields both the methods existence table and the def-node table; the latter is also
7486
8585
  # consumed by `record_class_sources`, so a def-dense file is walked once here instead of three times (methods +
7487
8586
  # 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)
8587
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty =
8588
+ walk_def_contributions(acc, path, root)
7489
8589
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
7490
8590
  fold_parameter_envelopes(acc, file_envelopes)
7491
8591
  fold_refinements(acc, file_refinements)
7492
8592
  # 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))
8593
+ walked = declaration_walk_tables(root, path)
8594
+ acc[:def_nestings].merge!(walked.fetch(:def_nestings))
7494
8595
  # ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
7495
8596
  # the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
7496
8597
  # produces a changed `"Class.method"` fingerprint pair (and its call sites a symbol edge) instead of
7497
8598
  # silently degrading to the file's full ancestry closure.
7498
8599
  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)
8600
+ file_singleton_def_nodes)
8601
+ superclasses = walked.fetch(:superclasses)
7501
8602
  acc[:superclasses].merge!(superclasses)
7502
- merge_header_nestings(acc[:header_nestings], header_nestings)
8603
+ merge_header_nestings(acc[:header_nestings], walked.fetch(:header_nestings))
7503
8604
  ancestry_keys = fold_file_mixin_tables(acc, root)
7504
8605
  record_file_positions(acc, path, root, superclasses, ancestry_keys, file_def_nodes)
7505
8606
  merge_constant_literal_tables(acc, root, path)
7506
- merge_class_keyed_index_tables(acc, root, file_methods)
7507
- merge_member_layout_tables(acc, root)
8607
+ merge_class_keyed_index_tables(acc, root, file_methods, certainty)
8608
+ merge_member_layout_tables(acc, walked)
8609
+ end
8610
+
8611
+ # The two def-contribution walks of {#accumulate_project_index}, over ONE {Certainty} classification, with the
8612
+ # file's siblings folded into the accumulator BEFORE its members join it (ADR-119 WD3: the `possible` rule
8613
+ # reads the accumulator's member as it stood). Returns `[methods, def_nodes, envelopes, refinements,
8614
+ # singleton_def_nodes, certainty]`, the classification passed on for the visibility walk.
8615
+ def walk_def_contributions(acc, path, root)
8616
+ certainty = Certainty.possible_nodes(root)
8617
+ file_methods, file_def_nodes, file_envelopes, file_refinements, file_siblings =
8618
+ build_methods_and_def_nodes(root, path, certainty: certainty)
8619
+ file_singleton_def_nodes, file_singleton_contested = build_singleton_def_tables(root, certainty: certainty)
8620
+ fold_file_siblings(acc, file_siblings.merge(contested_discovered_singleton_def_nodes: file_singleton_contested),
8621
+ methods: file_methods, def_nodes: file_def_nodes,
8622
+ singleton_def_nodes: file_singleton_def_nodes)
8623
+ [file_methods, file_def_nodes, file_envelopes, file_refinements, file_singleton_def_nodes, certainty]
7508
8624
  end
7509
8625
 
7510
8626
  # Issue #1123 — this file's three instance- / singleton-side module lists, folded into the
@@ -7516,12 +8632,24 @@ module Rigor
7516
8632
  # {#accumulate_project_index} to hold its ABC budget.
7517
8633
  def fold_file_mixin_tables(acc, root)
7518
8634
  mixin = mixin_tables(root)
8635
+ extend_side = extend_tables(root)
7519
8636
  accumulate_include_lists(acc[:includes], mixin[:includes])
7520
8637
  accumulate_prepend_lists(acc[:prepends], mixin[:prepends])
7521
- accumulate_extend_lists(acc[:extends], build_discovered_extends(root))
8638
+ accumulate_extend_lists(acc[:extends], extend_side[:extends])
8639
+ accumulate_unpositioned(acc[:unpositioned_mixins], mixin[:unpositioned])
8640
+ accumulate_unpositioned(acc[:unpositioned_mixins], extend_side[:unpositioned])
7522
8641
  mixin[:includes].merge(mixin[:prepends]) { |_cn, included_mods, _prepends| included_mods }
7523
8642
  end
7524
8643
 
8644
+ # {Scope::DiscoveryIndex#unpositioned_mixins} folds by union, so the fold is order-independent and a
8645
+ # bundle-served file folds exactly as its live walk would.
8646
+ def accumulate_unpositioned(target, additions)
8647
+ additions.each do |class_name, sides|
8648
+ sitting = (target[class_name] ||= {})
8649
+ sides.each { |side, names| sitting[side] = sitting.key?(side) ? (sitting[side] | names) : names.dup }
8650
+ end
8651
+ end
8652
+
7525
8653
  # Issue #644 — folds one file's publication census into the cross-file accumulator, keyed by
7526
8654
  # (name, path) (kept out of {#accumulate_project_index} to hold its ABC budget). The conflict rule that
7527
8655
  # makes the fold order irrelevant runs at {#finalize_constant_writes}. Issue #1367 — the same walk carries the
@@ -7536,16 +8664,24 @@ module Rigor
7536
8664
 
7537
8665
  # Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
7538
8666
  # {#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))
8667
+ def merge_member_layout_tables(acc, walked)
8668
+ acc[:data_member_layouts].merge!(walked.fetch(:data_member_layouts))
8669
+ acc[:struct_member_layouts].merge!(walked.fetch(:struct_member_layouts))
7542
8670
  end
7543
8671
 
7544
8672
  # Folds the per-class method-visibility and method-existence tables of one file into the cross-file accumulator
7545
8673
  # (kept out of {#accumulate_project_index} to hold its ABC budget). `file_methods` is the existence table from the
7546
8674
  # 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|
8675
+ #
8676
+ # ADR-119 WD3 — the visibility slots fold later-wins, so their contests follow the file that wrote them
8677
+ # ({#fold_slot_contests}), as the def-node slots' do.
8678
+ def merge_class_keyed_index_tables(acc, root, file_methods, certainty)
8679
+ file_visibilities, file_contested = build_method_visibility_tables(root, certainty: certainty)
8680
+ siblings = acc[:siblings]
8681
+ siblings[:contested_discovered_method_visibilities] = fold_slot_contests(
8682
+ siblings[:contested_discovered_method_visibilities], file_visibilities, file_contested
8683
+ )
8684
+ file_visibilities.each do |class_name, table|
7549
8685
  (acc[:method_visibilities][class_name] ||= {}).merge!(table)
7550
8686
  end
7551
8687
  file_methods.each do |class_name, table|
@@ -7657,8 +8793,7 @@ module Rigor
7657
8793
  tables.write_census.visit(node, top_level: qualified_prefix.empty?)
7658
8794
  end
7659
8795
 
7660
- rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner,
7661
- rebound_self_base(self_owner))
8796
+ rebound = rebound_block_self(node, qualified_prefix, nil, meta_owner, self_owner)
7662
8797
  # A `ConstantWriteNode`'s only child is its rvalue, so this reaches exactly the call whose block the
7663
8798
  # constant names — and nil everywhere else, leaving every other descent as it was.
7664
8799
  #
@@ -8181,6 +9316,16 @@ module Rigor
8181
9316
  end
8182
9317
  end
8183
9318
 
9319
+ # {#rekey_class_table} for `unpositioned_mixins`: two bodies landing on one key keep BOTH sides' names,
9320
+ # where the Hash arm of {#combine_rekeyed_entries} would replace one side's list with the other's.
9321
+ def rekey_unpositioned(table, renames)
9322
+ table.each_with_object({}) do |(name, sides), out|
9323
+ out.merge!(rename_compact_name(renames, name) => sides) do |_key, sitting, arriving|
9324
+ sitting.merge(arriving) { |_side, a, b| a | b }
9325
+ end
9326
+ end
9327
+ end
9328
+
8184
9329
  def rekey_parameter_envelopes(table, renames)
8185
9330
  table.each_with_object({}) do |(name, entries), out|
8186
9331
  out.merge!(rename_compact_name(renames, name) => entries) do |_key, sitting, arriving|
@@ -8221,7 +9366,7 @@ module Rigor
8221
9366
  # definition — the pre-#728 answer for a name no site recorded — so it keeps unioning. A KEYED entry is
8222
9367
  # the cref of the site that wrote that exact name, and the two sides are two different sites, so
8223
9368
  # 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
9369
+ # ahead of `::Mixin`, and the ancestor-name resolution takes the first known class as the sole
8225
9370
  # resolution, so the top-level site's `include Mixin` silently became the `Wrap` one. That is not just
8226
9371
  # a wrong class, it is a FALSE POSITIVE source — the two modules' same-named methods can differ in
8227
9372
  # arity, and `call.wrong-arity` then fires on a correct program.
@@ -8309,6 +9454,8 @@ module Rigor
8309
9454
  struct_member_layouts constant_writes].each do |key|
8310
9455
  acc[key] = rekey_class_table(acc[key], renames)
8311
9456
  end
9457
+ acc[:unpositioned_mixins] = rekey_unpositioned(acc[:unpositioned_mixins], renames)
9458
+ acc[:siblings] = rename_siblings(acc[:siblings], renames)
8312
9459
  # Issue #992 — the envelope table cannot take {#combine_rekeyed_entries}' later-wins Hash merge: two
8313
9460
  # bodies of one class landing on the same key are exactly the reopening whose disagreement must
8314
9461
  # make a name opaque.
@@ -8486,10 +9633,11 @@ module Rigor
8486
9633
  Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
8487
9634
  return false unless child_prefix
8488
9635
 
8489
- if singleton_cref && !decl_nameable_under_cref?(node) && !self_decl&.any?
9636
+ if self_decl&.empty? || (singleton_cref && !decl_nameable_under_cref?(node) && !self_decl&.any?)
8490
9637
  # `class D` below `class <<` opens `#<singleton>::D` — a real class nothing can
8491
9638
  # name — so nothing is registered; the body still walks so nameable headers
8492
- # below it re-anchor at a real cref.
9639
+ # below it re-anchor at a real cref. The same holds for `class self::D` where
9640
+ # `self` is opaque (`REGISTRY.first.class_eval { … }`): `self_decl` is `[]`.
8493
9641
  if node.body
8494
9642
  record_declarations(node.body, [], identity_table, discovered, renames,
8495
9643
  nil, singleton_cref: true)