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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../reflection"
4
+ require_relative "../../inference/definer_resolution"
4
5
  require_relative "../../source/parameter_envelope"
5
6
 
6
7
  module Rigor
@@ -14,9 +15,10 @@ module Rigor
14
15
  # Asked in two stages, because every condition past the first can only WITHHOLD a firing:
15
16
  #
16
17
  # 1. {#owner_envelope} walks the receiver's superclass chain to the first class whose own body, or one of
17
- # whose project mixins, records the name. Every record at that level must agree — `include` and
18
- # `prepend` share one table, so a mixin's definition may shadow the class's own — and none may be
19
- # opaque. A call that fits this envelope is silent, and nothing below is consulted.
18
+ # whose project mixins, records the name. Every record at that level must agree — deliberately wider
19
+ # than Ruby's order, so a mixin whose definition may shadow the class's own, or a load-order-dependent
20
+ # pair (#986), declines instead of answering — and none may be opaque. A call that fits this envelope
21
+ # is silent, and nothing below is consulted.
20
22
  # 2. {#authoritative?} runs only for a call that would fire. It declines when a `method_missing` /
21
23
  # `respond_to_missing?` hook or a dynamic-surface mark sits anywhere in the chain; when a mixin the
22
24
  # project does not declare is not a known RBS module that lacks the name; when a `pre_eval:` patch or
@@ -24,20 +26,45 @@ module Rigor
24
26
  # project subclass of the receiver — whose instances the receiver's type admits — records a different
25
27
  # or opaque envelope for it.
26
28
  #
29
+ # ADR-119 C1b / C2-c — on both sides stage 1 and the subclass check ask `Inference::DefinerResolution`, and a
30
+ # chain it declines (`UNKNOWN`) answers no envelope: where Ruby's order depends on a fact the tables cannot
31
+ # prove, the rule is silent instead of checking the call against master's order (#1570, #1607).
32
+ #
27
33
  # Deliberately NOT in scope: a constructor reached through `Class#new` (nothing records `initialize` as
28
34
  # `[:singleton, :new]`, so `.new` finds no owner unless the class writes `def self.new` itself); keyword
29
35
  # arity (the envelope's required-keyword flag declines, as `compute_arity_envelope` declines an RBS
30
36
  # function with required keywords); and a `class_eval` whose receiver is not a constant, which the
31
37
  # discovery walk cannot name.
32
- class SourceArity
38
+ class SourceArity # rubocop:disable Metrics/ClassLength
33
39
  MISSING_HOOKS = %i[method_missing respond_to_missing?].freeze
34
40
  private_constant :MISSING_HOOKS
35
41
 
36
42
  # One class on the walk: its own name, the project modules its mixins resolve to (transitively through
37
43
  # their own `include`s), and the candidate-name lists of the mixins that resolve to no project module.
44
+ #
45
+ # Issue #1570 — the level is a SEGMENT of the receiver's `Scope::ResolutionChain` (`:arity` flavor): the
46
+ # class with the modules Ruby inserts around it before the superclass's entries begin. Ruby skips an
47
+ # `include` of a module the superclass chain already carries, so `class C < Base; include M` where
48
+ # `Base` includes `M` leaves `M` at `Base`'s level; collecting every module a class's own `include`s
49
+ # name put it at `C`'s level instead, where its `def` answered a call `Base#foo` receives.
38
50
  Level = Data.define(:class_name, :modules, :externals)
39
51
  private_constant :Level
40
52
 
53
+ # ADR-119 C1b — the answer of a level whose records are not one non-opaque envelope. It equals itself in
54
+ # every world, so two worlds that both reach such a level agree, and no envelope equals it.
55
+ AMBIGUOUS = Object.new.freeze
56
+ private_constant :AMBIGUOUS
57
+
58
+ # What `Inference::DefinerResolution` reads off a candidate: the answer, the entry that gave it, its
59
+ # position on the chain and its side. The same four readers as `DefinerResolution::Hit`, which a call site
60
+ # may not name (the case/in contract spec allows no other reference to the module).
61
+ ArityHit = Data.define(:answer, :owner, :index, :side)
62
+ private_constant :ArityHit
63
+
64
+ # A subclass level the walk cannot read: `subclasses_agree?` reads the nil as a decline.
65
+ NO_LEVELS = [nil].freeze
66
+ private_constant :NO_LEVELS
67
+
41
68
  def initialize(scope, method_name, kind)
