rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
+ require_relative "../source/constant_path"
7
8
  require_relative "../type"
8
9
  require_relative "diagnostic"
9
10
  require_relative "dependency_recorder"
@@ -12,41 +13,35 @@ require_relative "check_rules/always_truthy_condition_collector"
12
13
  require_relative "check_rules/unreachable_clause_collector"
13
14
  require_relative "check_rules/dead_assignment_collector"
14
15
  require_relative "check_rules/ivar_write_collector"
16
+ require_relative "check_rules/main_pass_collector"
15
17
  require_relative "check_rules/self_closedness_scanner"
16
18
 
17
19
  module Rigor
18
20
  module Analysis
19
- # First-preview catalogue of `rigor check` diagnostic rules.
21
+ # Catalogue of `rigor check` diagnostic rules.
20
22
  #
21
- # The rules are intentionally narrow: they fire ONLY when the
22
- # engine is confident enough to make a useful claim, and they
23
- # MUST NOT raise on unrecognised AST shapes, RBS gaps, or
24
- # missing scope information. Each rule consumes the per-node
25
- # scope index produced by
23
+ # Rules fire ONLY when the engine is confident enough to make a
24
+ # useful claim and MUST NOT raise on unrecognised AST shapes,
25
+ # RBS gaps, or missing scope information. Each rule consumes
26
+ # the per-node scope index produced by
26
27
  # `Rigor::Inference::ScopeIndexer.index` and yields zero or
27
28
  # more `Rigor::Analysis::Diagnostic` values.
28
29
  #
29
- # The first shipped rule, `UndefinedMethodOnTypedReceiver`,
30
- # flags an explicit-receiver `Prism::CallNode` whose receiver
31
- # statically resolves to a `Type::Nominal` or `Type::Singleton`
32
- # known to the analyzer's RBS environment AND whose method
33
- # name does not appear on that class's instance / singleton
34
- # method table. This is the canonical "type check" signal
35
- # ("Foo has no method bar"), but it explicitly does NOT fire
36
- # for:
30
+ # The primary rule (`call.undefined-method`) flags an
31
+ # explicit-receiver `Prism::CallNode` whose receiver statically
32
+ # resolves to a class known to the RBS environment and whose
33
+ # method name does not appear on that class's method table.
34
+ # It does NOT fire for:
37
35
  #
38
36
  # - implicit-self calls (no `node.receiver`) — too noisy
39
37
  # without per-method RBS for every helper in the class.
40
38
  # - dynamic / unknown receivers (`Dynamic[T]`, `Top`, `Union`)
41
39
  # — by definition we cannot enumerate the method set.
42
- # - shape carriers (`Tuple`, `HashShape`, `Constant`) their
43
- # dispatch goes through `ShapeDispatch` / `ConstantFolding`
44
- # which the rule does not yet model.
40
+ # - shape carriers: `Tuple` → "Array", `HashShape` "Hash",
41
+ # `Constant` the constant's class `concrete_class_name`
42
+ # resolves these to their runtime class for dispatch.
45
43
  # - receivers whose class name is NOT registered in the
46
44
  # loader (RBS-blind environments, unknown stdlib).
47
- #
48
- # The above list is the deliberate conservative envelope of
49
- # the first preview; later slices broaden it.
50
45
  # rubocop:disable Metrics/ModuleLength
51
46
  module CheckRules
52
47
  # Canonical identifiers for each rule. Per ADR-8 §
@@ -163,59 +158,149 @@ module Rigor
163
158
  # @param root [Prism::Node]
164
159
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
165
160
  # @return [Array<Rigor::Analysis::Diagnostic>]
166
- def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
167
- diagnostics = []
168
- Source::NodeWalker.each(root) do |node|
169
- case node
170
- when Prism::CallNode
171
- diagnostics.concat(call_node_diagnostics(path, node, scope_index))
172
- when Prism::DefNode
173
- return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
174
- diagnostics << return_diagnostic if return_diagnostic
175
- override_vis = override_visibility_diagnostic(path, node, scope_index)
176
- diagnostics << override_vis if override_vis
177
- override_return = override_return_widened_diagnostic(path, node, scope_index)
178
- diagnostics << override_return if override_return
179
- override_param = override_param_narrowed_diagnostic(path, node, scope_index)
180
- diagnostics << override_param if override_param
181
- when Prism::IfNode, Prism::UnlessNode
182
- unreachable = unreachable_branch_diagnostic(path, node, scope_index)
183
- diagnostics << unreachable if unreachable
184
- end
185
- end
161
+ #
162
+ # ADR-53 B4 — when `node_collectors` is supplied, the converged
163
+ # {Plugin::NodeRuleWalk} traversal has already populated the built-in
164
+ # collectors (including the main pass) in one shared walk with the
165
+ # plugin node-rules, so they are consumed as-is. When it is nil (a
166
+ # direct caller with no plugin walk, e.g. a unit test), the standalone
167
+ # {RuleWalk} walk runs here instead, so `diagnose` stays correct
168
+ # without the converged path.
169
+ def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [],
170
+ node_collectors: nil)
171
+ collectors = node_collectors || run_node_collectors(path, root, scope_index)
172
+ diagnostics = collectors[:main_pass].results.dup
186
173
  diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
