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
data/lib/rigor/scope.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative "type"
4
4
  require_relative "environment"
5
5
  require_relative "scope/discovery_index"
6
+ require_relative "scope/resolution_chain"
7
+ require_relative "inference/definer_resolution"
6
8
  require_relative "analysis/fact_store"
7
9
  require_relative "analysis/dependency_recorder"
8
10
  require_relative "inference/expression_typer"
@@ -29,7 +31,7 @@ module Rigor
29
31
  :void_origins, :plugin_typed_calls,
30
32
  :optimistic_origins, :optimistic_locals, :optimistic_ivars,
31
33
  :repeated_or_writes, :match_frame,
32
- :constant_narrowings, :guard_records
34
+ :constant_narrowings, :guard_records, :bot_guard_classes
33
35
 
34
36
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
35
37
  # reference; the per-table readers stay on Scope so engine call sites and plugins are unaffected by the
@@ -64,9 +66,12 @@ module Rigor
64
66
  def discovered_global_write_census = @discovery.discovered_global_write_census
65
67
  def discovered_includes = @discovery.discovered_includes
66
68
  # Issue #1123 — `{qualified class or module name => [module names it `prepend`s, as written]}`, in
67
- # instance-ancestor search order (nearest prepend first). Feeds {#prepends_of}, the one table that tells
68
- # a `prepend` from an `include` at `def`-priority level.
69
- def discovered_prepends = @discovery.discovered_prepends
69
+ # instance-ancestor search order (nearest prepend first). The one table that tells a `prepend` from an
70
+ # `include`: {ResolutionChain} reads it to put a prepended module ahead of the class.
71
+ # The plugin-facing view: each class's prepends once, nearest first. The discovery index itself keeps every
72
+ # statement (a repeated `prepend` is a no-op in Ruby, the first statement winning), and the resolution chain
73
+ # reads that raw list.
74
+ def discovered_prepends = @discovery.discovered_prepends.transform_values { |names| names.uniq.freeze }.freeze
70
75
  def discovered_extends = @discovery.discovered_extends
71
76
  def discovered_class_sources = @discovery.discovered_class_sources
72
77
  # Issue #644 — `{qualified constant name => Set[declaring file]}`; seeded only on an ADR-46 recording run.
@@ -85,9 +90,13 @@ module Rigor
85
90
  # Issue #1290 — the census names sharing `name`'s last segment that some write other than a memo `||=`
86
91
  # assigns: the ones that shadow an outer constant of the name, so the lexical ladder stops at them
87
92
  # (`Reflection.resolve_constant_type`). That asks once per constant reference, so a bare name is looked up
88
- # as it stands rather than split.
93
+ # as it stands rather than split, and a qualified one is sliced after its last `::` rather than split into
94
+ # an Array of every segment ([#1502](https://github.com/rigortype/rigor/issues/1502)). The two agree on every
95
+ # name a parsed constant path spells. Only the `Foo::` Prism recovers from a syntax error differs, and no
96
+ # file with a parse error is typed.
89
97
  def shadowing_constant_names(name)
90
- segment = name.include?("::") ? name.split("::").last : name
98
+ separator = name.rindex("::")
99
+ segment = separator ? name[(separator + 2)..] : name
91
100
  @discovery.constant_shadowers[segment] || EMPTY_BOUND_CONSTANT_NAMES
92
101
  end
93
102
 
@@ -241,12 +250,21 @@ module Rigor
241
250
  # analysis cannot see runs, so a call or block that may run project or unresolved code restores each recorded
242
251
  # binding to the union of this pre-guard type and its narrowed one ({#forget_guard_narrowings}). A write drops
243
252
  # the record ({#with_global}), and a name the frame-local special-variable machinery owns is never recorded.
253
+ # Issue #1446 — a class guard's narrowing of an instance variable is recorded too, keyed `[:ivar, :@name]`
254
+ # ({#with_guarded_ivar}), since that code may reach `self` and rebind it; a write drops it ({#without_ivar_guard}).
244
255
  EMPTY_GUARD_RECORDS = {}.freeze
256
+ # Issue #1446 — the classes a class guard narrowed a receiver to where the narrowing left it `bot`, keyed like
257
+ # {EMPTY_GUARD_RECORDS} with `[:local, :name]` too: `return unless @cb.is_a?(Proc)` binds `@cb` to `bot` when the
258
+ # guard is disjoint from its binding, but the value that passes it is a `Proc` (or a subclass of one), and a call
259
+ # on it dispatches on that class. `Inference::GuardRebinding` reads the class a `bot` receiver runs its methods
260
+ # on from here ({#bot_guard_classes_for}); a `bot` no class guard produced has no entry. A write or another
261
+ # narrowing of the name drops its entry, and a join keeps one only both arms hold.
262
+ EMPTY_BOT_GUARD_CLASSES = {}.freeze
245
263
  private_constant :EMPTY_VAR_BINDINGS, :EMPTY_INDEXED_NARROWINGS,
246
264
  :EMPTY_CHAIN_NARROWINGS, :EMPTY_DECLARATION_SOURCED,
247
265
  :EMPTY_FOLD_SAFE, :EMPTY_ORIGINS, :EMPTY_PUBLISHED_CONSTANT_SOURCED,
248
266
  :EMPTY_PUBLISHED_CONSTANT_IVARS, :EMPTY_REPEATED_OR_WRITES,
249
- :EMPTY_CONSTANT_NARROWINGS, :EMPTY_GUARD_RECORDS
267
+ :EMPTY_CONSTANT_NARROWINGS, :EMPTY_GUARD_RECORDS, :EMPTY_BOT_GUARD_CLASSES
250
268
 
251
269
  class << self
252
270
  def empty(environment: Environment.default, source_path: nil)
@@ -287,7 +305,7 @@ module Rigor
287
305
  @plugin_typed_calls.key?(node)
288
306
  end
289
307
 
290
- def initialize(
308
+ def initialize( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- one keyword and assignment per field
291
309
  environment:, locals:,
292
310
  fact_store: Analysis::FactStore.empty,
293
311
  self_type: nil,
@@ -315,7 +333,8 @@ module Rigor
315
333
  repeated_or_writes: EMPTY_REPEATED_OR_WRITES,
316
334
  match_frame: nil,
317
335
  constant_narrowings: EMPTY_CONSTANT_NARROWINGS,
318
- guard_records: EMPTY_GUARD_RECORDS
336
+ guard_records: EMPTY_GUARD_RECORDS,
337
+ bot_guard_classes: EMPTY_BOT_GUARD_CLASSES
319
338
  )
320
339
  @environment = environment
321
340
  @locals = locals
@@ -346,6 +365,7 @@ module Rigor
346
365
  @match_frame = match_frame
347
366
  @constant_narrowings = constant_narrowings
348
367
  @guard_records = guard_records
368
+ @bot_guard_classes = bot_guard_classes
349
369
  freeze
350
370
  end
351
371
 
@@ -440,7 +460,8 @@ module Rigor
440
460
  # rvalue is one.
441
461
  published_constant_sourced: drop_published_constant_sourced_for(:local, name),
442
462
  local_origins: drop_origin(@local_origins, name),
443
- optimistic_locals: keep_marks ? @optimistic_locals : drop_origin(@optimistic_locals, name))
463
+ optimistic_locals: keep_marks ? @optimistic_locals : drop_origin(@optimistic_locals, name),
464
+ bot_guard_classes: drop_bot_guard_class(:local, name))
444
465
  end