42
69
  @scope = scope
43
70
  @method_name = method_name.to_sym
@@ -53,6 +80,7 @@ module Rigor
53
80
  def owner_envelope(class_name)
54
81
  @owner_entries = []
55
82
  @ambiguous = false
83
+ @unknown = false
56
84
  envelope, @withheld = Analysis::DependencyRecorder.withhold { walk_to_owner(class_name) }
57
85
  envelope
58
86
  end
@@ -67,6 +95,11 @@ module Rigor
67
95
  # withhold, so none of these needs the file-level class edge the bucket reads would otherwise record.
68
96
  def settle_by_definitions
69
97
  return if @withheld.nil?
98
+ # ADR-119 C1b — a declined chain (`@unknown`) that some level records the name on is silent only until
99
+ # another file's edit lifts the decline, so its verdict depends on everything the walk read, as an opaque
100
+ # level's does. A decline over a name no project level records is not `@unknown` (see
101
+ # {#decline_unless_recorded_nowhere}): it settles by definitions, as an `ABSENT` read does.
102
+ return settle_by_walk if @unknown
70
103
 
71
104
  @owner_entries.each do |name, kind|
72
105
  if kind == :singleton
@@ -95,20 +128,88 @@ module Rigor
95
128
 
96
129
  private
97
130
 
131
+ # ADR-119 C1b / C2-c, #1570 / #1607 — the nearest level is asked of {Inference::DefinerResolution}, on the
132
+ # receiver's own side: the chain stands only where every candidate level gives one envelope in every
133
+ # world Ruby may have run (a fork, an unproven mixin order, a `possible`-only definer, and on the
134
+ # singleton side a hook that could have put a definer ahead). Where it does not, the answer is NO
135
+ # envelope and `@unknown` — the rule stays silent — instead of master's order, which is what #1570 and
136
+ # #1607 fired on. The levels the walk keeps for {#authoritative?} are the chain's.
98
137
  def walk_to_owner(class_name)
99
138
  @levels = []
100
139
  @passed = []
101
- walked_whole_chain?(class_name) do |current|
102
- level = level_for(current)
140
+ @recorded = false
141
+
142
+ case Inference::DefinerResolution.resolve(@scope, class_name, @method_name, side,
143
+ question: :arity) { |chain, from| arity_hit(chain, from) }
144
+ in Inference::DefinerResolution::Known(answer: _answer, owner: _owner) then nearest_envelope(class_name)
145
+ in Inference::DefinerResolution::ABSENT then nearest_envelope(class_name) # rubocop:disable Lint/DuplicateBranch
146
+ in Inference::DefinerResolution::UNKNOWN then decline_unless_recorded_nowhere(class_name)
147
+ end
148
+ end
149
+
150
+ # A declined read of a name no project level records (`arity_hit` found no level that does, in any world it
151
+ # was asked) answers no envelope whether or not it stands, so the walk's reads are not the verdict's
152
+ # dependency: only a `def` appearing can change it, which {#settle_by_definitions} files, as for an
153
+ # `ABSENT` read. It can leave a stale false negative in a warm run, never a firing. Without this, every
154
+ # `Widget.new` (the implicit `Class` tail may answer it) would replay the whole walk as a file-level
155
+ # class edge.
156
+ def decline_unless_recorded_nowhere(class_name)
157
+ nowhere = !@recorded && !arity_chain(class_name).truncated?
158
+ nowhere ? nearest_envelope(class_name) : decline
159
+ end
160
+
161
+ # The chain's nearest level's envelope. `Known#answer` is that envelope, but {#authoritative?} and
162
+ # {#settle_by_definitions} also read the levels, passed names and owner entries this walk fills, so the
163
+ # answer is not used on its own. Run only once the candidate-set read has answered, so the chain stands
164
+ # for the name.
165
+ def nearest_envelope(class_name) = owner_in(chain_levels(arity_chain(class_name)).first)
166
+
167
+ def decline
168
+ @levels = []
169
+ @passed = []
170
+ @owner_entries = []
171
+ @envelope = nil
172
+ @unknown = true
173
+ nil
174
+ end
175
+
176
+ # The first level at or after entry position `from` whose records name the method: its envelope (or
177
+ # {AMBIGUOUS}), the entry that recorded it and that entry's position and side on the chain. On the
178
+ # singleton side the class's own record is a `:singleton` entry and an extended module's an `:instance`
179
+ # one (`owner_entries` names the kind of each).
180
+ def arity_hit(chain, from)
181
+ start = 0
182
+ chain.levels.each_with_index do |raw, index|
183
+ level = Level.new(*raw)
184
+ entries = chain.level_entries(index)
185
+ first = start
186
+ start += entries.size
187
+ next if first < from
188
+
189
+ found = level_envelopes(level)
190
+ next if found.empty?
191
+
192
+ envelope = found.first
193
+ answer = found.all?(envelope) && !Source::ParameterEnvelope.opaque?(envelope) ? envelope : AMBIGUOUS
194
+ owner, owner_kind = owner_entries(level).first
195
+ position = first + (entries.index { |entry| entry.name == owner && entry.side == owner_kind } || 0)
196
+ @recorded = true
197
+ return ArityHit.new(answer, owner, position, owner_kind)
198
+ end
199
+ nil
200
+ end
201
+
202
+ def owner_in(levels)
203
+ levels.each do |level|
103
204
  @levels << level