187
- always_truthy_results, unreachable_clause_results = flow_collector_results(root, scope_index)
188
- diagnostics.concat(always_truthy_condition_diagnostics(path, always_truthy_results))
189
- diagnostics.concat(unreachable_clause_diagnostics(path, unreachable_clause_results))
190
- diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
191
- diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
174
+ diagnostics.concat(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
175
+ diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
176
+ diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
177
+ diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
192
178
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
193
179
  end
194
180
 
195
- # ADR-53 Track B (slice B2) both flow collectors ride one
196
- # {RuleWalk} traversal instead of walking the file once each.
197
- # Under `RIGOR_SHADOW_RULE_WALK=1` the legacy per-collector walks
198
- # also run as the oracle and any divergence aborts the run — the
199
- # corpus-scale half of the equivalence harness (the curated half
200
- # is `rule_walk_equivalence_spec`).
201
- def flow_collector_results(root, scope_index)
202
- always_truthy = AlwaysTruthyConditionCollector.new(scope_index)
203
- unreachable_clauses = UnreachableClauseCollector.new(scope_index)
204
- RuleWalk.run(root, [always_truthy, unreachable_clauses])
205
- if ENV["RIGOR_SHADOW_RULE_WALK"]
206
- shadow_verify_flow_collectors(root, scope_index, always_truthy.results, unreachable_clauses.results)
207
- end
208
- [always_truthy.results, unreachable_clauses.results]
181
+ # The verbatim per-node dispatch of the former inline main pass
182
+ # (`diagnose`'s `Source::NodeWalker.each` `case`), now invoked by
183
+ # {MainPassCollector} on the shared {RuleWalk}. Returns the
184
+ # diagnostics for one node, in the same emission order as before.
185
+ def main_pass_node_diagnostics(path, node, scope_index)
186
+ case node
187
+ when Prism::CallNode
188
+ call_node_diagnostics(path, node, scope_index)
189
+ when Prism::DefNode
190
+ [
191
+ return_type_mismatch_diagnostic(path, node, scope_index),
192
+ override_visibility_diagnostic(path, node, scope_index),
193
+ override_return_widened_diagnostic(path, node, scope_index),
194
+ override_param_narrowed_diagnostic(path, node, scope_index)
195
+ ].compact
196
+ when Prism::IfNode, Prism::UnlessNode
197
+ [unreachable_branch_diagnostic(path, node, scope_index)].compact
198
+ else
199
+ []
200
+ end
209
201
  end
210
202
 
211
- def shadow_verify_flow_collectors(root, scope_index, always_truthy_results, unreachable_clause_results)
212
- legacy_always = AlwaysTruthyConditionCollector.new(scope_index).collect(root)
213
- legacy_clauses = UnreachableClauseCollector.new(scope_index).collect(root)
214
- return if legacy_always == always_truthy_results && legacy_clauses == unreachable_clause_results
203
+ # Constructs the fresh, unpopulated built-in collector set keyed by
204
+ # role, including the main pass. Split out so the converged walk
205
+ # (ADR-53 B4) can build the collectors, drive them via a
206
+ # {RuleWalk::CollectorDriver} inside the single {Plugin::NodeRuleWalk}
207
+ # traversal, and hand the populated set back to {.diagnose} as
208
+ # `node_collectors:`. The main pass needs `path` because its per-node
209
+ # diagnostics carry it (ADR-53 B3c hosts it on the same walk).
210
+ def build_node_collectors(path, scope_index)
211
+ {
212
+ main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
213
+ always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
214
+ unreachable_clauses: UnreachableClauseCollector.new(scope_index),
215
+ ivar_writes: IvarWriteCollector.new(scope_index),
216
+ dead_assignments: DeadAssignmentCollector.new(scope_index)
217
+ }
218
+ end
215
219
 
216
- raise "RIGOR_SHADOW_RULE_WALK divergence: always-truthy legacy=#{legacy_always.size} " \
217
- "walk=#{always_truthy_results.size}; unreachable-clause legacy=#{legacy_clauses.size} " \
218
- "walk=#{unreachable_clause_results.size}"
220
+ # A {RuleWalk::CollectorDriver} over a built-in collector set, for a
221
+ # foreign traversal to drive (ADR-53 B4). The driver visits each node
222
+ # and derives child contexts exactly as the standalone {RuleWalk}
223
+ # walk would.
224
+ def node_collector_driver(collectors)
225
+ RuleWalk::CollectorDriver.new(collectors.values)
226
+ end
227
+
228
+ # ADR-53 Track B — the {RuleWalk}-hosted built-in collectors (the main
229
+ # pass and the four fact collectors) all ride one traversal of the
230
+ # file instead of one walk each. Returns the populated collectors
231
+ # keyed by role so the caller can build the diagnostics from each
232
+ # collector's `results`. Used on the standalone path (no converged
233
+ # plugin walk); the converged path populates the same collector set
234
+ # via {.node_collector_driver} instead.
235
+ #
236
+ # Under `RIGOR_SHADOW_RULE_WALK=1` each hosted collector's legacy
237
+ # single-collector `#collect` walk also runs as the oracle and any
238
+ # divergence aborts the run — the corpus-scale half of the
239
+ # equivalence harness (the curated half is `rule_walk_equivalence_spec`).
240
+ def run_node_collectors(path, root, scope_index)
241
+ collectors = build_node_collectors(path, scope_index)
242
+ RuleWalk.run(root, collectors.values)
243
+ shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
244
+ collectors
245
+ end
246
+
247
+ def shadow_verify_node_collectors(path, root, scope_index, collectors)
248
+ divergences = collectors.filter_map do |role, collector|
249
+ legacy = oracle_results(role, collector, path, root, scope_index)
250
+ next if comparable(legacy) == comparable(collector.results)
251
+
252
+ "#{role} legacy=#{legacy.size} walk=#{collector.results.size}"
253
+ end
254
+ return if divergences.empty?
255
+
256
+ raise "RIGOR_SHADOW_RULE_WALK divergence: #{divergences.join('; ')}"
257
+ end
258
+
259
+ # Normalises a collector's result for value comparison. The fact
260
+ # collectors return `Data` / Hash structures that already compare by
261
+ # value; the main pass returns {Diagnostic} objects (plain objects
262
+ # with identity `==`), so serialise those to hashes first.
263
+ def comparable(results)
264
+ return results.map(&:to_h) if results.is_a?(Array) && results.first.is_a?(Diagnostic)
265
+
266
+ results
267
+ end
268
+
269
+ # The oracle each hosted collector's walk result is checked against.
270
+ # The fact collectors re-run their legacy single-collector `#collect`
271
+ # walk; the main pass re-runs the former inline `Source::NodeWalker`
272
+ # `case` (`main_pass_oracle`) since its diagnostics are the result.
273
+ def oracle_results(role, collector, path, root, scope_index)
274
+ return main_pass_oracle(path, root, scope_index) if role == :main_pass
275
+
276
+ collector.class.new(scope_index).collect(root)
277
+ end
278
+
279
+ # The former inline main pass, kept as the shadow oracle: walks the
280
+ # tree with `Source::NodeWalker.each` and accumulates the same
281
+ # per-node diagnostics in the same order {MainPassCollector} now
282
+ # produces them on the shared walk.
283
+ def main_pass_oracle(path, root, scope_index)
284
+ diagnostics = []
285
+ Source::NodeWalker.each(root) do |node|
286
+ diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
287
+ end
288
+ diagnostics
289
+ end
290
+
291
+ # ADR-53 B4 — corpus-scale oracle for the CONVERGED walk: the
292
+ # collectors (including the main pass, ADR-53 B3c) were populated by
293
+ # the {Plugin::NodeRuleWalk} traversal, not by `RuleWalk.run`, so
294
+ # re-run each collector's legacy oracle (the fact collectors'
295
+ # `#collect` walk, the main pass's inline `Source::NodeWalker` `case`)
296
+ # and assert the converged walk produced byte-identical results. Same
297
+ # divergence contract as {.shadow_verify_node_collectors}; nil
298
+ # collectors (caller without built-in collection) is a no-op. `path`
299
+ # is threaded because the main pass's oracle carries it.
300
+ def shadow_verify_converged_collectors(path, root, scope_index, collectors)
301
+ return if collectors.nil?
302
+
303
+ shadow_verify_node_collectors(path, root, scope_index, collectors)
219
304
  end
220
305
 
221
306
  def call_node_diagnostics(path, node, scope_index)
@@ -252,8 +337,8 @@ module Rigor
252
337
  # Class-level ivars (`@x = 1` outside any def, in the
253
338
  # class body) are also skipped — they're a separate
254
339
  # surface (`Module#@var`) the engine doesn't yet model.
255
- def ivar_write_mismatch_diagnostics(path, root, scope_index)
256
- IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar|
340
+ def ivar_write_mismatch_diagnostics(path, ivar_writes)
341
+ ivar_writes.flat_map do |class_name, writes_by_ivar|
257
342
  writes_by_ivar.flat_map do |ivar_name, writes|
258
343
  ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
259
344
  end
@@ -266,8 +351,8 @@ module Rigor
266
351
  # read in the same body. The
267
352
  # `Analysis::CheckRules::DeadAssignmentCollector` describes
268
353
  # the conservative envelope.
269
- def dead_assignment_diagnostics(path, root, scope_index)
270
- DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
354
+ def dead_assignment_diagnostics(path, dead_assignments)
355
+ dead_assignments.map do |result|
271
356
  build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
272
357
  end
273
358
  end
@@ -421,9 +506,24 @@ module Rigor
421
506
  scope = scope_index[call_node]
422
507
  return nil if scope.nil?
423
508
 
424
- receiver_type = scope.type_of(call_node.receiver)
509
+ # N3 — a safe-navigation call (`recv&.m`) never dispatches on the
510
+ # nil edge of its receiver: at runtime it short-circuits to nil.
511
+ # A receiver that types as exactly `nil` yields nil with no call at
512
+ # all, so it is silent (no dead-code diagnostic — `&.` is the
513
+ # nil-skip operator by design, and frightening working
514
+ # `@x = nil; @x&.m` code would breach FP discipline). A nil-bearing
515
+ # *union* receiver is left to flow through unchanged: a `T | nil`
516
+ # union has no single concrete class, so the rule already bails
517
+ # below — preserving that keeps `&.` from newly firing on the
518
+ # non-nil constituent (which, for a cross-file project def, would
519
+ # be a working-code false positive).
520
+ receiver_type = safe_navigation_receiver(call_node, scope)
425
521
  class_name = concrete_class_name(receiver_type)
426
- return nil if class_name.nil?
522
+ # A union receiver has no single concrete class. The scalar path
523
+ # below cannot reason about it, but the call is still definitely
524
+ # undefined when EVERY arm lacks the method — see
525
+ # `union_undefined_method_diagnostic`.
526
+ return union_undefined_method_diagnostic(path, call_node, receiver_type, scope) if class_name.nil?
427
527
 
428
528
  # ADR-26 — a plugin may declare a class "open": one
429
529
  # known to respond beyond its RBS-declared method
@@ -602,6 +702,20 @@ module Rigor
602
702
  when Type::Tuple then "Array"
603
703
  when Type::HashShape then "Hash"
604
704
  when Type::Constant then constant_class_name(type.value)
705
+ # A refinement IS its base class for method dispatch — its method
706
+ # surface is the base's. Resolve to the base so the call rules
707
+ # (undefined-method / wrong-arity / argument-type-mismatch) reason
708
+ # about it instead of bailing. `Type::Refined` carries string-family
709
+ # refinements (`lowercase-string`, …) over an explicit `.base`;
710
+ # `Type::IntegerRange` carries the bounded-int refinements
711
+ # (`non-negative-int`, `positive-int`, `int<1,5>`), every one an
712
+ # Integer; `Type::Difference` (`A - B`) carries the non-empty /
713
+ # non-zero refinements (`non-empty-string` = `String - ""`,
714
+ # `non-empty-array` = `Array - []`, `non-zero-int` = `Integer - 0`)
715
+ # — subtracting values never changes the method surface, so the
716
+ # base (minuend) class dispatches.
717
+ when Type::Refined, Type::Difference then concrete_class_name(type.base)
718
+ when Type::IntegerRange then "Integer"
605
719
  end
606
720
  end
607
721
 
@@ -678,12 +792,90 @@ module Rigor
678
792
  scope = scope_index[miss.node]
679
793
  next if scope.nil?
680
794
  next unless confidently_closed_self_class?(miss.class_name, scope)
795
+ next if method_defined_on_known_subclass?(miss.class_name, miss.method_name, scope)
681
796
 
682
797
  build_self_undefined_method_diagnostic(path, miss)
683
798
  end
684
799
  end
685
800
 
801
+ # ADR-24 slice 4 — subclass-aware gating (the abstract / template-method
802
+ # base-class false positive the WD4 corpus eval surfaced). A base class
803
+ # legitimately calls a method its subclasses implement
804
+ # (`Mail::CommonField#decoded` calls `do_decode`, which
805
+ # `Mail::UnstructuredField < CommonField` and its siblings define; the
806
+ # same shape covers `Mail::Retriever#find` → POP3 / IMAP). When the
807
+ # missed method is discovered on ANY known subclass of the self-class,
808
+ # the call is a template-method hook, not a typo — suppress. Walks the
809
+ # project subclass closure (the `discovered_superclasses` child→parent
810
+ # map inverted, cycle-guarded). A pure narrowing — it only ever
811
+ # suppresses a firing the closed-class gate would otherwise emit.
812
+ def method_defined_on_known_subclass?(class_name, method_name, scope)
813
+ supers = scope.discovered_superclasses
814
+ seen = {}
815
+ queue = direct_subclasses(class_name, supers)
816
+ until queue.empty?
817
+ subclass = queue.shift
818
+ next if seen[subclass]
819
+
820
+ seen[subclass] = true
821
+ return true if method_known_on_class?(subclass, method_name, scope)
822
+
823
+ queue.concat(direct_subclasses(subclass, supers))
824
+ end
825
+ false
826
+ end
827
+
828
+ # The directly-recorded subclasses of `class_name`. `discovered_superclasses`
829
+ # keys the child fully-qualified (`Mail::POP3`) but records the parent
830
+ # *as written* (`Retriever`), so a qualified miss class (`Mail::Retriever`)
831
+ # is matched by resolving the parent name in the child's namespace.
832
+ def direct_subclasses(class_name, discovered_superclasses)
833
+ discovered_superclasses.filter_map { |child, parent| child if parent_matches?(child, parent, class_name) }
834
+ end
835
+
836
+ # Ruby constant lookup: a recorded parent `Retriever` on child
837
+ # `Mail::POP3` resolves to `Mail::Retriever` (walk the child's namespace
838
+ # prefixes, longest first), matched against the miss's fully-qualified
839
+ # class name. Namespace-anchored, so it cannot match a same-named base in
840
+ # an unrelated namespace.
841
+ def parent_matches?(child, parent, class_name)
842
+ parent_name = parent.to_s
843
+ return true if parent_name == class_name
844
+
845
+ segments = child.to_s.split("::")[0...-1]
846
+ until segments.empty?
847
+ return true if "#{segments.join('::')}::#{parent_name}" == class_name
848
+
849
+ segments.pop
850
+ end
851
+ false
852
+ end
853
+
854
+ # Whether `method_name` is defined on `class_name` in the project — a
855
+ # plain `def` (the def-node table) or a dynamic definition
856
+ # (`define_method` / `attr_*` / `alias`). `discovered_method?` alone
857
+ # misses plain defs, which is exactly what the abstract hooks are.
858
+ def method_known_on_class?(class_name, method_name, scope)
859
+ !scope.user_def_for(class_name, method_name).nil? ||
860
+ scope.discovered_method?(class_name, method_name, :instance)
861
+ end
862
+
863
+ # ADR-24 slice 4 — the universal bases. A recorded self-call miss tagged
864
+ # with one of these means the engine fell back to the root self-type
865
+ # because it could NOT resolve the real class (a class-body macro context
866
+ # where self is the Class object, top-level `main`, `instance_eval`, an
867
+ # FFI / `define_method` metaprogramming surface). Their instance method
868
+ # set is never "project-known and complete" — every object also responds
869
+ # to whatever the unresolved real class adds — so a miss there is a
870
+ # resolution gap, not a typo. This is the dominant false-positive class
871
+ # the WD4 corpus eval surfaced (protobuf 73 / tdiary 199 / pycall 10 /
872
+ # … FFI + class-macro calls, 287 firings across the corpus); excluding
873
+ # it is a pure narrowing.
874
+ SELF_UNDEFINED_UNIVERSAL_BASES = %w[Object BasicObject Kernel].to_set.freeze
875
+ private_constant :SELF_UNDEFINED_UNIVERSAL_BASES
876
+
686
877
  def confidently_closed_self_class?(class_name, scope)
878
+ return false if SELF_UNDEFINED_UNIVERSAL_BASES.include?(class_name)
687
879
  return false if unbounded_receiver_surface?(class_name, scope)
688
880
  return false if scope.discovered_method?(class_name, :method_missing, :instance)
689
881
  # A superclass or mixin extends the surface beyond what this file
@@ -742,7 +934,7 @@ module Rigor
742
934
  # by `undefined_method_diagnostic`; it returns nil
743
935
  # when the call's receiver / RBS coverage / call shape
744
936
  # disqualifies the rule.
745
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
937
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
746
938
  def wrong_arity_diagnostic(path, call_node, scope_index)
747
939
  return nil if call_node.receiver.nil?
748
940
  return nil unless plain_positional_call?(call_node)
@@ -755,6 +947,15 @@ module Rigor
755
947
  return nil if class_name.nil?
756
948
 
757
949
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
950
+ # `Struct.new(:a, :b).new(...)` chained: the inner
951
+ # `Struct.new(...)` is an anonymous Struct *subclass* whose
952
+ # `.new` accepts any positional arity (one slot per member,
953
+ # all defaulting to nil) — including zero. The receiver types
954
+ # as `Singleton[Struct]` (so the call-site `.new` dispatches,
955
+ # per the dispatcher's `struct_new_lift`), but validating that
956
+ # `.new` against the real `Struct.new(*Symbol)` signature is a
957
+ # false positive. Skip arity-checking the chained position.
958
+ return nil if anonymous_struct_new_call?(call_node, class_name, kind)
758
959
  return nil if scope.discovered_method?(class_name, call_node.name, kind)
759
960
 
760
961
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
@@ -772,7 +973,25 @@ module Rigor
772
973
 
773
974
  build_arity_diagnostic(path, call_node, class_name, min, max, actual)
774
975
  end
775
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
976
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
977
+
978
+ # True for the outer `.new` of a chained `Struct.new(...).new`:
979
+ # `class_name`/`kind` already pin the receiver to
980
+ # `Singleton[Struct]`, and the receiver node is itself a
981
+ # `Struct.new` (or `::Struct.new`) call — the anonymous subclass.
982
+ def anonymous_struct_new_call?(call_node, class_name, kind)
983
+ return false unless class_name == "Struct" && kind == :singleton
984
+ return false unless call_node.name == :new
985
+
986
+ receiver = call_node.receiver
987
+ return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
988
+
989
+ inner = receiver.receiver
990
+ return true if inner.is_a?(Prism::ConstantReadNode) && inner.name == :Struct
991
+
992
+ # `::Struct.new(...).new` — a top-level constant path.
993
+ inner.is_a?(Prism::ConstantPathNode) && inner.parent.nil? && inner.name == :Struct
994
+ end
776
995
 
777
996
  def plain_positional_call?(call_node)
778
997
  arguments = call_node.arguments
@@ -864,6 +1083,17 @@ module Rigor
864
1083
  scope = scope_index[call_node]
865
1084
  return nil if scope.nil?
866
1085
 
1086
+ # ADR-58 WD1 — a receiver whose `nil` constituent is purely
1087
+ # declaration-sourced (the class-ivar index seed of a ctor
1088
+ # `@x = nil` written in another method, possibly copied into a
1089
+ # local via `r = @right`) does not fire by default: the working
1090
+ # program's cross-method invariant is assumed per the robustness
1091
+ # principle. The nil stays in the displayed type; only its use as
1092
+ # diagnostic fuel is withheld. Any flow-live touch (method-local
1093
+ # nil write, failed-guard narrowing) drops the mark upstream, so
1094
+ # flow-observed nil keeps firing exactly as before.
1095
+ return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
1096
+
867
1097
  receiver_type = scope.type_of(call_node.receiver)
868
1098
  return nil unless receiver_type.is_a?(Type::Union)
869
1099
 
@@ -883,6 +1113,21 @@ module Rigor
883
1113
  union.members.any? { |member| nil_member?(member) }
884
1114
  end
885
1115
 
1116
+ # The receiver type the `call.undefined-method` existence check
1117
+ # should reason about. For a safe-navigation call whose receiver
1118
+ # types as exactly `nil`, this is `Type::Bot` — the call is
1119
+ # statically skipped at runtime, and `concrete_class_name(Bot)` is
1120
+ # nil so the rule bails (silent). Every other receiver (including a
1121
+ # `T | nil` union, which already has no single concrete class) flows
1122
+ # through unchanged.
1123
+ def safe_navigation_receiver(call_node, scope)
1124
+ receiver_type = scope.type_of(call_node.receiver)
1125
+ return receiver_type unless call_node.safe_navigation?
1126
+ return receiver_type unless nil_member?(receiver_type)
1127
+
1128
+ Type::Combinator.bot
1129
+ end
1130
+
886
1131
  def nil_member?(member)
887
1132
  (member.is_a?(Type::Constant) && member.value.nil?) ||
888
1133
  (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
@@ -919,6 +1164,71 @@ module Rigor
919
1164
  !definition.methods[method_name.to_sym].nil?
920
1165
  end
921
1166
 
1167
+ # Teeth on a *union* receiver. The scalar `undefined_method_diagnostic`
1168
+ # bails when the receiver has no single concrete class; here we fire
1169
+ # when EVERY non-nil arm is a fully-known, bounded, instance class on
1170
+ # which the method is absent — the call is then undefined regardless of
1171
+ # which arm the value takes at runtime (`A | B` responds to `m` only if
1172
+ # both `A` and `B` do). FP-safe by construction: `method_present_anywhere?`
1173
+ # returns "present" for any Dynamic / unknown / unbuildable /
1174
+ # source-declared arm, so the `any?` short-circuits and we never fire on
1175
+ # uncertainty; `union_arm_blocks_undefined_fire?` additionally bails on
1176
+ # any open (ADR-26) / synthesized / singleton / module-mixin arm. This is
1177
+ # no more aggressive than the scalar rule — it just applies the same
1178
+ # certainty test to each arm.
1179
+ #
1180
+ # Nil-bearing unions are deferred: their nil arm interacts with the
1181
+ # `possible-nil-receiver` rule, safe-navigation, and ADR-58
1182
+ # declaration-sourced nil. Slice 1 handles pure non-nil unions
1183
+ # (e.g. `String | Symbol`).
1184
+ def union_undefined_method_diagnostic(path, call_node, receiver_type, scope)
1185
+ return nil unless receiver_type.is_a?(Type::Union)
1186
+ return nil if call_node.safe_navigation?
1187
+
1188
+ members = receiver_type.members
1189
+ # Nil-bearing unions (`T | nil`) stay silent — the deliberate N3
1190
+ # decision (`safe_navigation_undefined_method_spec.rb`). A corpus FP
1191
+ # study of a bundled-arm-narrowed candidate (12 projects incl.
1192
+ # ActiveSupport-heavy) found ~zero real firings yet a demonstrated
1193
+ # loss-of-specificity false positive, so the silence is kept. See
1194
+ # ADR-62 and `docs/notes/20260613-mutation-teeth-harness.md`.
1195
+ return nil if members.any? { |member| nil_member?(member) }
1196
+ return nil if members.any? { |member| union_arm_blocks_undefined_fire?(member, scope) }
1197
+ # Only a genuinely multi-class union ("the value is an A or a B") gains
1198
+ # from this rule. A union whose arms all resolve to ONE class
1199
+ # (`Hash[K1, V1] | Hash[K2, V2]`) is a shape-join artifact — checking
1200
+ # method existence there is the scalar rule's job, and when the join is
1201
+ # a misinference it is a false positive (a corpus probe caught mail's
1202
+ # `compose_codepoints` typed `Hash | Hash` for an `Array`, flagging
1203
+ # `.pack`). Require at least two distinct arm classes.
1204
+ return nil if members.map { |member| concrete_class_name(member) }.uniq.size < 2
1205
+ return nil if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
1206
+
1207
+ build_undefined_method_diagnostic(path, call_node, receiver_type)
1208
+ end
1209
+
1210
+ # An arm that makes a sound "undefined on every arm" verdict
1211
+ # impossible: a non-class surface (Dynamic / Top / Bot), a singleton
1212
+ # (slice 1 reasons about instance arms only), the generic metaclass
1213
+ # `Class` / `Module` (a value typed as one is *some* class/module object
1214
+ # whose singleton methods cannot be enumerated from the metaclass — e.g.
1215
+ # `plugin_class : Class` really holds a `Plugin` subclass with
1216
+ # `.manifest`), an unbounded receiver (ADR-26 open class or a synthesized
1217
+ # stub), or a module mixin whose Object-inherited methods the per-arm
1218
+ # lookup would miss.
1219
+ METACLASS_ARMS = %w[Class Module].to_set.freeze
1220
+ private_constant :METACLASS_ARMS
1221
+
1222
+ def union_arm_blocks_undefined_fire?(member, scope)
1223
+ class_name = concrete_class_name(member)
1224
+ return true if class_name.nil?
1225
+ return true if member.is_a?(Type::Singleton)
1226
+ return true if METACLASS_ARMS.include?(class_name)
1227
+ return true if unbounded_receiver_surface?(class_name, scope)
1228
+
1229
+ module_mixin_receiver?(member, scope)
1230
+ end
1231
+
922
1232
  # Slice 7 phase 19 — PHPStan-style `dump_type(value)`.
923
1233
  # When the engine recognises a call to `dump_type` (with
924
1234
  # any of the supported receiver shapes — implicit self
@@ -1011,27 +1321,12 @@ module Rigor
1011
1321
  receiver = call_node.receiver
1012
1322
  return true if receiver.nil?
1013
1323
 
1014
- name = constant_name_of(receiver)
1324
+ name = Source::ConstantPath.qualified_name_or_nil(receiver)
1015
1325
  return false if name.nil?
1016
1326
 
1017
1327
  RIGOR_TESTING_RECEIVERS.include?(name)
1018
1328
  end
1019
1329
 
1020
- def constant_name_of(node)
1021
- case node
1022
- when Prism::ConstantReadNode then node.name.to_s
1023
- when Prism::ConstantPathNode then render_constant_path(node)
1024
- end
1025
- end
1026
-
1027
- def render_constant_path(node)
1028
- parent = node.parent
1029
- base = constant_name_of(parent)
1030
- return nil if parent && base.nil?
1031
-
1032
- parent ? "#{base}::#{node.name}" : node.name.to_s
1033
- end
1034
-
1035
1330
  def build_assert_type_diagnostic(path, call_node, expected, actual)
1036
1331
  Diagnostic.from_message_loc(
1037
1332
  call_node,
@@ -1048,7 +1343,8 @@ module Rigor
1048
1343
  rule: RULE_NIL_RECEIVER,
1049
1344
  path: path,
1050
1345
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
1051
- severity: :error
1346
+ severity: :error,
1347
+ method_name: call_node.name.to_s
1052
1348
  )
1053
1349
  end
1054
1350
 
@@ -1236,7 +1532,9 @@ module Rigor
1236
1532
  rule: RULE_VISIBILITY_MISMATCH,
1237
1533
  path: path,
1238
1534
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1239
- severity: :error
1535
+ severity: :error,
1536
+ receiver_type: receiver_type.class_name,
1537
+ method_name: call_node.name.to_s
1240
1538
  )
1241
1539
  end
1242
1540
 
@@ -1382,6 +1680,21 @@ module Rigor
1382
1680
  UNIVERSAL_EQUALITY_METHODS = %i[== != eql? equal? <=>].to_set.freeze
1383
1681
  private_constant :UNIVERSAL_EQUALITY_METHODS
1384
1682
 
1683
+ # ADR-64 WD1 — the binary arithmetic / bit / ordering operators
1684
+ # dispatch through Ruby's `coerce` protocol (and `<=>` for the
1685
+ # comparisons): `5 + Money.new` is valid at runtime because
1686
+ # `Integer#+` calls `Money#coerce(5)`, even though no RBS `Integer#+`
1687
+ # overload lists `Money`. A non-`Numeric` argument to them is therefore
1688
+ # NOT statically refutable — any user type may define `coerce` — so the
1689
+ # *non-nil* argument-type-mismatch channel excludes them (a fixed
1690
+ # allow-list, modelled on {UNIVERSAL_EQUALITY_METHODS}, not `coerce`
1691
+ # detection). `nil` never coerces, so the nil channel stays in force
1692
+ # here; the exclusion applies to the non-nil case only. `<=>` and the
1693
+ # `==` family are already excluded wholesale by
1694
+ # {UNIVERSAL_EQUALITY_METHODS}.
1695
+ COERCE_DISPATCH_METHODS = %i[+ - * / % ** & | ^ << >> < > <= >=].to_set.freeze
1696
+ private_constant :COERCE_DISPATCH_METHODS
1697
+
1385
1698
  def argument_type_diagnostic(path, call_node, scope_index)
1386
1699
  return nil if call_node.receiver.nil?
1387
1700
  return nil if UNIVERSAL_EQUALITY_METHODS.include?(call_node.name)
@@ -1405,15 +1718,266 @@ module Rigor
1405
1718
 
1406
1719
  method_def = lookup_method(receiver_type, class_name, call_node.name, scope)
1407
1720
  return nil if method_def.nil? || method_def == true
1408
- return nil unless method_def.method_types.size == 1
1409
1721
 
1410
1722
  param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
1411
- mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope, param_overrides)
1723
+ mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
1412
1724
  return nil if mismatch.nil?