445
466
  private :bind_local
446
467
 
@@ -557,6 +578,11 @@ module Rigor
557
578
  end
558
579
 
559
580
  def with_ivar(name, type)
581
+ bind_ivar(name, type, @guard_records)
582
+ end
583
+
584
+ # The one body behind {#with_ivar} and {#with_guarded_ivar}, which differ only in the guard records they leave.
585
+ def bind_ivar(name, type, guard_records)
560
586
  new_indexed_narrowings = drop_indexed_narrowings_for(:ivar, name)
561
587
  new_chain_narrowings = drop_chain_narrowings_for(:ivar, name)
562
588
  # ADR-58 WD1 — a method-local ivar write or narrowing is flow-live: drop any declaration-sourced mark so
@@ -568,8 +594,11 @@ module Rigor
568
594
  declaration_sourced: drop_declaration_sourced_for(:ivar, name),
569
595
  published_constant_sourced: drop_published_constant_sourced_for(:ivar, name),
570
596
  ivar_origins: drop_origin(@ivar_origins, name),
571
- optimistic_ivars: drop_origin(@optimistic_ivars, name))
597
+ optimistic_ivars: drop_origin(@optimistic_ivars, name),
598
+ guard_records: guard_records,
599
+ bot_guard_classes: drop_bot_guard_class(:ivar, name))
572
600
  end
601
+ private :bind_ivar
573
602
 
574
603
  # ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only provenance is the class-ivar index.
575
604
  # Unlike `with_ivar` this binds the type AND records the declaration-sourced mark in one transition.
@@ -680,7 +709,8 @@ module Rigor
680
709
  name = name.to_sym
681
710
  written = rebuild(globals: @globals.merge(name => type).freeze,
682
711
  declaration_sourced: drop_declaration_sourced_for(:global, name),
683
- guard_records: drop_guard_record(:global, name))
712
+ guard_records: drop_guard_record(:global, name),
713
+ bot_guard_classes: drop_bot_guard_class(:global, name))
684
714
  alias_name = STDOUT_ALIASES[name]
685
715
  alias_name ? written.forget_guard_narrowing_of(:global, alias_name) : written
686
716
  end
@@ -717,7 +747,28 @@ module Rigor
717
747
  records = record ? add_guard_record([:global, name].freeze, pre_guard) : drop_guard_record(:global, name)
718
748
  rebuild(globals: @globals.merge(name => type).freeze,
719
749
  declaration_sourced: drop_declaration_sourced_for(:global, name),
720
- guard_records: records)
750
+ guard_records: records,
751
+ bot_guard_classes: drop_bot_guard_class(:global, name))
752
+ end
753
+
754
+ # Issue #1446 — binds the instance variable `name` to `type` on a class guard's edge, recording `pre_guard` as
755
+ # {#with_guarded_global} does. The truthiness, `nil?`, `&.` and `respond_to?` guards narrow an instance variable
756
+ # through {#with_ivar} and record nothing.
757
+ def with_guarded_ivar(name, type, pre_guard)
758
+ name = name.to_sym
759
+ bind_ivar(name, type, add_guard_record([:ivar, name].freeze, pre_guard))
760
+ end
761
+
762
+ # Issue #1446 — this scope with no guard record for the instance variable `name`: what a write to it leaves.
763
+ # {#with_ivar} keeps the record, since a narrowing binds through it too.
764
+ def without_ivar_guard(name)
765
+ records = drop_guard_record(:ivar, name.to_sym)
766
+ records.equal?(@guard_records) ? self : rebuild(guard_records: records)
767
+ end
768
+
769
+ # Issue #1446 — true when a class guard's narrowing of the instance variable `name` is live.
770
+ def guard_narrowed_ivar?(name)
771
+ !@guard_records.empty? && @guard_records.key?([:ivar, name.to_sym])
721
772
  end
722
773
 
723
774
  # Issue #1429 — the type a guard narrowed the constant reference `key` to, or nil.
@@ -731,7 +782,23 @@ module Rigor
731
782
  # {#with_guarded_global} does.
732
783
  def with_constant_narrowing(key, type, pre_guard)
733
784
  rebuild(constant_narrowings: @constant_narrowings.merge(key => type).freeze,
734
- guard_records: add_guard_record([:constant, key].freeze, pre_guard))
785
+ guard_records: add_guard_record([:constant, key].freeze, pre_guard),
786
+ bot_guard_classes: drop_bot_guard_class(:constant, key))
787
+ end
788
+
789
+ # Issue #1446 — this scope with `class_names` recorded as the classes a class guard narrowed the receiver `[kind,
790
+ # name]` to, where the guard left it `bot` ({EMPTY_BOT_GUARD_CLASSES}). Call it after the narrowing, which drops
791
+ # any earlier entry.
792
+ def with_bot_guard_classes(kind, name, class_names)
793
+ key = [kind, kind == :constant ? name : name.to_sym].freeze
794
+ rebuild(bot_guard_classes: @bot_guard_classes.merge(key => class_names.dup.freeze).freeze)
795
+ end
796
+
797
+ # Issue #1446 — the classes a class guard narrowed the `bot` receiver `[kind, name]` to, or nil.
798
+ def bot_guard_classes_for(kind, name)
799
+ return nil if @bot_guard_classes.empty?
800
+
801
+ @bot_guard_classes[[kind, name]]
735
802
  end
736
803
 
737
804
  # Issue #1429 — every constant reference whose last segment is `name` with no narrowing: what a write to a constant
@@ -741,7 +808,9 @@ module Rigor
741
808
  return self if keys.empty?
742
809
 
743
810
  records = @guard_records.reject { |(kind, key), _| kind == :constant && keys.include?(key) }
744
- rebuild(constant_narrowings: @constant_narrowings.except(*keys).freeze, guard_records: records.freeze)
811
+ classes = @bot_guard_classes.except(*keys.map { |key| [:constant, key] }).freeze
812
+ rebuild(constant_narrowings: @constant_narrowings.except(*keys).freeze, guard_records: records.freeze,
813
+ bot_guard_classes: classes)
745
814
  end
746
815
 
747
816
  # Issue #1429 — the constant reference `key` with no narrowing.
@@ -749,11 +818,12 @@ module Rigor
749
818
  return self unless @constant_narrowings.key?(key)
750
819
 
751
820
  rebuild(constant_narrowings: @constant_narrowings.except(key).freeze,
752
- guard_records: @guard_records.except([:constant, key]).freeze)
821
+ guard_records: @guard_records.except([:constant, key]).freeze,
822
+ bot_guard_classes: drop_bot_guard_class(:constant, key))
753
823
  end
754
824
 
755
- # True when a guard's narrowing of a global or constant is live, the state {#forget_guard_narrowings} drops and
756
- # so the gate on every scan that decides whether to.
825
+ # True when a guard's narrowing of a global, constant or instance variable is live, the state
826
+ # {#forget_guard_narrowings} drops and so the gate on every scan that decides whether to.
757
827
  def guard_narrowed?
758
828
  !@guard_records.empty?
759
829
  end
@@ -762,23 +832,19 @@ module Rigor
762
832
  # the union of its narrowed type and the binding the guard narrowed, and no record is left. The union, not the
763
833
  # pre-guard binding alone, because the code may leave the value as it was: `$stdout.is_a?(StringIO)`, then a
764
834
  # helper, reads `IO | StringIO`, which keeps `$stdout.string` quiet as the guard intended.