104
205
  found = level_envelopes(level)
105
206
  if found.empty?
106
- @passed.concat([current] + level.modules)
207
+ @passed.concat([level.class_name] + level.modules)
107
208
  next
108
209
  end
109
210
 
110
211
  envelope = found.first
111
- @owner_entries = owner_entries(level)
212
+ @owner_entries |= owner_entries(level)
112
213
  unless found.all?(envelope) && !Source::ParameterEnvelope.opaque?(envelope)
113
214
  @ambiguous = true
114
215
  return nil
@@ -155,56 +256,30 @@ module Rigor
155
256
  @scope.discovered_classes.key?(name) || Rigor::Reflection.rbs_class_known?(name, scope: @scope)
156
257
  end
157
258
 
158
- # False when the walk stopped at the ADR-41 budget rather than at the top of the chain: budget
159
- # exhaustion is uncertainty, and every caller reads it as a reason to decline.
160
- def walked_whole_chain?(class_name)
161
- current = class_name.to_s
162
- seen = {}
163
- while current && !seen[current]
164
- return false if seen.size >= Scope::ANCESTOR_WALK_LIMIT
165
-
166
- seen[current] = true
167
- yield current
168
- current = resolve(current, @scope.superclass_of(current))
169
- end
170
- true
259
+ # Yields each level of `class_name`'s chain. False when the walk stopped at the ADR-41 budget rather than
260
+ # ending: budget exhaustion is uncertainty, and every caller reads it as a reason to decline.
261
+ def walked_whole_chain?(class_name, &)
262
+ levels, whole = chain_levels(arity_chain(class_name))
263
+ levels.each(&)
264
+ whole
171
265
  end
172
266
 
173
- def level_for(class_name)
174
- modules = []
175
- externals = []
176
- collect_mixins(class_name, own_mixins(class_name), modules, externals, {})
177
- Level.new(class_name: class_name, modules: modules, externals: externals)
178
- end
267
+ # `[levels, whole]` for one world of the chain, up to the first superclass the project does not declare.
268
+ def chain_levels(chain) = [chain.levels.map { |level| Level.new(*level) }, !chain.truncated?]
179
269
 
180
- # Instance methods reach a receiver through `include` / `prepend`; class methods through `extend`, and
181
- # an extended module's own `include`s reach the same singleton.
182
- def own_mixins(class_name)
183
- @kind == :singleton ? (@scope.discovered_extends[class_name] || []) : @scope.includes_of(class_name)
184
- end
270
+ def side = @kind == :singleton ? :singleton : :instance
185
271
 