1413
1725
 
1414
1726
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1415
1727
  end
1416
1728
 
1729
+ # Single overload → the exact per-argument acceptance (unchanged).
1730
+ # Multiple overloads → the nil channel (a pure-`nil` argument every
1731
+ # overload rejects) plus, on non-coerce methods, the non-nil channel
1732
+ # (a single-concrete-class argument every overload rejects). See
1733
+ # {#multi_overload_argument_mismatch}.
1734
+ def argument_mismatch(method_types, call_node, scope, param_overrides)
1735
+ if method_types.size == 1
1736
+ first_argument_mismatch(method_types.first, call_node, scope, param_overrides)
1737
+ else
1738
+ multi_overload_argument_mismatch(method_types, call_node, scope, param_overrides)
1739
+ end
1740
+ end
1741
+
1742
+ # Multi-overload argument-type-mismatch. The dispatcher's per-overload
1743
+ # acceptance plumbing is not run here; instead the FP-safe shape mirrors
1744
+ # the "absent on every arm" union-undefined-method rule: an argument is
1745
+ # a mismatch only when EVERY overload's matching positional param
1746
+ # rejects it.
1747
+ #
1748
+ # Two channels, both gated on a positively-refuted argument:
1749
+ # - **nil** (any method): a pure `nil` no overload admits is a
1750
+ # guaranteed `TypeError` — `nil` never coerces.
1751
+ # - **non-nil** (ADR-64, non-coerce methods only): an argument that
1752
+ # types to a single concrete RBS-known class that no overload admits.
1753
+ # Excludes {COERCE_DISPATCH_METHODS} (`5 + Money.new` is valid via
1754
+ # `coerce`), restricts to a single concrete class (WD3 — a union arg
1755
+ # stays deferred), and decides acceptance on the RBS param type
1756
+ # ({#param_accepts_arg_class?}) so it sees through the `int` / `string`
1757
+ # interface-aliases the translator degrades.
1758
+ def multi_overload_argument_mismatch(method_types, call_node, scope, param_overrides)
1759
+ functions = method_types.map(&:type)
1760
+ return nil unless functions.all? { |function| argument_check_eligible?(function) }
1761
+
1762
+ coerce_method = COERCE_DISPATCH_METHODS.include?(call_node.name)
1763
+ arguments = call_node.arguments&.arguments || []
1764
+ arguments.each_with_index do |arg, index|
1765
+ arg_type = scope.type_of(arg)
1766
+ params = overload_positional_params(functions, index)
1767
+ next if params.nil? # arity divergence — some overload lacks a param here
1768
+
1769
+ mismatch =
1770
+ if nil_member?(arg_type) # pure nil only — not a `T | nil` union
1771
+ nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
1772
+ elsif !coerce_method
1773
+ non_nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
1774
+ end
1775
+ return mismatch if mismatch
1776
+ end
1777
+ nil
1778
+ end
1779
+
1780
+ # The nil channel: a pure `nil` argument no overload admits (ADR-58
1781
+ # parity excuses a declaration-sourced ivar nil).
1782
+ def nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
1783
+ return nil if declaration_sourced_nil_argument?(arg, scope)
1784
+ return nil if params.any? { |param| param_admits_nil?(param, param_overrides, scope) }
1785
+
1786
+ { node: arg, name: nil, expected: overload_param_expected_label(params), actual: arg_type }
1787
+ end
1788
+
1789
+ # The non-nil channel (ADR-64 WD2/WD3): a single-concrete-class
1790
+ # argument no overload admits, on a non-coerce method.
1791
+ def non_nil_arg_overload_mismatch(arg, arg_type, params, param_overrides, scope)
1792
+ return nil unless single_concrete_arg_class?(arg_type, scope)
1793
+ return nil if params.any? { |param| param_accepts_arg_class?(param, arg_type, param_overrides, scope) }
1794
+
1795
+ { node: arg, name: nil, expected: overload_param_expected_label(params), actual: arg_type }
1796
+ end
1797
+
1798
+ # The matching positional RBS param across every overload, or nil when
1799
+ # any overload has no param at `index` (arity divergence — the
1800
+ # wrong-arity rule's concern, not this one's).
1801
+ def overload_positional_params(functions, index)
1802
+ params = functions.map { |function| (function.required_positionals + function.optional_positionals)[index] }
1803
+ params.any?(&:nil?) ? nil : params
1804
+ end
1805
+
1806
+ # The class names whose instances `nil` IS — `NilClass` and every
1807
+ # ancestor. A parameter typed as any other class instance rejects nil.
1808
+ NIL_COMPATIBLE_CLASS_NAMES = %w[NilClass Object BasicObject Kernel].to_set.freeze
1809
+ private_constant :NIL_COMPATIBLE_CLASS_NAMES
1810
+
1811
+ # Does this parameter admit a `nil` argument? Decided on the RBS
1812
+ # parameter type (a `rigor:v1:param` override takes precedence).
1813
+ # Conservative throughout: any case we cannot decide returns true
1814
+ # (admits → do not fire), so the rule never fires on uncertainty.
1815
+ def param_admits_nil?(param, param_overrides, scope)
1816
+ override = param_overrides[param.name]
1817
+ return rigor_type_admits_nil?(override) if override
1818
+
1819
+ rbs_type_admits_nil?(param.type, scope)
1820
+ end
1821
+
1822
+ # The `rigor:v1:param` override variant — a refinement
1823
+ # (`non-empty-string`) rejects nil; an explicit nil / nilable union /
1824
+ # gradual override admits it.
1825
+ def rigor_type_admits_nil?(type)
1826
+ return true if type.is_a?(Type::Dynamic) || type.is_a?(Type::Top)
1827
+ return true if nil_member?(type)
1828
+ return union_contains_nil?(type) if type.is_a?(Type::Union)
1829
+
1830
+ false
1831
+ end
1832
+
1833
+ # Walks the RBS parameter type. The load-bearing cases are `Alias`
1834
+ # (`string` = `String | _ToStr`) and `Interface` (`_ToStr`), which
1835
+ # {Inference::RbsTypeTranslator} degrades to `untyped` — the reason a
1836
+ # `nil` argument is invisible after translation (the interface-alias
1837
+ # gap). Resolving them here recovers the rejection. Only a concrete
1838
+ # class instance that is not a `nil` ancestor, and an interface
1839
+ # NilClass does not satisfy, return false; everything else admits.
1840
+ def rbs_type_admits_nil?(rbs_type, scope)
1841
+ case rbs_type
1842
+ when RBS::Types::Union then rbs_type.types.any? { |member| rbs_type_admits_nil?(member, scope) }
1843
+ when RBS::Types::Alias
1844
+ expanded = scope.environment&.rbs_loader&.expand_type_alias(rbs_type)
1845
+ expanded.nil? || rbs_type_admits_nil?(expanded, scope)
1846
+ when RBS::Types::ClassInstance
1847
+ NIL_COMPATIBLE_CLASS_NAMES.include?(rbs_type.name.to_s.delete_prefix("::"))
1848
+ when RBS::Types::Interface then interface_admits_nil?(rbs_type, scope)
1849
+ else true # Optional / bases / variable / tuple / record / proc / literal / intersection → conservative admit
1850
+ end
1851
+ end
1852
+
1853
+ # An interface parameter (`_ToStr`) admits nil only when NilClass
1854
+ # implements every method it requires (`to_str`, `to_int`, … — which
1855
+ # NilClass does not, so `string` / `int` params reject nil; a
1856
+ # hypothetical `_ToS` would admit, since NilClass#to_s exists).
1857
+ # Unresolvable → conservative true.
1858
+ def interface_admits_nil?(rbs_type, scope)
1859
+ loader = scope.environment&.rbs_loader
1860
+ return true if loader.nil?
1861
+
1862
+ methods = loader.interface_method_names(rbs_type.name.to_s)
1863
+ return true if methods.nil? || methods.empty?
1864
+
1865
+ methods.all? { |method_name| nil_class_has_method?(method_name, scope) }
1866
+ end
1867
+
1868
+ # ADR-64 WD3 — the non-nil channel fires only on an argument that types
1869
+ # to a single concrete, RBS-known class. A union arg mirrors the
1870
+ # union-receiver story and stays deferred; a class/module object
1871
+ # (`Singleton`) has a special acceptance surface and is skipped; a
1872
+ # non-RBS project class is skipped because its conversion protocol (a
1873
+ # duck-typed `to_int` / `to_str`) is invisible to us, so we cannot
1874
+ # refute acceptance.
1875
+ def single_concrete_arg_class?(arg_type, scope)
1876
+ return false if arg_type.is_a?(Type::Union)
1877
+ return false if arg_type.is_a?(Type::Singleton)
1878
+
1879
+ class_name = concrete_class_name(arg_type)
1880
+ return false if class_name.nil?
1881
+
1882
+ Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
1883
+ end
1884
+
1885
+ # ADR-64 WD2 — does this parameter accept the (non-nil) argument?
1886
+ # The non-nil generalization of {#param_admits_nil?}: decided on the RBS
1887
+ # parameter type (a `rigor:v1:param` override takes precedence) so it
1888
+ # sees through the `int` / `string` interface-aliases the translator
1889
+ # degrades to gradual. Conservative throughout — any case we cannot
1890
+ # decide returns true (accepts → do not fire).
1891
+ def param_accepts_arg_class?(param, arg_type, param_overrides, scope)
1892
+ override = param_overrides[param.name]
1893
+ return rigor_type_accepts_arg?(override, arg_type) if override
1894
+
1895
+ rbs_type_accepts_arg?(param.type, arg_type, scope)
1896
+ end
1897
+
1898
+ # The `rigor:v1:param` override variant — a Rigor `Type`, so the
1899
+ # acceptance engine decides directly (gradual; only a proven rejection
1900
+ # refutes). Dynamic / Top admit unconditionally.
1901
+ def rigor_type_accepts_arg?(param_type, arg_type)
1902
+ return true if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
1903
+
1904
+ !Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
1905
+ end
1906
+
1907
+ # Walks the RBS parameter type, mirroring {#rbs_type_admits_nil?}. The
1908
+ # load-bearing cases are `Alias` / `Interface` (`int` = `Integer |
1909
+ # _ToInt`), which the translator degrades to gradual — resolving them
1910
+ # here recovers the rejection. A faithfully-translated `ClassInstance`
1911
+ # is handed to the acceptance engine; everything undecidable admits.
1912
+ def rbs_type_accepts_arg?(rbs_type, arg_type, scope)
1913
+ case rbs_type
1914
+ when RBS::Types::Union then rbs_type.types.any? { |member| rbs_type_accepts_arg?(member, arg_type, scope) }
1915
+ when RBS::Types::Alias
1916
+ expanded = scope.environment&.rbs_loader&.expand_type_alias(rbs_type)
1917
+ expanded.nil? || rbs_type_accepts_arg?(expanded, arg_type, scope)
1918
+ when RBS::Types::ClassInstance then class_instance_accepts_arg?(rbs_type, arg_type, scope)
1919
+ when RBS::Types::Interface then interface_accepts_arg?(rbs_type, arg_type, scope)
1920
+ else true # bases / variable / tuple / record / proc / literal / intersection / optional → conservative admit
1921
+ end
1922
+ end
1923
+
1924
+ # A `ClassInstance` param (`Integer`, `Numeric`, …) is translated
1925
+ # faithfully (no interface degradation), so the acceptance engine — the
1926
+ # canonical RBS-ancestry / generic-aware subtype check — decides it.
1927
+ # Only a proven rejection refutes; an unresolvable class is `:maybe`,
1928
+ # which admits.
1929
+ def class_instance_accepts_arg?(rbs_type, arg_type, scope)
1930
+ translated = translate_param_type(rbs_type, scope.environment)
1931
+ return true if translated.is_a?(Type::Dynamic) || translated.is_a?(Type::Top)
1932
+
1933
+ !Inference::Acceptance.accepts(translated, arg_type, mode: :gradual).no?
1934
+ end
1935
+
1936
+ # An interface param (`_ToInt`) accepts the arg only when the arg's
1937
+ # class implements every method it requires (`to_int`, …). The non-nil
1938
+ # mirror of {#interface_admits_nil?}: ask the arg class, not NilClass.
1939
+ # Unresolvable anywhere → conservative true (admit).
1940
+ def interface_accepts_arg?(rbs_type, arg_type, scope)
1941
+ loader = scope.environment&.rbs_loader
1942
+ return true if loader.nil?
1943
+
1944
+ methods = loader.interface_method_names(rbs_type.name.to_s)
1945
+ return true if methods.nil? || methods.empty?
1946
+
1947
+ class_name = concrete_class_name(arg_type)
1948
+ return true if class_name.nil?
1949
+
1950
+ methods.all? { |method_name| arg_class_has_method?(class_name, method_name, scope) }
1951
+ end
1952
+
1953
+ # The non-nil mirror of {#nil_class_has_method?}, but conservative on
1954
+ # the unknown side: an unresolvable definition returns true (the class
1955
+ # *might* implement the method — e.g. a metaprogrammed conversion), so
1956
+ # the channel never fires on uncertainty.
1957
+ def arg_class_has_method?(class_name, method_name, scope)
1958
+ definition = Rigor::Reflection.instance_definition(class_name, scope: scope)
1959
+ return true if definition.nil?
1960
+
1961
+ !definition.methods[method_name.to_sym].nil?
1962
+ end
1963
+
1964
+ # A readable "expected" label for a multi-overload mismatch — the RBS
1965
+ # parameter type(s) as written (`string`, or the per-overload set), since
1966
+ # the translated Rigor type degrades the interface-alias the rejection
1967
+ # hinges on. Shared by the nil and non-nil channels.
1968
+ def overload_param_expected_label(params)
1969
+ params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
1970
+ end
1971
+
1972
+ # ADR-58 parity for the nil channel: a declaration-sourced ivar read
1973
+ # that types as nil is the same not-diagnostic-fuel case the union
1974
+ # path gates in {#declaration_sourced_nil_only_mismatch?}; suppress it
1975
+ # here too so a ctor-seeded `@x = nil` read passed as an argument does
1976
+ # not fire on a working program's cross-method invariant.
1977
+ def declaration_sourced_nil_argument?(arg, scope)
1978
+ arg.is_a?(Prism::InstanceVariableReadNode) && scope.declaration_sourced?(:ivar, arg.name)
1979
+ end
1980
+
1417
1981
  def first_argument_mismatch(method_type, call_node, scope, param_overrides)