835
+ #
836
+ # Issue #1446 — an instance variable a class guard narrowed is restored the same way.
765
837
  def forget_guard_narrowings
766
838
  return self if @guard_records.empty?
767
839
 
768
- globals = @globals
769
- constants = @constant_narrowings
840
+ tables = { global: @globals, constant: @constant_narrowings, ivar: @ivars }
770
841
  @guard_records.each do |(kind, name), pre_guard|
771
- if kind == :global
772
- current = globals[name]
773
- globals = globals.merge(name => Type::Combinator.union(pre_guard, current)) if current
774
- else
775
- current = constants[name]
776
- constants = constants.merge(name => Type::Combinator.union(pre_guard, current)) if current
777
- end
842
+ table = tables.fetch(kind)
843
+ current = table[name]
844
+ tables[kind] = table.merge(name => Type::Combinator.union(pre_guard, current)) if current
778
845
  end
779
- rebuild(globals: globals.frozen? ? globals : globals.freeze,
780
- constant_narrowings: constants.frozen? ? constants : constants.freeze,
781
- guard_records: EMPTY_GUARD_RECORDS)
846
+ globals, constants, ivars = tables.values_at(:global, :constant, :ivar).map { |t| t.frozen? ? t : t.freeze }
847
+ rebuild(globals: globals, constant_narrowings: constants, ivars: ivars, guard_records: EMPTY_GUARD_RECORDS)
782
848
  end
783
849
 
784
850
  # Issue #1362 (ADR-58 parity, ADR-117 Decision point 2) — used by the method-entry and top-level seeds to bind a
@@ -998,6 +1064,18 @@ module Rigor
998
1064
  @self_type.nil?
999
1065
  end
1000
1066
 
1067
+ # ADR-119 WD1 — the RAW slot of the def tables for `class_name` and `method_name`: the instance side by default,
1068
+ # the singleton side for `kind: :singleton`. The slot is a live `Prism::DefNode` or a {Inference::DefHandle}
1069
+ # (ADR-85 WD3), returned as stored: no dependency recording and no handle resolution. ADR-119 C1d/C2 decide how
1070
+ # a contested slot answers here. Callers that need a node go through {#user_def_for} or {#singleton_def_for}.
1071
+ # The `same_slot` census reads of this slot stay justified: certainty reaches them through the declaration
1072
+ # signature the producer fingerprints (C1d-a), not through this accessor.
1073
+ def def_node_slot(class_name, method_name, kind = :instance)
1074
+ table = kind == :singleton ? discovered_singleton_def_nodes : discovered_def_nodes
1075
+ per_class = table[class_name]
1076
+ per_class && per_class[method_name]
1077
+ end
1078
+
1001
1079
  # v0.0.2 #5 — per-class table mapping `method_name (Symbol) → Prism::DefNode`. Populated by `ScopeIndexer`
1002
1080
  # alongside `discovered_methods` for instance-side defs only (singleton-side and `define_method`-introduced
1003
1081
  # methods do not contribute a static body the engine can re-type). Consumed by `ExpressionTyper` to do
@@ -1085,12 +1163,15 @@ module Rigor
1085
1163
  same_file_top_level_def?(method_name) ? node : nil
1086
1164
  end
1087
1165
 
1166
+ # ADR-119 WD1 errata, deliberately unchanged: this compares the recorded site's FILE with the call's file, and
1167
+ # which file a def sits in does not depend on whether it executes. The `<toplevel>` slots are contested
1168
+ # truthfully (ADR-119 C1d-a) but unread until C2 makes the top-level reader decline on them.
1088
1169
  def same_file_top_level_def?(method_name)
1089
1170
  key = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
1090
1171
  site = discovered_def_sources.dig(key, method_name.to_sym)
1091
1172
  return true if site.nil? || @source_path.nil?
1092
1173
 
1093
- File.expand_path(site.sub(/:\d+\z/, "")) == File.expand_path(@source_path)
1174
+ File.absolute_path(site.sub(/:\d+\z/, "")) == File.absolute_path(@source_path)
1094
1175
  end
1095
1176
  private :same_file_top_level_def?
1096
1177
 
@@ -1272,32 +1353,6 @@ module Rigor
1272
1353
  @discovery.discovered_includes[class_name.to_s] || []
1273
1354
  end
1274
1355
 
1275
- # Issue #1123 — the modules `class_name` `prepend`s, in instance-ancestor SEARCH order: the nearest
1276
- # prepend first, so `prepend A; prepend B` answers `["B", "A"]` and `prepend A, B` keeps `["A", "B"]`
1277
- # (each Ruby statement's argument list lands as one unit, ahead of the earlier statements'). Read by
1278
- # {#user_def_through_ancestors}'s prepend wedge — Ruby inserts a prepended module, and its own ancestry,
1279
- # IMMEDIATELY BEFORE the class that prepends it, so these names are searched ahead of the class's own
1280
- # `def`s. Empty for a class the project never prepends, which is the behaviour every class had before
1281
- # `prepend` was ordered at all.
1282
- #
1283
- # The same names ALSO appear in {#includes_of} — that table answers "which modules does this class
1284
- # carry", which is what the arity, visibility, reflection and constant-scope consumers ask, and a
1285
- # prepended module does contribute instance methods. This table adds the ORDER and the KIND; it never
1286
- # removes a name from the other one.
1287
- #
1288
- # The class dependency is recorded only on a HIT, as {#data_member_layout} does: the wedge asks this
1289
- # for every class the walk visits, so recording on a miss would give a caller file an ancestry edge on
1290
- # a class that prepends nothing — an edge where ADR-46 slice 4 keeps method-body reads at
1291
- # symbol granularity.
1292
- def prepends_of(class_name)
1293
- names = @discovery.discovered_prepends[class_name.to_s]
1294
- return EMPTY_MIXIN_NAMES if names.nil?
1295
-
1296
- record_class_dependency(class_name) if Analysis::DependencyRecorder.active?
1297
- names
1298
- end
1299
- private :prepends_of
1300
-
1301
1356
  # Issue #898 — the module names `extend`ed onto `class_name`'s SINGLETON, as written, gathered up the
1302
1357
  # as-written superclass chain because a singleton class inherits its superclass's singleton class
1303
1358
  # (`class Base; extend Comparable; end; class Widget < Base; end` leaves `Widget.is_a?(Comparable)`
@@ -1326,224 +1381,145 @@ module Rigor
1326
1381
  names.uniq
1327
1382
  end
1328
1383
 
1329
- # ADR-24 slice 2 — the user-side ancestor walk: resolves `method_name` against `class_name`'s own `def`s,
1330
- # then breadth-first through its included / prepended modules (mixins first, as Ruby orders them) and its
1331
- # superclass chain. Returns `[def_node, owner]`, or `[nil, nil]` when nothing in the project defines it.
1332
- # RBS-known / third-party ancestors resolve to no discovered class and end that branch.
1384
+ # ADR-24, amended for #1567 / #1568 / #1570 / #1571 — every reader below answers in Ruby's linearised
1385
+ # ancestor order, read from {ResolutionChain} (an internal class: `Scope`'s own surface is the plugin API
1386
+ # `spec/rigor/public_api_drift_spec.rb` pins, and the chain is not part of it). None walks `includes_of` /
1387
+ # `superclass_of` / the extends table itself; `spec/rigor/scope/ancestry_walker_detection_spec.rb` fails on
1388
+ # a method that does. Each keeps its signature and its return type exactly: only WHICH definer it answers
1389
+ # moved. `name_memo:` is still accepted and no longer needed — the chain memoises name resolution itself.
1390
+ UNUSED_NAME_MEMO = {}.freeze
1391
+ private_constant :UNUSED_NAME_MEMO
1333
1392
  #