186
- # Issue #986 — a name the compact-header rename collision left ambiguous resolves to no ONE class,
187
- # but BOTH classes it names are ancestors at runtime; only their MRO order is unknowable. Taking
188
- # both as levels is what keeps this rule alive for the rest of the receiver: a method only one of
189
- # them declares still has one envelope and still fires, a method they disagree about lands two on
190
- # `#envelope_for`'s join and declines there, and the class's own `def`s, its superclass's and its
191
- # unambiguous mixins are untouched. Letting the decline arrive as an unknown EXTERNAL mixin instead
192
- # suppressed the rule for every level of the receiver.
193
- def collect_mixins(owner, raw_names, modules, externals, seen)
194
- raw_names.each do |raw|
195
- resolved = resolve(owner, raw)
196
- names = resolved ? [resolved] : @scope.ambiguous_ancestor_resolutions(owner, raw)
197
- next externals << @scope.ancestor_name_candidates(owner, raw) if names.empty?
198
-
199
- names.each do |name|
200
- next if seen[name]
201
-
202
- seen[name] = true
203
- modules << name
204
- collect_mixins(name, @scope.includes_of(name), modules, externals, seen)
205
- end
206
- end
207
- end
272
+ # Instance methods reach a receiver through `include` / `prepend`; class methods through `extend`, and
273
+ # an extended module's own `include`s reach the same singleton — the chain's singleton side.
274
+ #
275
+ # Issue #986 — the `:arity` flavor expands a name the compact-header rename collision left ambiguous
276
+ # to BOTH classes it names: both are ancestors at runtime and only their MRO order is unknowable. Taking
277
+ # both into the level is what keeps this rule alive for the rest of the receiver: a method only one of
278
+ # them declares still has one envelope and still fires, a method they disagree about lands two on the
279
+ # level's join and declines there, and the class's own `def`s, its superclass's and its unambiguous
280
+ # mixins are untouched. Letting the decline arrive as an unknown EXTERNAL mixin instead suppressed the
281
+ # rule for every level of the receiver.
282
+ def arity_chain(class_name) = Scope::ResolutionChain.for(@scope, class_name.to_s, side, :arity)
208
283
 
209
284
  def resolve(owner, raw)
210
285
  return nil if raw.nil?
@@ -273,8 +348,8 @@ module Rigor
273
348
  # A hook anywhere in the chain, including above the owner, and on either side: a class that answers
274
349
  # names it does not define is a class whose `def`s are not the whole story of what a call reaches.
275
350
  def chain_free_of_hooks?(class_name)
276
- walked_whole_chain?(class_name) do |current|
277
- ([current] + level_for(current).modules).each do |name|
351
+ walked_whole_chain?(class_name) do |level|
352
+ ([level.class_name] + level.modules).each do |name|
278
353
  envelopes = @scope.parameter_envelopes_of(name)
279
354
  return false if dynamic_surface?(name)
280
355
  return false if MISSING_HOOKS.any? { |hook| hook_recorded?(envelopes, hook) }
@@ -290,13 +365,42 @@ module Rigor
290
365
  # receiver may be any of them, and each one's own definition of the name is what runs for it.
291
366
  def subclasses_agree?(class_name, envelope)
292
367
  each_subclass(class_name) do |subclass|
293
- level = level_for(subclass)
294
- return false unless clean_level?(level)
295
- return false unless level_envelopes(level).all?(envelope)
368
+ subclass_levels(subclass).each do |level|
369
+ # A subclass whose own level the budget cut cannot be read, which is a reason to decline.
370
+ return false if level.nil?
371
+ return false unless clean_level?(level)
372
+ return false unless level_envelopes(level).all?(envelope)
373
+ end
296
374
  end
297
375
  true
298
376
  end
299
377
 
378
+ # A subclass's own level (nil where the budget cut it or the chain does not stand for the name). It asks
379
+ # {Inference::DefinerResolution} on the receiver's side (ADR-119 C1b, C2-c): the subclass's own level
380
+ # answers, and a fork, an unproven mixin order, a `possible`-only definer or a hook there is `UNKNOWN`,
381
+ # which `subclasses_agree?` reads as a reason to decline.
382
+ def subclass_levels(subclass)
383
+ case Inference::DefinerResolution.resolve(@scope, subclass, @method_name, side, question: :arity,
384
+ &own_level_answer(subclass))
385
+ in Inference::DefinerResolution::Known(answer: _answer, owner: _owner) then [first_level(subclass)]
386
+ in Inference::DefinerResolution::ABSENT then [nil]
387
+ in Inference::DefinerResolution::UNKNOWN then NO_LEVELS
388
+ end
389
+ end
390
+
391
+ # The subclass's own level, always a candidate (an empty answer where it records nothing), so that the
392
+ # read asks whether the chain stands for the subclass and never whether an ancestor defines the name.
393
+ def own_level_answer(subclass)
394
+ lambda do |chain, from|
395
+ next nil unless from.zero? && !chain.levels.empty?
396
+
397
+ level = Level.new(*chain.levels.first)
398
+ ArityHit.new(level_envelopes(level).uniq, subclass, 0, side)
399
+ end
400
+ end
401
+
402
+ def first_level(class_name) = chain_levels(arity_chain(class_name)).first.first
403
+
300
404
  def each_subclass(class_name)