1418
1982
  function = method_type.type
1419
1983
  return nil unless argument_check_eligible?(function)
@@ -1424,21 +1988,71 @@ module Rigor
1424
1988
  param = params[index]
1425
1989
  next if param.nil? # arity mismatch is the wrong-arity rule's concern.
1426
1990
 
1427
- # `rigor:v1:param: <name> <refinement>` annotations
1428
- # tighten the RBS-declared parameter type. The
1429
- # override is the authoritative contract when
1430
- # present; otherwise we translate the RBS type as
1431
- # before.
1432
- param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
1433
- next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
1991
+ mismatch = single_argument_mismatch(param, arg, scope, param_overrides)
1992
+ return mismatch if mismatch
1993
+ end
1994
+ nil
1995
+ end
1434
1996
 
1435
- arg_type = scope.type_of(arg)
1436
- next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
1997
+ # The mismatch (or nil) for one positional argument against one
1998
+ # parameter. The nil channel decides a pure `nil` argument on the RBS
1999
+ # parameter type — seeing through the `string` / `int` interface-alias
2000
+ # the translator degrades to gradual (so `"a" + nil` fires), excusing a
2001
+ # declaration-sourced ivar nil (ADR-58 parity). The non-nil channel is
2002
+ # the original translated-acceptance check, with a `rigor:v1:param`
2003
+ # override taking precedence over the RBS-declared type.
2004
+ def single_argument_mismatch(param, arg, scope, param_overrides)
2005
+ arg_type = scope.type_of(arg)
2006
+
2007
+ if nil_member?(arg_type)
2008
+ return nil if declaration_sourced_nil_argument?(arg, scope)
2009
+ return nil if param_admits_nil?(param, param_overrides, scope)
1437
2010
 