1334
- # It lives HERE, not in a consumer, because it reads nothing but this scope's frozen discovery index
1335
- # (`user_def_for` / `includes_of` / `superclass_of` / the `discovered_*` tables) — the same property that
1336
- # lets `ExpressionTyper` memoise its results run-wide. Two callers depend on that single answer:
1337
- # `ExpressionTyper#compute_user_def_with_owner`, which wraps it in the run-scoped memo for the dispatch hot
1338
- # path, and {Inference::MethodDispatcher::StructMaterialization}, whose `.with` guard must see a `with`
1339
- # contributed by an included module. They forked once — the guard resolved own-class-only while the
1340
- # dispatcher walked ancestors — and answered differently about the same receiver expression (#598 review).
1393
+ # Every reader below asks {ResolutionChain#settle} whether its chain answer stands, and where it does not
1394
+ # answers what the walk it replaced answered ({ResolutionChain::MasterOrder}) — the tables cannot say which
1395
+ # world ran, and a disagreement is no reason to answer anything new.
1396
+
1397
+ # ADR-24 slice 2 — the user-side method lookup: the first project `def` of `method_name` along
1398
+ # `class_name`'s instance chain, as `[def_node, owner]`, or `[nil, nil]` when nothing the project declares
1399
+ # defines it. An external ancestor is passed over, as before: a caller asking "does the project define
1400
+ # this" must not read an RBS module's declaration as absence (#1572 is the typing read that will stop
1401
+ # there).
1341
1402
  #
1342
- # `name_memo` is a CACHE, not semantics: the per-edge as-written-name resolutions, so a caller resolving
1343
- # many methods against one class pays each ancestor edge once. Omit it and the walk is identical, just
1344
- # uncached.
1403
+ # It lives HERE, not in a consumer, because it reads nothing but this scope's frozen discovery index — the
1404
+ # property that lets `ExpressionTyper` memoise its results run-wide. `ExpressionTyper#compute_user_def_with_owner`
1405
+ # wraps it in that memo for the dispatch hot path, and {Inference::MethodDispatcher::StructMaterialization}'s
1406
+ # `.with` guard reads the same answer (#598 review).
1345
1407
  #