301
405
  children = children_by_parent
302
406
  queue = children.fetch(class_name.to_s, []).dup
@@ -6,6 +6,7 @@ require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
7
  require_relative "../source/constant_path"
8
8
  require_relative "../inference/singleton_object_constant"
9
+ require_relative "shadow_harness"
9
10
  require_relative "../type"
10
11
  require_relative "diagnostic"
11
12
  require_relative "dependency_recorder"
@@ -60,10 +61,9 @@ module Rigor
60
61
  # ADR-87 WD4 — the canonical rule-id table lives in the light `check_rules/rule_ids.rb` (required at the
61
62
  # top of this file) so {Analysis::RuleCatalog} can read it without loading the engine.
62
63
 
63
- # ADR-35 slice 1 — bound for the `def.override-visibility-reduced`
64
- # ancestor walk, and the public > protected > private ordering
65
- # used to decide whether an override reduces visibility.
66
- OVERRIDE_ANCESTOR_WALK_LIMIT = 100
64
+ # ADR-35 slice 1 — the public > protected > private ordering used to decide whether an override reduces
65
+ # visibility. The override rules' ancestor walk is bounded by `Scope::ANCESTOR_WALK_LIMIT`, the
66
+ # resolution chain's.
67
67
  VISIBILITY_RANK = { public: 2, protected: 1, private: 0 }.freeze
68
68
 
69
69
  # Resolves a user-supplied rule token (`undefined-method`,
@@ -216,7 +216,7 @@ module Rigor
216
216
  def run_node_collectors(path, root, scope_index)
217
217
  collectors = build_node_collectors(path, scope_index, root)
218
218
  RuleWalk.run(root, collectors.values)
219
- shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
219
+ shadow_verify_node_collectors(path, root, scope_index, collectors) if ShadowHarness.enabled?
220
220
  collectors
221
221
  end
222
222
 
@@ -2631,6 +2631,9 @@ module Rigor
2631
2631
  # `Prism::SelfNode` are also skipped — Ruby 2.7+ permits `self.foo` for private methods.
2632
2632
  # - Receiver MUST resolve to a `Type::Nominal` so the rule has a single class identity to query.
2633
2633
  # Unions / Dynamic / shape carriers are skipped.
2634
+ # - Issue #1568 — a module the class PREPENDS sits ahead of it in Ruby's order, so a definition there
2635
+ # is what the call reaches; the class's own private `def` answers only when nothing prepended
2636
+ # defines the name ({#reached_definer_visibility}).
2634
2637
  def visibility_mismatch_diagnostic(path, call_node, scope_index)
2635
2638
  return nil unless explicit_non_self_receiver?(call_node.receiver)
2636
2639
 
@@ -2647,9 +2650,33 @@ module Rigor
2647
2650
  visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
2648
2651
  return nil unless visibility == :private
2649
2652
 
2653
+ reached = reached_definer_visibility(scope, receiver_type.class_name, call_node.name)
2654
+ return nil if reached == :undecided
2655
+ return nil unless reached.nil? || reached == :private
2656
+
2650
2657
  build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
2651
2658
  end
2652
2659
 
2660
+ # Issue #1568, ADR-119 C1c — the recorded visibility of the definition a call on `class_name` reaches: the
2661
+ # first project entry on the class's {Scope::ResolutionChain} from its head that records the name, so a
2662
+ # module the class PREPENDS answers ahead of the class. `Inference::DefinerResolution` reads it with the
2663
+ # `:visibility` question's default answer. It is `:undecided` wherever the chain does not settle the order
2664
+ # (an unpositioned or forked mixin, a conditional include) or an ancestor ahead of the definer may define
2665
+ # the name (one RBS does not know), and the caller declines; a definer whose visibility slot ADR-119's
2666
+ # producers contest is not alone (`DefinerResolution` asks the next candidate too), so it is `:undecided`
2667
+ # there without a read of the table here. nil is nothing recording the name at all, and the class's own
2668
+ # visibility stands. An ancestor the project does not declare is passed over, as the method lookup passes it.
2669
+ def reached_definer_visibility(scope, class_name, method_name)
2670
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :visibility)
2671
+ in Inference::DefinerResolution::Known(answer:)
2672
+ answer
2673
+ in Inference::DefinerResolution::UNKNOWN
2674
+ :undecided
2675
+ in Inference::DefinerResolution::ABSENT
2676
+ nil
2677
+ end
2678
+ end
2679
+
2653
2680
  def explicit_non_self_receiver?(receiver)