1438
- result = Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual)
1439
- return { node: arg, name: param.name, expected: param_type, actual: arg_type } if result.no?
2011
+ return { node: arg, name: param.name, expected: overload_param_expected_label([param]), actual: arg_type }
1440
2012
  end
1441
- nil
2013
+
2014
+ param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
2015
+ return nil if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
2016
+ return nil if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
2017
+ return nil unless argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
2018
+
2019
+ { node: arg, name: param.name, expected: param_type, actual: arg_type }
2020
+ end
2021
+
2022
+ # The parameter rejects the argument AND the rejection is not a
2023
+ # withheld declaration-sourced-nil case.
2024
+ def argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
2025
+ return false unless Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
2026
+
2027
+ # ADR-58 (N2 extension) — the same declaration-sourced-nil-is-not-
2028
+ # diagnostic-fuel criterion that governs `possible-nil-receiver`
2029
+ # applies here. When the only reason the argument is rejected is a
2030
+ # *declaration-sourced* nil constituent (the class-ivar index seed
2031
+ # of a ctor `@x = nil` / a non-definitely-assigned ivar read), and
2032
+ # the argument type with that nil removed WOULD be accepted, the
2033
+ # working program's cross-method invariant is assumed and we do not
2034
+ # fire. Flow-live nil (a method-local `@x = nil` write, a failed-
2035
+ # guard narrowing) drops the provenance mark upstream and still
2036
+ # fires. The argument's type is unchanged — only the firing
2037
+ # decision is gated.
2038
+ !declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2039
+ end
2040
+
2041
+ # True when `arg` is a declaration-sourced ivar read whose rejection is
2042
+ # caused solely by its nil constituent: stripping nil from the argument
2043
+ # type yields a type the parameter accepts (gradual mode). Mirrors the
2044
+ # `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
2045
+ # rather than a local copy.
2046
+ def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2047
+ return false unless arg.is_a?(Prism::InstanceVariableReadNode)
2048
+ return false unless scope.declaration_sourced?(:ivar, arg.name)
2049
+ return false unless arg_type.is_a?(Type::Union)
2050
+ return false unless union_contains_nil?(arg_type)
2051
+
2052
+ non_nil = Type::Combinator.union(*arg_type.members.reject { |m| nil_member?(m) })
2053
+ return false if non_nil.is_a?(Type::Bot)
2054
+
2055
+ Inference::Acceptance.accepts(param_type, non_nil, mode: :gradual).yes?
1442
2056
  end