1346
- # Issue #1123 — a prepended module is searched through {#prepends_of} AHEAD of the class's own `def`s
1347
- # ({#prepend_wedge_names}), because Ruby inserts it, and its own ancestry, immediately before the class
1348
- # that prepends it: `class Base; def speak = "base"; end; Base.prepend(Loud)` answers `Loud#speak`, and
1349
- # the `super` in that body then reaches `Base#speak`. An included module still
1350
- # sits AFTER the class in this walk, but — since #1173 — `includes_of` feeds the mixin step in
1351
- # instance-ancestor search order, so `include A; include B` searches B first the way CRuby does. The
1352
- # wedge is searched at EVERY node, not just the entry class: a prepend on an ancestor (`class Sub <
1353
- # Base` where `Base` prepends a module) wins for the subclass too, exactly as Ruby dispatches it.
1354
- def user_def_through_ancestors(class_name, method_name, name_memo: {})
1355
- queue = [class_name.to_s]
1356
- seen = {}
1357
- visited = 0
1358
- until queue.empty?
1359
- current = queue.shift
1360
- next if current.nil? || seen[current]
1361
-
1362
- seen[current] = true
1363
- visited += 1
1364
- return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
1408
+ # #1567 — the walk was breadth-first until the chain replaced it: `class C < Base; include A` with `A`
1409
+ # including `M` answered `Base#foo` where Ruby calls `M#foo`. The prepend wedge #1123 added (a prepended
1410
+ # module searched ahead of the class's own `def`s) and the include search order #1173 fixed are both
1411
+ # positions on the chain now, at every class on it.
1412
+ def user_def_through_ancestors(class_name, method_name, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
1413
+ chain = ResolutionChain.for(self, class_name.to_s, :instance, :methods)
1414
+ found = first_user_def(chain, method_name)
1415
+ owner = found&.last
1416
+ if chain.settle(self, owner, owner: owner) { |retro| first_user_def(retro, method_name)&.last } == :master
1417
+ found = master_user_def(class_name.to_s, method_name)
1418
+ end
1419
+ return found if found
1365
1420
 
1366
- wedge_hit = search_prepend_wedge(current, method_name, name_memo, seen)
1367
- return wedge_hit if wedge_hit
1421
+ chain.truncated? ? ancestor_walk_gave_up : [nil, nil]
1422
+ end
1368
1423
 
1369
- found = user_def_for(current, method_name)
1370
- return [found, current] if found
1424
+ def first_user_def(chain, method_name)
1425
+ chain.search(self) do |entry|
1426
+ next if entry.external?
1371
1427
 
1372
- enqueue_ancestors(current, queue, name_memo)
1428
+ node = user_def_for(entry.name, method_name)
1429
+ [node, entry.name] if node
1373
1430
  end
1374
- [nil, nil]
1375
1431
  end
1376
1432
 
1377
- # Issue #1123 — the `[def_node, owner]` pair a class's prepend wedge answers `method_name` with, or nil
1378
- # when nothing in the wedge defines it and the walk should ask the class's own `def`s next.
1379
- #
1380
- # Every name the wedge takes is marked on `seen`, the SAME set the walk's breadth-first step uses: a
1381
- # prepended module also sits in `includes_of`, so the mixin step would otherwise re-ask a name the wedge
1382
- # already answered — and, more importantly, marking it there keeps a `prepend` graph that loops back on
1383
- # the walk (a prepended module reached again from somewhere else) from being expanded twice.
1384
- def search_prepend_wedge(class_name, method_name, name_memo, seen)
1385
- prepend_wedge_names(class_name, name_memo).each do |name|
1386
- next if seen[name]
1387
-
1388
- seen[name] = true
1389
- found = user_def_for(name, method_name)
1390
- return [found, name] if found
1433
+ def master_user_def(class_name, method_name)
1434
+ names = ResolutionChain::MasterOrder.definer_sequence(self, class_name)
1435
+ names.each { |name| record_class_dependency(name) } if Analysis::DependencyRecorder.active?
1436
+ names.each do |name|
1437
+ node = user_def_for(name, method_name)
1438
+ return [node, name] if node
1391
1439
  end
1392
1440
  nil
1393
1441
  end
1394
-
1395
- # Issue #1123 — the qualified names the instance MRO searches ahead of `class_name`'s own definitions,
1396
- # in the order it searches them: for each of `class_name`'s prepends, nearest first, that module's own
1397
- # prepend wedge, then the module itself, then its includes and superclass.
1398
- #
1399
- # A prepended module's own ancestry is part of the wedge because Ruby inserts the whole sub-chain with
1400
- # it — `module Loud; include Loud::Prefix; end` prepended into `Base` puts `Loud::Prefix` ahead of
1401
- # `Base`, so the wedge has to reach it. `seen` is shared across one wedge computation and guards a cyclic
1402
- # `prepend`/`include` graph (and the overlap with `includes_of`, which carries the prepend names too);
1403
- # the walk's own set is applied by {#search_prepend_wedge}, and each name is expanded at most once.
1404
- def prepend_wedge_names(class_name, name_memo, seen = {})
1405
- out = []
1406
- prepends_of(class_name).each do |raw|
1407
- resolved = resolve_ancestor_class_name(class_name, raw, name_memo)
1408
- next if resolved.nil? || seen[resolved]
1409
-
1410
- seen[resolved] = true
1411
- out.concat(subchain_names(resolved, name_memo, seen))
1412
- end
1413
- out
1414
- end
1415
-
1416
- # Issue #1123 — appends `node`'s own instance search order to `out`: its prepend wedge, the node
1417
- # itself, then its includes and superclass, each expanded the same way. Since #1173 the include
1418
- # half is `includes_of`'s search order — the ORDER inside a prepended module's own include list is
1419
- # not re-derived here, only read.
1420
- def subchain_names(node, name_memo, seen)
1421
- out = prepend_wedge_names(node, name_memo, seen)
1422
- out << node
1423
- (includes_of(node) + [superclass_of(node)].compact).each do |raw|
1424
- resolved = resolve_ancestor_class_name(node, raw, name_memo)
1425
- next if resolved.nil? || seen[resolved]
1426
-
1427
- seen[resolved] = true
1428
- out.concat(subchain_names(resolved, name_memo, seen))
1442
+ private :first_user_def, :master_user_def
1443
+
1444
+ # Issue #731 — the singleton-side twin of {#user_def_through_ancestors}: the first `def self.` /
1445
+ # `class << self` body of `method_name` along `class_name`'s singleton chain, as `[def_node, owner]`, or
1446
+ # `[nil, nil]` — each class object's own table, then the modules it extends in Ruby's order, then the
1447
+ # superclass's. `ScopeIndexer` folds an extended module's own `def`s into the extender's table, so those
1448
+ # answer at the extender; a module the extended one includes is its own entry (#1567's singleton shape:
1449
+ # `class C < Base; extend A` with `A` including `M` is `M#foo`, not `Base.foo`). Where the chain does
1450
+ # not stand, the answer is the class objects' alone, as the superclass-only walk this replaced gave.
1451
+ def singleton_def_through_ancestors(class_name, method_name, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
1452
+ chain = ResolutionChain.for(self, class_name.to_s, :singleton, :methods)
1453
+ found = first_singleton_def(chain, method_name)
1454
+ owner = found&.last
1455
+ if chain.settle(self, owner, owner: owner) { |retro| first_singleton_def(retro, method_name)&.last } == :master
1456
+ found = first_singleton_def(chain, method_name, :singleton)
1429
1457
  end
1430
- out
1431
- end
1432
- private :search_prepend_wedge, :prepend_wedge_names, :subchain_names
1458
+ return found if found
1433
1459
 
1434
- # Issue #731 — the singleton-side twin of {#user_def_through_ancestors}: resolves `method_name` against
1435
- # `class_name`'s own `def self.` / `class << self` bodies, then up the SUPERCLASS chain. Returns
1436
- # `[def_node, owner]`, or `[nil, nil]`.
1437
- #
1438
- # Superclasses only, and that is the whole shape of class-method inheritance in Ruby: a subclass
1439
- # inherits its parent's class methods, while an `include`d module contributes INSTANCE methods and its
1440
- # own `def self.x` is not callable on the includer. `extend M` needs no walk either — `ScopeIndexer`
1441
- # folds an extend into the extender's own singleton entries before the table is frozen — so the one
1442
- # edge this misses is a module's singleton surface reached through some other route, which nothing
1443
- # resolves today.
1444
- def singleton_def_through_ancestors(class_name, method_name, name_memo: {})
1445
- queue = [class_name.to_s]
1446
- seen = {}
1447
- visited = 0
1448
- until queue.empty?
1449
- current = queue.shift
1450
- next if current.nil? || seen[current]
1451
-
1452
- seen[current] = true
1453
- visited += 1
1454
- return ancestor_walk_gave_up if visited > ANCESTOR_WALK_LIMIT
1460
+ chain.truncated? ? ancestor_walk_gave_up : [nil, nil]
1461
+ end
1455
1462
 
1456
- found = singleton_def_for(current, method_name)
1457
- return [found, current] if found
1463
+ def first_singleton_def(chain, method_name, side = nil)
1464
+ chain.search(self, side: side) do |entry|
1465
+ next if entry.external?
1458
1466
 
1459
- enqueue_ancestors(current, queue, name_memo, mixins: false)
1467
+ name = entry.name
1468
+ node = entry.side == :singleton ? singleton_def_for(name, method_name) : user_def_for(name, method_name)
1469
+ [node, entry.name] if node
1460
1470
  end
1461
- [nil, nil]
1462
1471
  end
1472
+ private :first_singleton_def
1463
1473
 
1464
1474
  # Issue #633 — the ancestors a project class reaches that the project itself does NOT declare: the
1465
- # `< StandardError` / `< Array` superclasses and the `include Comparable` mixins the two walks above
1466
- # deliberately drop, gathered depth-first over the project ancestry (an included module's own
1467
- # sub-chain precedes the includer's superclass edge) so an inherited edge counts too — and, since
1468
- # #1173, so the groups arrive in instance-ancestor search order for the caller that adopts the
1469
- # FIRST answering one.
1475
+ # `< StandardError` / `< Array` superclasses and the `include Comparable` mixins, as the CANDIDATE LIST for
1476
+ # each as-written name ({#ancestor_name_candidates}: the nesting spellings first, the bare name last), in
1477
+ # Ruby's order along the instance chain — the order the caller that adopts the FIRST answering one (#1173)
1478
+ # depends on. Resolving a candidate means asking the RBS environment, which this frozen-index read does
1479
+ # not do; the caller takes the first candidate its own oracle knows. Where the chain does not stand, the
1480
+ # groups are the depth-first ones the walk this replaced emitted.
1470
1481
  #
1471
- # Each entry is the CANDIDATE LIST for one as-written name ({#ancestor_name_candidates}: the nesting
1472
- # spellings first, the bare name last), not a resolved class — resolving it means asking the RBS
1473
- # environment, which this frozen-index walk does not read. The caller takes the first candidate its own
1474
- # oracle knows and ignores the rest, the same "most-qualified first" order the project-side resolution
1475
- # uses.
1476
- # Issue #527 slice 1 — `mixins: false` follows the SUPERCLASS chain only. Ruby reaches an included
1477
- # module's methods too, so the default answers the whole ancestry; a consumer resolving one KIND of
1478
- # inheritance edge at a time (the dispatch arm lands `< Hash` before `include Enumerable`) narrows
1479
- # it, the same way {#singleton_def_through_ancestors} narrows {#enqueue_ancestors}.
1480
- def external_ancestor_name_candidates(class_name, name_memo: {}, mixins: true)
1481
- groups = []
1482
- collect_external_ancestors(class_name.to_s, groups, name_memo, mixins, {}, [0])
1482
+ # Issue #527 slice 1 — `mixins: false` answers the superclass edge only: a consumer resolving one KIND of
1483
+ # inheritance edge at a time (the dispatch arm lands `< Hash` before `include Enumerable`) narrows it, and
1484
+ # records only the superclass chain it read.
1485
+ def external_ancestor_name_candidates(class_name, name_memo: UNUSED_NAME_MEMO, mixins: true) # rubocop:disable Lint/UnusedMethodArgument
1486
+ chain = ResolutionChain.for(self, class_name.to_s, :instance, :methods)
1487
+ groups = external_groups(chain, mixins)
1488
+ if chain.settle(self, groups) { |retro| external_groups(retro, mixins) } == :master
1489
+ groups = ResolutionChain::MasterOrder.external_groups(self, class_name.to_s, mixins)
1490
+ end
1491
+ if Analysis::DependencyRecorder.active?
1492
+ mixins ? chain.record(self) : chain.level_classes.each { |name| record_class_dependency(name) if name }
1493
+ end
1494
+ # Issue #527 — the cut is a budget event, not an answer: consumers read the groups as "the ancestors
1495
+ # this class reaches", so a short list must be visible in `--stats` beside the other walks' exhaustions.
1496
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT) if chain.truncated?
1483
1497
  groups
1484
1498
  end
1485
1499
 
1486
- # One node of {#external_ancestor_name_candidates}: the project-declared ancestors continue the walk,
1487
- # everything else is reported as a candidate list.
1488
- #
1489
- # Issue #1173 — the walk is depth-first pre-order, not breadth-first: a resolved ancestor's own
1490
- # edges are followed BEFORE the next sibling edge, because Ruby inserts an included module's whole
1491
- # sub-chain ahead of the includer's superclass (`class C < Hash; include M` where `M` itself
1492
- # includes `Widen` chains `C → M → Widen → Hash`; BFS would report `Hash` ahead of `Widen`). Each
1493
- # node's edges still go includes-then-superclass, and `includes_of` is already search order, so the
1494
- # emitted groups arrive in MRO order — which the #1173 include arm depends on when two ancestors
1495
- # both declare the name.
1496
- def collect_external_ancestors(current, groups, name_memo, mixins, seen, visited)
1497
- return if current.nil? || seen[current]
1498
-
1499
- seen[current] = true
1500
- visited[0] += 1
1501
- if visited[0] > ANCESTOR_WALK_LIMIT
1502
- # Issue #527 — the give-up is a budget event, not an answer. Consumers read the groups as
1503
- # "the ancestors this class reaches", so a truncated list must be visible in `--stats`
1504
- # alongside the other walks' exhaustions rather than silently short. Hit once: every node
1505
- # visited past the limit returns here too, and only the first should count.
1506
- Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT) if visited[0] == ANCESTOR_WALK_LIMIT + 1
1507
- return
1508
- end
1509
-
1510
- raw_names = mixins ? includes_of(current).dup : []
1511
- raw_super = superclass_of(current)
1512
- raw_names << raw_super if raw_super
1513
- raw_names.each do |raw|
1514
- resolved = resolve_ancestor_class_name(current, raw, name_memo)
1515
- if resolved
1516
- collect_external_ancestors(resolved, groups, name_memo, mixins, seen, visited)
1517
- else
1518
- groups << ancestor_name_candidates(current, raw)
1519
- end
1520
- end
1500
+ def external_groups(chain, mixins)
1501
+ chain.entries.filter_map { |entry| entry.candidates if entry.external? && (mixins || entry.superclass_edge) }
1521
1502
  end