2654
2681
  return false if receiver.nil?
2655
2682
  return false if receiver.is_a?(Prism::SelfNode)
@@ -3609,8 +3636,11 @@ module Rigor
3609
3636
  self_type = scope.self_type
3610
3637
  return nil unless self_type.respond_to?(:class_name)
3611
3638
 
3639
+ side = def_node.receiver.nil? ? receiverless_def_side(scope, self_type.class_name, def_node) : :singleton
3640
+ return nil if side.nil?
3641
+
3612
3642
  method_def =
3613
- if def_node.receiver.nil? && !singleton_context_def?(scope, self_type.class_name, def_node.name)
3643
+ if side == :instance
3614
3644
  Reflection.instance_method_definition(self_type.class_name, def_node.name, scope: scope)
3615
3645
  else
3616
3646
  Reflection.singleton_method_definition(self_type.class_name, def_node.name, scope: scope)
@@ -3624,14 +3654,26 @@ module Rigor
3624
3654
  declared_return_union(method_def, scope.environment)
3625
3655
  end
3626
3656
 
3627
- # A receiverless `def` inside `class << self` is a singleton def; the def NODE alone cannot say
3628
- # so, but the discovery walk recorded it under the singleton kind (and not the instance kind).
3629
- # Without this, `class << self; def load` compared its body against `Kernel#load: -> bool` — the
3630
- # inherited INSTANCE method — instead of its own `def self.load` signature. A name defined on
3631
- # both facets stays on the instance lookup (the pre-existing conservative reading).
3632
- def singleton_context_def?(scope, class_name, method_name)
3633
- scope.discovered_method?(class_name, method_name, :singleton) &&
3634
- !scope.discovered_method?(class_name, method_name, :instance)
3657
+ # Which side a receiverless `def` defines, from the indexer's own record of THAT node: the instance table
3658
+ # holding it makes it an instance def, the singleton table (`class << self` bodies, and a `def` under
3659
+ # `Const.instance_eval` that the indexer attributes to the singleton) a singleton def. A node neither table
3660
+ # holds (a `def` in a block whose `self` the engine does not model, such as `E.class_eval do` inside
3661
+ # `class << self`, a redefinition a later `def` replaced) or both do is `nil`, and the caller declines: no
3662
+ # world is consulted, but the record cannot say which signature the body answers to.
3663
+ def receiverless_def_side(scope, class_name, def_node)
3664
+ instance = same_def_node?(scope.user_def_for(class_name, def_node.name), def_node)
3665
+ singleton = same_def_node?(scope.singleton_def_for(class_name, def_node.name), def_node)
3666
+ return :instance if instance && !singleton
3667
+ return :singleton if singleton && !instance
3668
+
3669
+ nil
3670
+ end
3671
+
3672
+ # The tables hold the live node or one re-resolved from a `DefHandle`, so identity is the name and span.
3673
+ def same_def_node?(recorded, def_node)
3674
+ recorded.is_a?(Prism::DefNode) && recorded.name == def_node.name &&
3675
+ recorded.location.start_offset == def_node.location.start_offset &&
3676
+ recorded.location.end_offset == def_node.location.end_offset
3635
3677
  end
3636
3678
 
3637
3679
  # `type_vars:` — ADR-35 WD9 tier 1. When the caller supplies a generic-instantiation
@@ -3739,82 +3781,57 @@ module Rigor
3739
3781
  override_rank < parent_rank
3740
3782
  end
3741
3783
 