1443
2057
 
1444
2058
  def argument_check_eligible?(function)
@@ -1463,15 +2077,19 @@ module Rigor
1463
2077
  def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1464
2078
  method_label = "`#{call_node.name}' on #{class_name}"
1465
2079
  parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
2080
+ expected = mismatch[:expected]
2081
+ expected_description = expected.is_a?(String) ? expected : expected.describe(:short)
1466
2082
  message = "argument type mismatch at #{parameter_label}: " \
1467
- "expected #{mismatch[:expected].describe(:short)}, " \
2083
+ "expected #{expected_description}, " \
1468
2084
  "got #{mismatch[:actual].describe(:short)}"
1469
2085
  Diagnostic.from_node(
1470
2086
  mismatch[:node],
1471
2087
  rule: RULE_ARGUMENT_TYPE,
1472
2088
  path: path,
1473
2089
  message: message,
1474
- severity: :error
2090
+ severity: :error,
2091
+ receiver_type: class_name,
2092
+ method_name: call_node.name.to_s
1475
2093
  )
1476
2094
  end
1477
2095
 
@@ -1484,7 +2102,9 @@ module Rigor
1484
2102
  rule: RULE_WRONG_ARITY,
1485
2103
  path: path,
1486
2104
  message: message,
1487
- severity: :error
2105
+ severity: :error,
2106
+ receiver_type: class_name,
2107
+ method_name: call_node.name.to_s
1488
2108
  )