1522
- private :collect_external_ancestors
1503
+ private :external_groups
1523
1504
 
1524
- # The BFS node cap; a hierarchy past it gives up rather than walking unboundedly (ADR-41 WD4).
1525
- ANCESTOR_WALK_LIMIT = 100
1505
+ # The budget {ResolutionChain::LIMIT} enforces; a hierarchy past it gives up rather than reading an answer
1506
+ # off a cut chain (ADR-41 WD4).
1507
+ ANCESTOR_WALK_LIMIT = ResolutionChain::LIMIT
1526
1508
 
1527
1509
  EMPTY_HEADER_NESTING = [].freeze
1528
1510
  private_constant :EMPTY_HEADER_NESTING
1529
1511
 
1530
- # The answer for a class that prepends nothing — the common case, so the wedge does not allocate a
1531
- # fresh empty list per visited node. Shared and frozen, and never handed to a caller that could
1532
- # mutate it ({#prepends_of} is private to this walk).
1533
- EMPTY_MIXIN_NAMES = [].freeze
1534
- private_constant :EMPTY_MIXIN_NAMES
1535
-
1536
1512
  def ancestor_walk_gave_up
1537
1513
  Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
1538
1514
  [nil, nil]
1539
1515
  end
1516
+ private :ancestor_walk_gave_up
1540
1517
 
1541
- # Issue #723 — the same ancestor walk as {#user_def_through_ancestors}, asked of the DISCOVERY table
1542
- # rather than the def-node table: does the project define `method_name` on `class_name` or on any
1543
- # ancestor the project itself declares? The two tables are not interchangeable —
1544
- # `discovered_methods` also carries `define_method`, `attr_*` and the whole singleton side, none of
1545
- # which contributes a `Prism::DefNode` — and the suppression probe shared by the `call.*` check rules
1546
- # needs the broader one.
1518
+ # Issue #723 — the question {#user_def_through_ancestors} asks, asked of the DISCOVERY table rather than
1519
+ # the def-node table: does the project define `method_name` on `class_name` or on any ancestor the project
1520
+ # itself declares? The two tables are not interchangeable — `discovered_methods` also carries
1521
+ # `define_method`, `attr_*` and the whole singleton side, none of which contributes a `Prism::DefNode` —
1522
+ # and the suppression probe shared by the `call.*` check rules needs the broader one.
1547
1523
  #
1548
1524
  # Why it must walk at all: `Analysis::CheckRules#source_declared_method?` asked `discovered_method?`,
1549
1525
  # which is keyed on the receiver's OWN name, while the typer resolves the same call through this
@@ -1551,72 +1527,47 @@ module Rigor
1551
1527
  # `call.undefined-method` on a method `dump_type` resolved on the same line of the same run — writing
1552
1528
  # MORE RBS made the run worse, the incentive #653 removed for plugin-typed calls.
1553
1529
  #
1554
- # `kind: :singleton` follows the superclass chain ONLY: a class method is inherited by a subclass, but
1555
- # an `include`d module contributes instance methods and its own `def self.x` is not callable on the
1556
- # includer. `extend M` is not lost to that narrowing — `Inference::ScopeIndexer` folds an extend into
1557
- # the extender's own singleton entries before this table is frozen.
1558
- def discovered_method_through_ancestors?(class_name, method_name, kind, name_memo: {})
1530
+ # `kind: :singleton` reads the singleton chain: the class objects' own methods (an extended module's are
1531
+ # folded into its extender's) and the instance methods of the modules they extend and those modules'
1532
+ # includes. Existence is a union, so the chain's two worlds, which hold the same modules, agree on it.
1533
+ def discovered_method_through_ancestors?(class_name, method_name, kind, name_memo: UNUSED_NAME_MEMO) # rubocop:disable Lint/UnusedMethodArgument
1559
1534
  return false if class_name.nil?
1560
1535
 
1561
- queue = [class_name.to_s]
1562
- seen = {}
1563
- visited = 0
1564
- until queue.empty?
1565
- current = queue.shift
1566
- next if current.nil? || seen[current]
1536
+ chain = ResolutionChain.for(self, class_name.to_s, kind == :singleton ? :singleton : :instance, :methods)
1537
+ found = chain.search(self) do |entry|
1538
+ next false if entry.external?
1567
1539
 