3742
- # Breadth-first walk of the project-discovered ancestor chain (included / prepended modules first,
3743
- # then the superclass — Ruby's MRO ordering), yielding each resolved ancestor class name
3744
- # nearest-first. Returns the first truthy value the block produces, or nil. Cross-file: the chain
3745
- # is followed through the scope tables the runner seeds from the project pre-pass (ADR-24 WD1).
3746
- # Cycle-guarded and node-count-capped. Mirrors `ExpressionTyper#resolve_user_def_through_ancestors`.
3747
- def each_project_ancestor(scope, class_name)
3748
- queue = ancestor_class_names(scope, class_name)
3749
- seen = { class_name.to_s => true }
3750
- visited = 0
3751
- until queue.empty?
3752
- current = queue.shift
3753
- next if current.nil? || seen[current]
3754
-
3755
- seen[current] = true
3756
- visited += 1
3757
- return nil if visited > OVERRIDE_ANCESTOR_WALK_LIMIT
3758
-
3759
- result = yield current
3760
- return result if result
3761
-
3762
- ancestor_class_names(scope, current).each { |name| queue.push(name) }
3763
- end
3764
- nil
3784
+ # The project ancestors whose definitions `class_name`'s own `def` OVERRIDES are the entries AFTER the
3785
+ # class on its {Scope::ResolutionChain}, nearest first; {#nearest_ancestor_visibility} and
3786
+ # {#nearest_ancestor_method_def} each read the first of them that answers their question through
3787
+ # `Inference::DefinerResolution` (ADR-119 C1c), `from:` the position after the class. Both are instance
3788
+ # side only: the singleton override rules do not exist, and the singleton side's own read lands with
3789
+ # ADR-119's singleton design.
3790
+ #
3791
+ # Issue #1568 — the walk this replaced was breadth-first from the class's direct ancestors, and those
3792
+ # include the modules the class PREPENDS. A prepended module sits BEFORE the class in Ruby's order: its
3793
+ # `def` overrides the class's, not the other way round, so `prepend P` with a public `P#foo` and a
3794
+ # private `C#foo` reported `C#foo` as reducing `P#foo`'s visibility. It also reached `Base` before a
3795
+ # module included through an included module (#1567's order).
3796
+ #
3797
+ # ADR-119 WD3 — for the two override lints *absent* always counts: a chain that does not settle (a mixin
3798
+ # whose position the tables cannot vouch for, a fork whose worlds disagree, a conditional or
3799
+ # block-scoped `include`), or an external ancestor ahead of the parent that RBS does not know or knows
3800
+ # and declares the name in, is `UNKNOWN`, and the lint stays silent rather than answer from the
3801
+ # breadth-first order the walk used before (`Scope::ResolutionChain::MasterOrder`) where the order is
3802
+ # not known. The two lints read only the `[owner, value]` of a `Known`.
3803
+ #
3804
+ # ADR-46 slice 3 — an ancestor the project does not declare ends the walk's reach into it, and a class
3805
+ # defined LATER under that name would change the answer; `DefinerResolution` files the negative class
3806
+ # edge of each external entry it tests.
3807
+ def overridden_ancestors_start(scope, class_name)
3808
+ own = Scope::ResolutionChain.for(scope, class_name.to_s, :instance, :methods).index_of(class_name.to_s)
3809
+ own && (own + 1)
3765
3810
  end
3766
3811
 
3767
3812
  # `[defining_class, visibility]` for the nearest user-source ancestor that defines an instance
3768
3813
  # method `method_name`, or nil.
3769
3814
  def nearest_ancestor_visibility(scope, class_name, method_name)
3770
- each_project_ancestor(scope, class_name) do |ancestor|
3771
- # Stop at the nearest ancestor that DEFINES the method; its visibility may be nil (unknown) —
3772
- # the caller treats unknown as "cannot prove a reduction" and stays silent. Never fabricate
3773
- # `:public` from a missing entry (that produced a large false-positive cluster on cross-file
3774
- # Rails concerns).
3775
- [ancestor, scope.discovered_method_visibility(ancestor, method_name)] if scope.user_def_for(ancestor,
3776
- method_name)
3777
- end
3778
- end
3779
-
3780
- # Direct ancestors of `class_name` as project-discovered, qualified names: included / prepended
3781
- # modules first, then the superclass. As-written names are resolved against the nesting the
3782
- # subclass's declaration header is written in (`Scope#ancestor_name_candidates`, the single owner of
3783
- # that order); names that resolve to no project class/module (RBS-known / third-party) are dropped.
3784
- def ancestor_class_names(scope, class_name)
3785
- names = []
3786
- scope.includes_of(class_name).each do |raw|
3787
- resolved = resolve_override_ancestor_name(scope, class_name, raw)
3788
- names << resolved if resolved
3789
- end
3790
- raw_super = scope.superclass_of(class_name)
3791
- if raw_super
3792
- resolved_super = resolve_override_ancestor_name(scope, class_name, raw_super)
3793
- names << resolved_super if resolved_super
3815
+ from = overridden_ancestors_start(scope, class_name)
3816
+ return nil if from.nil?
3817
+
3818
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, :instance,
3819
+ question: :override, from: from) do |ancestor|
3820
+ # Stop at the nearest ancestor that DEFINES the method; its visibility may be nil (unknown) —
3821
+ # the caller treats unknown as "cannot prove a reduction" and stays silent. Never fabricate
3822
+ # `:public` from a missing entry (that produced a large false-positive cluster on
3823
+ # cross-file Rails concerns). The `:unrecorded` marker stands for that nil in the answer.
3824
+ next unless scope.user_def_for(ancestor, method_name)
3825
+
3826
+ scope.discovered_method_visibility(ancestor, method_name) || :unrecorded
3827
+ end
3828
+ in Inference::DefinerResolution::Known(answer: [owner, visibility])
3829
+ [owner, visibility == :unrecorded ? nil : visibility]
3830
+ in Inference::DefinerResolution::UNKNOWN # the case/in contract needs one arm per answer
3831
+ nil
3832
+ in Inference::DefinerResolution::ABSENT # rubocop:disable Lint/DuplicateBranch -- see above
3833
+ nil
3794
3834
  end