1489
2109
  end
1490
2110
 
@@ -1657,7 +2277,8 @@ module Rigor
1657
2277
  path: path,
1658
2278
  message: "return-type mismatch on `#{def_node.name}': " \
1659
2279
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1660
- severity: severity
2280
+ severity: severity,
2281
+ method_name: def_node.name.to_s
1661
2282
  )
1662
2283
  end
1663
2284
 
@@ -1809,7 +2430,8 @@ module Rigor
1809
2430
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1810
2431
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1811
2432
  "breaks substitutability",
1812
- severity: :warning
2433
+ severity: :warning,
2434
+ method_name: def_node.name.to_s
1813
2435
  )
1814
2436
  end
1815
2437
 
@@ -1833,7 +2455,14 @@ module Rigor
1833
2455
  # everything, so they stay silent (FP-safe). `self`/`instance`
1834
2456
  # are translated with `self_type: nil` on both sides, so a
1835
2457
  # parent `-> self` and an override `-> self` never fire.
1836
- def override_return_widened_diagnostic(path, def_node, scope_index)
2458
+ # The authored-override resolution shared by the Liskov override
2459
+ # rules (`def.override-return-widened` and
2460
+ # `def.override-param-narrowed`): the def must be an instance method
2461
+ # whose own class declares it in RBS, and a project-discovered
2462
+ # ancestor must also declare it. Returns
2463
+ # `[scope, override_method, parent_class, parent_method]`, or nil
2464
+ # (the rule does not fire) when any gate is unmet.
2465
+ def resolve_authored_override(def_node, scope_index)
1837
2466
  return nil unless def_node.receiver.nil? # instance methods only (singleton: follow-on)