1568
- seen[current] = true
1569
- visited += 1
1570
- # Budget exhaustion is uncertainty, not absence: answering "not declared" here would hand a
1571
- # `call.undefined-method` a fired verdict it has no evidence for. Suppress and record the hit.
1572
- if visited > ANCESTOR_WALK_LIMIT
1573
- Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
1574
- return true
1575
- end
1576
-
1577
- return true if discovered_method?(current, method_name, kind)
1578
-
1579
- enqueue_ancestors(current, queue, name_memo, mixins: kind != :singleton)
1580
- end
1581
- false
1582
- end
1583
-
1584
- # Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby places
1585
- # mixins nearer than the superclass), then the superclass. Each as-written name is resolved against the
1586
- # nesting `current`'s declaration header is written in ({#ancestor_name_candidates}); names that resolve
1587
- # to no project class/module are dropped. `mixins: false` walks the superclass chain alone, for the
1588
- # singleton-side question where an `include` contributes nothing.
1589
- def enqueue_ancestors(current, queue, name_memo, mixins: true)
1590
- if mixins
1591
- includes_of(current).each do |raw|
1592
- resolved = resolve_ancestor_class_name(current, raw, name_memo)
1593
- queue.push(resolved) if resolved
1594
- end
1540
+ discovered_method?(entry.name, method_name, entry.side == :singleton ? :singleton : kind_for(kind))
1595
1541
  end
1596
- raw_super = superclass_of(current)
1597
- return if raw_super.nil?
1542
+ return true if found
1543
+ # Budget exhaustion is uncertainty, not absence: answering "not declared" here would hand a
1544
+ # `call.undefined-method` a fired verdict it has no evidence for. Suppress and record the hit.
1545
+ return false unless chain.truncated?
1598
1546
 
1599
- resolved_super = resolve_ancestor_class_name(current, raw_super, name_memo)
1600
- queue.push(resolved_super) if resolved_super
1547
+ Inference::BudgetTrace.hit(Inference::BudgetTrace::ANCESTOR_WALK_LIMIT)
1548
+ true
1601
1549
  end
1602
1550
 
1603
- # Resolves an ancestor name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered class,
1604
- # following Ruby's `Module.nesting` constant lookup from the subclass's declaration HEADER, innermost
1605
- # first, then bare. nil when no candidate names a discovered user class.
1606
- def resolve_ancestor_class_name(subclass_qualified, raw_ancestor, name_memo)
1607
- by_subclass = (name_memo[subclass_qualified] ||= {})
1608
- return by_subclass[raw_ancestor] if by_subclass.key?(raw_ancestor)
1551
+ # A module entry on the singleton chain answers with its INSTANCE methods; on the instance chain, the
1552
+ # caller's kind stands.
1553
+ def kind_for(kind) = kind == :singleton ? :instance : kind
1554
+ private :kind_for
1609
1555
 
1610
- by_subclass[raw_ancestor] = compute_ancestor_class_name(subclass_qualified, raw_ancestor)
1556
+ # Pushes `current`'s direct ancestors onto a breadth-first queue: included / prepended modules first, then
1557
+ # the superclass, each resolved against the nesting `current`'s declaration header is written in; names
1558
+ # that resolve to no project class/module are dropped. `mixins: false` gives the superclass alone. No
1559
+ # reader in the engine walks this way any more (they read {ResolutionChain}); it stays for the plugin
1560
+ # surface, with the ADR-46 class edge the walk it served always filed.
1561
+ def enqueue_ancestors(current, queue, name_memo, mixins: true) # rubocop:disable Lint/UnusedMethodArgument
1562
+ record_class_dependency(current) if Analysis::DependencyRecorder.active?
1563
+ queue.concat(ResolutionChain.direct_ancestors(self, current.to_s, mixins))
1611
1564
  end
1612
- private :ancestor_walk_gave_up, :resolve_ancestor_class_name
1613
1565
 
1614
1566
  # Issue #682 — the candidate names an ancestor spelled `raw_ancestor` can denote in `subclass_qualified`,
1615
1567
  # in Ruby's lookup order, most-qualified first and the bare name last. The single owner of the question:
1616
- # three walks resolved a superclass / include name apiece ({#enqueue_ancestors} for method lookup,
1617
- # `Reflection.resolve_ancestor_name` for the constant ladder's ancestor rung, and
1618
- # `Analysis::CheckRules`' override-visibility rule), and each derived the order by PEELING the
1619
- # subclass's qualified name one `::` segment at a time.
1568
+ # three walks resolved a superclass / include name apiece (the method walk, the constant ladder's
1569
+ # ancestor rung, and `Analysis::CheckRules`' override-visibility rule — all three now {ResolutionChain}),
1570
+ # and each derived the order by PEELING the subclass's qualified name one `::` segment at a time.
1620
1571
  #
1621
1572
  # That peel is the NESTED spelling's answer, given to both spellings. Ruby evaluates a superclass
1622
1573
  # expression before entering the body, so the cref that governs it is the one the declaration's HEADER
@@ -1628,9 +1579,9 @@ module Rigor
1628
1579
  # The header nesting is a property of the DECLARATION, not of the reader, so it is read from the
1629
1580
  # discovery table `Inference::ScopeIndexer` records it in rather than from this scope's own
1630
1581
  # `#lexical_nesting`. Answering off the reader's chain would be wrong past the first hop of the ancestor
1631
- # BFS (the chain describes the reader's class, not the ancestor being resolved) and would silently
1632
- # corrupt the run-scoped ancestor memos in `Inference::ExpressionTyper#class_graph_buckets` and
1633
- # `Reflection.ancestor_constant_scopes`, both of which key on the class name alone because this walk is
1582
+ # chain (the chain describes the reader's class, not the ancestor being resolved) and would silently
1583
+ # corrupt the {ResolutionChain} memo and the ones layered on it (`Inference::ExpressionTyper#class_graph_buckets`,
1584
+ # `Reflection.ancestor_constant_scopes`), all of which key on the class name alone because this walk is
1634
1585
  # a pure function of the frozen discovery tables.
1635
1586
  #
1636
1587
  # The peel survives as the FALLBACK, for a class no declaration walk recorded — a scope seeded without
@@ -1737,10 +1688,6 @@ module Rigor
1737
1688
  end
1738
1689
  private :peeled_header_nesting
1739
1690
 
1740
- def compute_ancestor_class_name(subclass_qualified, raw_ancestor)
1741
- ancestor_name_candidates(subclass_qualified, raw_ancestor).find { |c| known_user_class?(c) }
1742
- end
1743
-
1744
1691
  # Issue #723 — `discovered_methods` is in the list because the other three miss a class whose only
1745
1692
  # project-side content is CLASS methods: `class Base; def self.build = :built; end` records no instance
1746
1693
  # def node, no superclass and no include, so the ancestor-name resolver did not recognise `Base` as a
@@ -1755,13 +1702,13 @@ module Rigor
1755
1702
  # narrower predicate (`discovered_classes` alone) would read a project class as having left the project
1756
1703
  # and attribute its root name to a same-named locked gem — a WRONG provenance label, which is the one
1757
1704
  # direction ADR-82 forbids.
1758
- private :ancestor_walk_gave_up, :compute_ancestor_class_name
1759
1705
 
1760
1706
  # Records, for a resolved cross-class ancestry read, every file that declares `class_name` (its declaration /