3795
- names
3796
- end
3797
-
3798
- # Issue #732 — the predicate is asked of the SCOPE, not re-implemented here. This rule kept its own
3799
- # three-table copy until #723 widened the scope's with `discovered_methods` and left this one behind,
3800
- # so the two disagreed about exactly one shape (a project class with no instance def, no superclass
3801
- # and no include — `class Base; def self.build = :built; end`) with nothing observing the fork. #682
3802
- # made `Scope` the single owner of the candidate ORDER; this makes it the single owner of the
3803
- # predicate that filters those candidates too.
3804
- def resolve_override_ancestor_name(scope, subclass_qualified, raw_ancestor)
3805
- resolved = scope.ancestor_name_candidates(subclass_qualified, raw_ancestor)
3806
- .find { |candidate| scope.known_user_class?(candidate) }
3807
- return resolved if resolved
3808
-
3809
- # ADR-46 slice 3 — the override checker reads the class graph
3810
- # directly (not through the recorder's `Scope` choke points), and
3811
- # short-circuits when the ancestor resolves to no project class, so
3812
- # an incremental re-check has no edge telling it to re-check this
3813
- # subclass when that ancestor is later defined. Record a negative
3814
- # class edge (keyed on the unqualified name) so the appeared-class
3815
- # widening picks it up.
3816
- DependencyRecorder.read_missing(:class, raw_ancestor.to_s.split("::").last) if DependencyRecorder.active?
3817
- nil
3818
3835
  end
3819
3836
 
3820
3837
  def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
@@ -3894,9 +3911,23 @@ module Rigor
3894
3911
  # `[defining_class, RBS::Definition::Method]` for the nearest project-discovered ancestor whose
3895
3912
  # RBS declares `method_name` (not the starting class's own declaration), or nil.
3896
3913
  def nearest_ancestor_method_def(scope, class_name, method_name)
3897
- each_project_ancestor(scope, class_name) do |ancestor|
3898
- method_def = safe_instance_method_definition(ancestor, method_name, scope)
3899
- [ancestor, method_def] if method_def && !defined_on?(method_def, class_name)
3914
+ from = overridden_ancestors_start(scope, class_name)
3915
+ return nil if from.nil?
3916
+
3917
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, :instance,
3918
+ question: :override, from: from) do |ancestor|
3919
+ method_def = safe_instance_method_definition(ancestor, method_name, scope)
3920
+ # The signature text is the answer two worlds compare: an `RBS::Definition::Method` has no
3921
+ # structural equality of its own.
3922
+ method_def.method_types.map(&:to_s) if method_def && !defined_on?(method_def, class_name)
3923
+ end
3924
+ in Inference::DefinerResolution::Known(owner:)
3925
+ method_def = safe_instance_method_definition(owner, method_name, scope)
3926
+ method_def && [owner, method_def]
3927
+ in Inference::DefinerResolution::UNKNOWN # the case/in contract needs one arm per answer
3928
+ nil
3929
+ in Inference::DefinerResolution::ABSENT # rubocop:disable Lint/DuplicateBranch -- see above
3930
+ nil
3900
3931
  end
3901
3932
  end
3902
3933