1838
2467
 
1839
2468
  scope = scope_index[def_node]
@@ -1853,6 +2482,14 @@ module Rigor
1853
2482
  return nil if parent.nil?
1854
2483
 
1855
2484
  parent_class, parent_method = parent
2485
+ [scope, override_method, parent_class, parent_method]
2486
+ end
2487
+
2488
+ def override_return_widened_diagnostic(path, def_node, scope_index)
2489
+ resolved = resolve_authored_override(def_node, scope_index)
2490
+ return nil if resolved.nil?
2491
+
2492
+ scope, override_method, parent_class, parent_method = resolved
1856
2493
  override_return = declared_return_union(override_method, scope.environment)
1857
2494
  parent_return = declared_return_union(parent_method, scope.environment)
1858
2495
  return nil if override_return.nil? || parent_return.nil?
@@ -1902,7 +2539,8 @@ module Rigor
1902
2539
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1903
2540
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1904
2541
  "breaks substitutability",
1905
- severity: :warning
2542
+ severity: :warning,
2543
+ method_name: def_node.name.to_s
1906
2544
  )
1907
2545
  end
1908
2546
 
@@ -1924,25 +2562,10 @@ module Rigor
1924
2562
  # overload-arm ambiguity, both sides must have exactly one
1925
2563
  # method type.
1926
2564
  def override_param_narrowed_diagnostic(path, def_node, scope_index)
1927
- return nil unless def_node.receiver.nil? # instance methods only
1928
-
1929
- scope = scope_index[def_node]
1930
- return nil if scope.nil?
1931
-
1932
- self_type = scope.self_type
1933
- return nil unless self_type.respond_to?(:class_name)
1934
-
1935
- class_name = self_type.class_name.to_s
1936
- method_name = def_node.name
2565
+ resolved = resolve_authored_override(def_node, scope_index)
2566
+ return nil if resolved.nil?
1937
2567
 
1938
- override_method = safe_instance_method_definition(class_name, method_name, scope)
1939
- return nil if override_method.nil?
1940
- return nil unless defined_on?(override_method, class_name)
1941
-
1942
- parent = nearest_ancestor_method_def(scope, class_name, method_name)
1943
- return nil if parent.nil?
1944
-
1945
- parent_class, parent_method = parent
2568
+ _scope, override_method, parent_class, parent_method = resolved
1946
2569
  override_params = positional_param_types(override_method)
1947
2570
  parent_params = positional_param_types(parent_method)
1948
2571
  return nil if override_params.nil? || parent_params.nil?
@@ -2006,7 +2629,8 @@ module Rigor
2006
2629
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
2007
2630
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
2008
2631
  "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
2009
- severity: :warning
2632
+ severity: :warning,
2633
+ method_name: def_node.name.to_s
2010
2634
  )
2011
2635
  end
2012
2636
  end