1761
- # reopening / superclass / include sites). The `discovered_class_sources` table it reads is populated only by
1762
- # the cross-file project pre-pass ({Inference::ScopeIndexer.discovered_def_index_for_paths}) and only when
1763
- # dependency recording is active. No-op when the class is not a project class (core / stdlib / gem names
1764
- # never appear in the source map). Gated by the caller on the recorder being active.
1707
+ # reopening / superclass / include sites). The `discovered_class_sources` table it reads is populated by the
1708
+ # cross-file project pre-pass ({Inference::ScopeIndexer.discovered_def_index_for_paths}); a scope built
1709
+ # without one holds none, and the read then files nothing. No-op when the class is not a project class
1710
+ # (core / stdlib / gem names never appear in the source map). Gated by the caller on the recorder being
1711
+ # active.
1765
1712
  def record_class_dependency(class_name)
1766
1713
  sites = @discovery.discovered_class_sources[class_name.to_s]
1767
1714
  return if sites.nil?
@@ -1781,8 +1728,7 @@ module Rigor
1781
1728
  # so a bare referent is re-checked when the class appears or disappears and at no other time. A file edge
1782
1729
  # would have re-checked every bare referent of every class the file declares on any edit to it.
1783
1730
  def record_class_existence(class_name)
1784
- segment = class_name.to_s.delete_prefix("::").split("::").last
1785
- Analysis::DependencyRecorder.read_name(:class, segment) if segment
1731
+ Analysis::DependencyRecorder.read_last_segment(:class, class_name.to_s)
1786
1732
  end
1787
1733
 
1788
1734
  # Issue #644 — the positive ADR-46 edge for a cross-file VALUE constant. `Reflection.constant_type_at`
@@ -1938,17 +1884,17 @@ module Rigor
1938
1884
 
1939
1885
  # Issue #1429 — a join keeps a guard record while the joined scope still narrows its name: a global both arms
1940
1886
  # bind, a constant both arms narrow (one an arm does not narrow reads its resolved type after the join, so its
1941
- # narrowing is gone). An arm without a record contributes nothing to the restore target, whose union with the
1942
- # joined binding {#forget_guard_narrowings} takes, so it still covers that arm's binding.
1943
- def join_guard_records(other, joined_globals, joined_constants)
1887
+ # narrowing is gone), and an instance variable both arms bind (#1446). An arm without a record contributes nothing
1888
+ # to the restore target, whose union with the joined binding {#forget_guard_narrowings} takes, so it still covers
1889
+ # that arm's binding.
1890
+ # `joined` maps each record kind to the joined scope's table of that kind.
1891
+ def join_guard_records(other, joined)
1944
1892
  mine = @guard_records
1945
1893
  theirs = other.guard_records
1946
1894
  return EMPTY_GUARD_RECORDS if mine.empty? && theirs.empty?
1947
1895
 
1948
1896
  merged = mine.merge(theirs) { |_key, left, right| Type::Combinator.union(left, right) }
1949
- kept = merged.select do |(kind, name), _|
1950
- kind == :global ? joined_globals.key?(name) : joined_constants.key?(name)
1951
- end
1897
+ kept = merged.select { |(kind, name), _| joined.fetch(kind).key?(name) }
1952
1898
  kept.empty? ? EMPTY_GUARD_RECORDS : kept.freeze
1953
1899
  end
1954
1900
  private :join_guard_records
@@ -1993,7 +1939,8 @@ module Rigor
1993
1939
  end
1994
1940
 
1995
1941
  def same_guard_state?(other)
1996
- @constant_narrowings == other.constant_narrowings && @guard_records == other.guard_records
1942
+ @constant_narrowings == other.constant_narrowings && @guard_records == other.guard_records &&
1943
+ @bot_guard_classes == other.bot_guard_classes
1997
1944
  end
1998
1945
 
1999
1946
  def rebuild(
@@ -2020,7 +1967,8 @@ module Rigor
2020
1967
  repeated_or_writes: @repeated_or_writes,
2021
1968
  match_frame: @match_frame,
2022
1969
  constant_narrowings: @constant_narrowings,
2023
- guard_records: @guard_records
1970
+ guard_records: @guard_records,
1971
+ bot_guard_classes: @bot_guard_classes
2024
1972
  )
2025
1973
  self.class.new(
2026
1974
  environment: environment, locals: locals,
@@ -2047,7 +1995,8 @@ module Rigor
2047
1995
  repeated_or_writes: repeated_or_writes,
2048
1996
  match_frame: match_frame,
2049
1997
  constant_narrowings: constant_narrowings,
2050
- guard_records: guard_records
1998
+ guard_records: guard_records,
1999
+ bot_guard_classes: bot_guard_classes
2051
2000
  )
2052
2001
  end
2053
2002
 
@@ -2137,15 +2086,32 @@ module Rigor
2137
2086
  # of a merge inside one body carry the same one; `||` keeps it should either arm lack it, since dropping
2138
2087
  # it only loses the frame's resets.
2139
2088
  match_frame: @match_frame || other.match_frame,
2140
- # Issue #1429 — the guard narrowings of globals and constants and their pre-guard records.
2141
- **join_guard_narrowings(other, joined_globals)
2089
+ # Issue #1429 — the guard narrowings of globals and constants and their pre-guard records, and of instance
2090
+ # variables (#1446).
2091
+ **join_guard_narrowings(other, joined_globals, joined_ivars)
2142
2092
  )
2143
2093
  end
2144
2094
 
2145
- def join_guard_narrowings(other, joined_globals)
2095
+ def join_guard_narrowings(other, joined_globals, joined_ivars)
2146
2096
  joined_constants = join_constant_narrowings(other)
2147
- { constant_narrowings: joined_constants,
2148
- guard_records: join_guard_records(other, joined_globals, joined_constants) }
2097
+ joined = { global: joined_globals, ivar: joined_ivars, constant: joined_constants }
2098
+ { constant_narrowings: joined_constants, guard_records: join_guard_records(other, joined),
2099
+ bot_guard_classes: join_bot_guard_classes(other) }
2100
+ end
2101
+
2102
+ # Issue #1446 — an entry both arms hold, with the classes of either: the joined receiver reads `bot` only where
2103
+ # both arms do. One only an arm holds is dropped, so a call on the joined `bot` reads as on an unknown one.
2104
+ def join_bot_guard_classes(other)
2105
+ mine = @bot_guard_classes
2106
+ theirs = other.bot_guard_classes
2107
+ return mine if mine.equal?(theirs)
2108
+ return EMPTY_BOT_GUARD_CLASSES if mine.empty? || theirs.empty?
2109
+
2110
+ joined = mine.each_with_object({}) do |(key, names), acc|
2111
+ other_names = theirs[key]
2112
+ acc[key] = (names | other_names).freeze if other_names
2113
+ end
2114
+ joined.empty? ? EMPTY_BOT_GUARD_CLASSES : joined.freeze
2149
2115
  end
2150
2116
 
2151
2117
  # Issue #1429 — a constant reference both arms narrow reads the union; one only an arm narrows reads its resolved
@@ -2359,6 +2325,13 @@ module Rigor
2359
2325
  @guard_records.merge(key => pre_guard).freeze
2360
2326
  end
2361
2327
 
2328
+ def drop_bot_guard_class(kind, name)
2329
+ return @bot_guard_classes if @bot_guard_classes.empty?
2330
+
2331
+ key = [kind, kind == :constant ? name : name.to_sym]
2332
+ @bot_guard_classes.key?(key) ? @bot_guard_classes.except(key).freeze : @bot_guard_classes
2333
+ end
2334
+
2362
2335
  def drop_guard_record(kind, name)
2363
2336
  return @guard_records if @guard_records.empty?
2364
2337