rigortype 0.4.4 → 0.4.6

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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "../../source/node_children"
6
6
  require_relative "../../inference/narrowing"
7
7
  require_relative "inferred_param_guard"
8
+ require_relative "clause_coverage"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -36,6 +37,10 @@ module Rigor
36
37
  # is code evidence that the subject can hold such an object (`io = STDOUT; case io when StringIO`), and Rigor's
37
38
  # reading of the subject's type is what the evidence contradicts. The body scope stays `Bot`, so no call on the
38
39
  # subject in it is checked. A subject that is a literal carrier (`x = 1; case x when String`) is still reported.
40
+ #
41
+ # Issue #1485 — the same holds for a `:prior_exhaustion` clause: on a subject that is not a literal carrier it is
42
+ # reported only when every class it names is at or below a class an earlier clause names ({ClauseCoverage}), so
43
+ # `io = STDOUT; case io when IO … when StringIO` is quiet and `when IO … when File` still reports.
39
44
  class UnreachableClauseCollector
40
45
  LOOP_OR_BLOCK_NODE_CLASSES = [
41
46
  Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
@@ -48,6 +53,24 @@ module Rigor
48
53
  # `else`-only.
49
54
  DEFENSIVE_EXIT_CALLS = %i[raise fail throw abort exit].freeze
50
55
 
56
+ # Issue #2071 — core classes whose idiomatic stand-ins are not their subclasses: a `StringIO` answers the
57
+ # IO protocol without being an `IO`, and a `Tempfile` delegates to a `File` without being one (or an `IO`).
58
+ # A subject Rigor types as one of these classes (`$stdout` by idiom, ADR-117) may hold such a stand-in,
59
+ # which no `when IO` / `in File` matches, so the `else` still runs for it.
60
+ DUCK_STAND_INS = {
61
+ "IO" => %w[StringIO Tempfile].freeze,
62
+ "File" => %w[Tempfile].freeze
63
+ }.freeze
64
+
65
+ # Each stand-in's ancestors as Ruby 4.0.5 has them (`StringIO.ancestors`, `Tempfile.ancestors` without the
66
+ # anonymous `DelegateClass` entries): a clause naming one matches the stand-in. Written out rather than read
67
+ # from RBS, which declares `class Tempfile < File`; a `Tempfile` is a `Delegator`, so neither `Object` nor
68
+ # `Kernel` matches it.
69
+ STAND_IN_ANCESTORS = {
70
+ "StringIO" => %w[StringIO Enumerable Object Kernel BasicObject].freeze,
71
+ "Tempfile" => %w[Tempfile Delegator BasicObject].freeze
72
+ }.freeze
73
+
51
74
  # @!attribute kind
52
75
  # @return `:disjoint` (this clause's type is disjoint
53
76
  # from the still-possible subject), `:prior_exhaustion` (an
@@ -124,21 +147,56 @@ module Rigor
124
147
  return if entry_type.nil? || entry_type.is_a?(Type::Bot) || entry_type.is_a?(Type::Dynamic)
125
148
 
126
149
  keyword = node.is_a?(Prism::CaseMatchNode) ? "in" : "when"
150
+ coverage = ClauseCoverage.new(scope, entry_type)
127
151
  node.conditions.each do |clause|
128
152
  case clause
129
- when Prism::WhenNode then collect_when(clause, subject.name)
130
- when Prism::InNode then collect_in(clause, subject.name)
153
+ when Prism::WhenNode then collect_when(clause, subject.name, coverage)
154
+ when Prism::InNode then collect_in(clause, subject.name, coverage)
131
155
  end
156
+ coverage.record(clause)
132
157
  end
158
+ return if stand_in_reaches_else?(node, entry_type)
159
+
133
160
  collect_else(node.else_clause, subject.name, keyword)
134
161
  end
135
162
 
163
+ # Issue #2071 — whether the subject, as the `case` binds it, has a `Nominal` member of a {DUCK_STAND_INS}
164
+ # class with a stand-in no clause names an ancestor of ({STAND_IN_ANCESTORS}). `io = STDOUT; case io when IO
165
+ # then … else … end` runs the `else` for a `StringIO`, which the type `IO` stands for in idiomatic Ruby; a
166
+ # literal subject is a `Constant`, not a `Nominal`, and keeps its verdict. Asked of the `else` only: a dead
167
+ # `when` has its own evidence rules (#1429, #1485).
168
+ def stand_in_reaches_else?(node, entry_type)
169
+ members = entry_type.is_a?(Type::Union) ? entry_type.members : [entry_type]
170
+ stand_ins = members.flat_map do |member|
171
+ member.is_a?(Type::Nominal) ? DUCK_STAND_INS.fetch(member.class_name.delete_prefix("::"), []) : []
172
+ end
173
+ return false if stand_ins.empty?
174
+
175
+ named = clause_class_names(node)
176
+ stand_ins.any? { |stand_in| named.none? { |name| STAND_IN_ANCESTORS.fetch(stand_in).include?(name) } }
177
+ end
178
+
179
+ # The classes the clauses name as bare constants (`when A, B`, `in A`, `in A => x`).
180
+ def clause_class_names(node)
181
+ node.conditions.flat_map do |clause|
182
+ patterns = clause.is_a?(Prism::WhenNode) ? clause.conditions : [bare_pattern(clause.pattern)]
183
+ patterns.filter_map do |pattern|
184
+ entry = @scope_index[pattern]
185
+ entry && Inference::Narrowing.lexical_class_name(pattern, entry)
186
+ end
187
+ end
188
+ end
189
+
190
+ def bare_pattern(pattern)
191
+ pattern.is_a?(Prism::CapturePatternNode) ? pattern.value : pattern
192
+ end
193
+
136
194
  def entry_subject_type(case_node, subject_name)
137
195
  scope = @scope_index[case_node]
138
196
  scope&.local(subject_name)
139
197
  end
140
198
 
141
- def collect_when(clause, subject_name)
199
+ def collect_when(clause, subject_name, coverage)
142
200
  return if clause.statements.nil? # empty body → no useful location
143
201
  return unless all_constant_conditions?(clause)
144
202
 
@@ -149,6 +207,7 @@ module Rigor
149
207
  kind = when_clause_kind(clause, subject_name)
150
208
  return if kind == :disjoint &&
151
209
  disjoint_nominal_subject?(@scope_index[clause.conditions.first], subject_name, clause.conditions)
210
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
152
211
 
153
212
  @results << Result.new(
154
213
  clause: clause, body: clause.statements, subject_name: subject_name,
@@ -187,7 +246,7 @@ module Rigor
187
246
  # (prior-exhaustion — true for ANY pattern downstream of a covering set). Non-bare patterns are not
188
247
  # narrowed, so their body is `bot` ONLY under prior-exhaustion; they never produce a spurious
189
248
  # `:disjoint`.
190
- def collect_in(clause, subject_name)
249
+ def collect_in(clause, subject_name, coverage)
191
250
  return if clause.statements.nil?
192
251
 
193
252
  scope = @scope_index[clause]
@@ -196,6 +255,7 @@ module Rigor
196
255
 
197
256
  kind = in_clause_kind(clause, subject_name)
198
257
  return if kind == :disjoint && disjoint_nominal_pattern?(clause, subject_name)
258
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
199
259
 
200
260
  @results << Result.new(
201
261
  clause: clause, body: clause.statements, subject_name: subject_name,
@@ -29,6 +29,8 @@ require_relative "check_rules/void_value_use_collector"
29
29
  require_relative "check_rules/self_closedness_scanner"
30
30
  require_relative "check_rules/source_arity"
31
31
  require_relative "check_rules/lexical_method_sites"
32
+ require_relative "check_rules/rebinding_block_callees"
33
+ require_relative "check_rules/rbs_receiver_surface"
32
34
  require_relative "check_rules/special_global_setters"
33
35
  require_relative "check_rules/ruby_deprecations"
34
36
 
@@ -449,11 +451,31 @@ module Rigor
449
451
  other_class = ivar_class_for(write[:type])
450
452
  next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
451
453
  next nil if declaration_admits?(write[:declared], canonical[:type], write[:type])
454
+ next nil if separate_invocations?(write, other_class, writes)
452
455
 
453
456
  build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class)
454
457
  end
455
458
  end
456
459
 
460
+ # ADR-125 — a divergent write is never live together with the writes it conflicts with when the class
461
+ # serves one entry point per instance (a plugin-declared per-invocation class, e.g. a Rails controller:
462
+ # one action per request) and every conflicting write — non-nil, of a concrete class other than this
463
+ # write's — sits in a DIFFERENT action of the same class. An action is a public method no declared
464
+ # non-action macro names (`before_action :x`, `helper_method :y`); {IvarWriteCollector} decides it. A
465
+ # conflicting write in a private or protected helper, a callback target, a `helper_method`,
466
+ # `initialize`, or the same method keeps the finding. Which helper each action reaches is not proved
467
+ # here.
468
+ def separate_invocations?(write, write_class, writes)
469
+ return false unless write[:action]
470
+
471
+ writes.all? do |other|
472
+ other_class = ivar_class_for(other[:type])
473
+ next true if other_class.nil? || other_class == "NilClass" || other_class == write_class
474
+
475
+ other[:action] && other[:method_name] != write[:method_name]
476
+ end
477
+ end
478
+
457
479
  def declaration_admits?(declared, *write_types)
458
480
  return false if declared.nil?
459
481
 
@@ -801,7 +823,7 @@ module Rigor
801
823
  # rule must not surface a false `undefined-method`
802
824
  # for them.
803
825
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
804
- return nil if source_declared_method?(scope, class_name, call_node.name, kind)
826
+ return nil if source_declared_method?(scope, class_name, call_node.name, kind, call_node: call_node)
805
827
 
806
828
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
807
829
 
@@ -817,6 +839,8 @@ module Rigor
817
839
  return nil if method_def
818
840
 
819
841
  return nil if last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites)
842
+ return nil if subclass_instance_may_answer?(rendered_receiver, class_name, call_node.name, scope,
843
+ call_node.receiver)
820
844
 
821
845
  definition_site = project_definition_site(scope, class_name, call_node.name, kind)
822
846
  # Issue #735 — the site is evidence, and whose RBS this is decides what it is evidence OF. On a
@@ -951,12 +975,52 @@ module Rigor
951
975
  # (see {#ancestry_declares_method?} for why that placement is load-bearing, not tidiness).
952
976
  def last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites = nil)
953
977
  return true if lexically_defined_method?(class_name, call_node, scope, kind, lexical_sites)
978
+ return true if project_patched_through_ancestors?(scope, class_name, call_node.name, kind,
979
+ call_node: call_node)
954
980
  return true if module_mixin_receiver?(receiver_type, scope)
955
981
  return true if mixin_self_class_receiver?(call_node, scope)
956
982
  return true if unknown_mixin_includer?(class_name, scope)
957
983
  return true if object_mixin_may_define?(receiver_type, class_name, call_node.name, scope)
984
+ return true if ancestry_declares_method?(scope, class_name, call_node.name, kind)
985
+
986
+ rbs_ancestor_may_answer?(class_name, call_node.name, scope, kind)
987
+ end
988
+
989
+ # Issue #1722 — a `method_missing`, or a project mixin, on one of the class's RBS ancestors
990
+ # ({RbsReceiverSurface}). Last for {#ancestry_declares_method?}'s reason: it walks the class graph.
991
+ def rbs_ancestor_may_answer?(class_name, method_name, scope, kind)
992
+ if kind == :singleton
993
+ RbsReceiverSurface.singleton_hook?(scope, class_name) ||
994
+ RbsReceiverSurface.singleton_mixin_may_answer?(scope, class_name, method_name)
995
+ else
996
+ RbsReceiverSurface.ancestor_may_answer?(scope, class_name, method_name)
997
+ end
998
+ end
958
999
 
959
- ancestry_declares_method?(scope, class_name, call_node.name, kind)
1000
+ # Issue #1722 — a receiver typed by its class's name, rather than by a literal, may hold an instance of a
1001
+ # subclass (a `#: () -> String` signature admits a `MyStr < String`), so a method a project subclass
1002
+ # defines, or a subclass's `method_missing`, may be the one that runs. A value the engine built from a
1003
+ # literal is exactly its class and keeps firing: a literal type (`"a"`, `1`, a union of them), a shape
1004
+ # carrier (`{a: 1}`, `[1, 2]`, `{}`, `[]`, and what folds to one), or a receiver written as a literal
1005
+ # (`"a#{x}b"`, which types as plain `String`). A local holding an interpolated String types as plain
1006
+ # `String` with no origin, so it still declines. Instance side only.
1007
+ def subclass_instance_may_answer?(receiver_type, class_name, method_name, scope, receiver_node = nil)
1008
+ return false if receiver_type.is_a?(Type::Singleton)
1009
+ return false if exact_literal_receiver?(receiver_type) || LITERAL_RECEIVER_NODES.include?(receiver_node.class)
1010
+
1011
+ RbsReceiverSurface.subclass_may_define?(scope, class_name, method_name)
1012
+ end
1013
+
1014
+ EXACT_TYPES = [Type::Constant, Type::Tuple, Type::HashShape].freeze
1015
+ LITERAL_RECEIVER_NODES = [
1016
+ Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode, Prism::InterpolatedXStringNode,
1017
+ Prism::ArrayNode, Prism::HashNode, Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode
1018
+ ].to_set.freeze
1019
+ private_constant :EXACT_TYPES, :LITERAL_RECEIVER_NODES
1020
+
1021
+ def exact_literal_receiver?(receiver_type)
1022
+ members = receiver_type.is_a?(Type::Union) ? receiver_type.members : [receiver_type]
1023
+ members.all? { |member| EXACT_TYPES.any? { |kind| member.is_a?(kind) } }
960
1024
  end
961
1025
 
962
1026
  # Issue #1697 — a module the project mixes into `Object` (a top-level `include M`, or `include M` in
@@ -1058,10 +1122,13 @@ module Rigor
1058
1122
  # Both paths sit at the same dispatcher precedence; the
1059
1123
  # check must hold them together so neither rule fires a
1060
1124
  # false positive.
1061
- def source_declared_method?(scope, class_name, method_name, kind)
1125
+ #
1126
+ # `call_node:` (issue #1708), when given, is the call asking: a private or protected `pre_eval:` patch
1127
+ # it may not reach does not count ({#patch_callable?}).
1128
+ def source_declared_method?(scope, class_name, method_name, kind, call_node: nil)
1062
1129
  return true if scope.discovered_method?(class_name, method_name, kind)
1063
1130
 
1064
- project_patched_method?(scope, class_name, method_name, kind)
1131
+ project_patched_method?(scope, class_name, method_name, kind, call_node: call_node)
1065
1132
  end
1066
1133
 
1067
1134
  # Issue #723 — the same question as {#source_declared_method?}, asked through the project's own
@@ -1087,12 +1154,42 @@ module Rigor
1087
1154
  # inference (between plugins and dependency-source).
1088
1155
  # Returns false when the environment carries no registry
1089
1156
  # (legacy path) or the lookup misses.
1090
- def project_patched_method?(scope, class_name, method_name, kind)
1157
+ def project_patched_method?(scope, class_name, method_name, kind, call_node: nil)
1158
+ environment = scope.environment
1159
+ registry = environment&.project_patched_methods
1160
+ return false if registry.nil? || registry.empty?
1161
+
1162
+ entry = registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind)
1163
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1164
+ end
1165
+
1166
+ # Issue #1708 — {#project_patched_method?} asked through the receiver's ancestors: a `pre_eval:` patch on
1167
+ # `Object`, `Numeric` or `Enumerable` gives an `Integer` or `Array` the method. Asked only once the
1168
+ # receiver's RBS has come back without the method, so a nearer class's own declaration still decides arity
1169
+ # and argument types; {#source_declared_method?} stays exact for the rules that read it for those.
1170
+ #
1171
+ # `call_node:` as {#source_declared_method?} reads it.
1172
+ def project_patched_through_ancestors?(scope, class_name, method_name, kind, call_node: nil)
1091
1173
  environment = scope.environment
1092
1174
  registry = environment&.project_patched_methods
1093
1175
  return false if registry.nil? || registry.empty?
1094
1176
 
1095
- !registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind).nil?
1177
+ entry = registry.lookup_through_ancestors(class_name: class_name.to_s, method_name: method_name.to_sym,
1178
+ kind: kind, rbs_loader: environment.rbs_loader)
1179
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1180
+ end
1181
+
1182
+ # Issue #1708 — whether `call_node` may reach the patch `entry`: Ruby raises NoMethodError for a private
1183
+ # patch called with an explicit receiver other than `self` (`1.kp` on `module Kernel; private def kp`), and
1184
+ # for a protected one called from outside its owner's instances. No call node, and a declined verdict (another
1185
+ # definer the call may reach, {Inference::ProjectPatchedMethods#callable?}), are the permissive answer.
1186
+ def patch_callable?(registry, entry, scope, call_node)
1187
+ return true if call_node.nil?
1188
+
1189
+ receiver = call_node.receiver
1190
+ verdict = registry.callable?(entry, explicit: !receiver.nil? && !receiver.is_a?(Prism::SelfNode),
1191
+ self_type: scope.self_type, rbs_loader: scope.environment.rbs_loader)
1192
+ verdict != false
1096
1193
  end
1097
1194
 
1098
1195
  # ADR-34 — `call.unresolved-toplevel`. Fires on an
@@ -1141,6 +1238,8 @@ module Rigor
1141
1238
  return nil if scope.top_level_def_for(name)
1142
1239
  return nil if source_declared_method?(scope, "Object", name, :instance)
1143
1240
  return nil if Rigor::Reflection.instance_method_definition("Object", name, scope: scope)
1241
+ # Issue #1708 — `main` is an Object, so a `pre_eval:` patch on `Kernel` or `BasicObject` defines the name too.
1242
+ return nil if project_patched_through_ancestors?(scope, "Object", name, :instance)
1144
1243
  return nil if main_singleton_method?(name, scope)
1145
1244
  return nil if Inference::ObjectMixins.may_define?(scope, name, receiver: :implicit)
1146
1245
  # `Target.class_eval { def added = 1; def use_added = added }` files the defs on Target
@@ -1148,12 +1247,22 @@ module Rigor
1148
1247
  # body is morally a class body, so ADR-34 stays silent there — including on a genuinely
1149
1248
  # undefined name inside the block. Ranges are computed once per file.
1150
1249
  return nil if call_inside_receiver_eval_ranges?(eval_ranges, call_node)
1151
- # ADR-121 WD7 — a refinement of `Object` (or one Rigor cannot read) in effect here may define the name.
1152
- return nil if refined_method_in_effect?("Object", call_node, scope, :instance, lexical_sites)
1250
+ return nil if lexically_declined_toplevel_call?(call_node, scope, lexical_sites, scope_index)
1153
1251
 
1154
1252
  build_unresolved_toplevel_diagnostic(path, call_node)
1155
1253
  end
1156
1254
 
1255
+ # The `call.unresolved-toplevel` declines that read the file's syntax, asked last because they walk it.
1256
+ def lexically_declined_toplevel_call?(call_node, scope, lexical_sites, scope_index = nil)
1257
+ # ADR-121 WD7 — a refinement of `Object` (or one Rigor cannot read) in effect here may define the name.
1258
+ return true if refined_method_in_effect?("Object", call_node, scope, :instance, lexical_sites)
1259
+ return false if lexical_sites.nil?
1260
+
1261
+ # Issue #1817 — a block handed to a project method that may `module_eval` / `instance_exec` it runs under
1262
+ # another `self`, so a call in it is not a top-level one ({RebindingBlockCallees}).
1263
+ RebindingBlockCallees.inside_rebound_block?(call_node, scope, lexical_sites.block_calls, scope_index)
1264
+ end
1265
+
1157
1266
  # Issue #1383 — RBS core declares `main`'s private singleton methods on
1158
1267
  # `RBS::Unnamed::TopLevelSelfClass`, so the set follows RBS rather than a hand-kept
1159
1268
  # list. CRuby 4.0's `main` also has `ruby2_keywords`, which RBS does not declare.
@@ -1410,12 +1519,27 @@ module Rigor
1410
1519
  # overlay open receiver ({GEM_OVERLAY_OPEN_RECEIVERS}) whose
1411
1520
  # bundled directory this run actually loaded, a core-overlay
1412
1521
  # open receiver ({CORE_OVERLAY_OPEN_RECEIVERS}), or a
1413
- # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`).
1522
+ # Rigor-synthesized stub type (see `RbsLoader#synthesized_type_names`),
1523
+ # or a project-only reopen of a Ruby library class whose own RBS
1524
+ # did not load ({#ruby_library_reopen?}).
1414
1525
  def unbounded_receiver_surface?(class_name, scope)
1415
1526
  bare_name = class_name.to_s.sub(/\A::/, "")
1416
1527
  open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope) ||
1417
1528
  CORE_OVERLAY_OPEN_RECEIVERS.include?(bare_name) ||
1418
- (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope))
1529
+ (GEM_OVERLAY_OPEN_RECEIVERS.include?(bare_name) && gem_overlay_loaded?(scope)) ||
1530
+ ruby_library_reopen?(class_name, scope)
1531
+ end
1532
+
1533
+ # Issue #2174 — `class Matrix` reopened with one `#:`-annotated method, and no `matrix` signatures
1534
+ # loaded, gives Matrix a declaration holding only that method ({RbsLoader#ruby_library_reopen?}).
1535
+ # `Matrix.identity(2)` is correct Ruby, so the class's surface is treated as open: `call.undefined-method`
1536
+ # declines on it, and a signature it only inherits (`Object#initialize` for `Matrix.new(rows)`) stops
1537
+ # deciding arity and argument types. Methods the project's own declaration lists stay checked.
1538
+ def ruby_library_reopen?(class_name, scope)
1539
+ loader = scope.environment&.rbs_loader
1540
+ return false if loader.nil? || !loader.respond_to?(:ruby_library_reopen?)
1541
+
1542
+ loader.ruby_library_reopen?(class_name)
1419
1543
  end
1420
1544
 
1421
1545
  def open_receiver?(class_name, scope)
@@ -2143,6 +2267,7 @@ module Rigor
2143
2267
  return true unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
2144
2268
  return true unless definition_available?(member, class_name, scope)
2145
2269
  return true if lookup_method(member, class_name, method_name, scope)
2270
+ return true if project_patched_through_ancestors?(scope, class_name, method_name, :instance)
2146
2271
 
2147
2272
  # Issue #723 — the arm's own RBS does not have it; its project ancestry still might. Last, for
2148
2273
  # the reason {#ancestry_declares_method?} carries: this is the probe that walks the class graph.
@@ -2196,12 +2321,30 @@ module Rigor
2196
2321
  # `compose_codepoints` typed `Hash | Hash` for an `Array`, flagging
2197
2322
  # `.pack`). Require at least two distinct arm classes.
2198
2323
  return nil if members.map { |member| concrete_class_name(member) }.uniq.size < 2
2199
- return nil if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
2200
- return nil if refined_on_some_arm?(members, call_node, scope, lexical_sites)
2324
+ return nil if union_may_answer?(members, call_node, scope, lexical_sites)
2201
2325
 
2202
2326
  build_undefined_method_diagnostic(path, call_node, receiver_type)
2203
2327
  end
2204
2328
 
2329
+ # Whether some arm of a non-nil union of classes may answer the call, which keeps the union rule silent.
2330
+ def union_may_answer?(members, call_node, scope, lexical_sites)
2331
+ return true if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
2332
+ return true if members.any? { |member| arm_surface_may_answer?(member, call_node.name, scope) }
2333
+ # Issue #1722 — `def o.m`, `o.extend(M)` and their kin give the one object the method, whatever its class.
2334
+ return true if lexical_sites&.singleton_local_def?(call_node)
2335
+
2336
+ refined_on_some_arm?(members, call_node, scope, lexical_sites)
2337
+ end
2338
+
2339
+ # Issue #1722 — the scalar rule's RBS-ancestor and subclass declines, asked of one arm. Kept out of
2340
+ # {#method_present_anywhere?}, which `call.possible-nil-receiver` also asks: there a "present" answer is
2341
+ # what lets the rule fire, and a `method_missing` on `Object` answers on the `nil` arm too.
2342
+ def arm_surface_may_answer?(member, method_name, scope)
2343
+ class_name = concrete_class_name(member)
2344
+ rbs_ancestor_may_answer?(class_name, method_name, scope, :instance) ||
2345
+ subclass_instance_may_answer?(member, class_name, method_name, scope)
2346
+ end
2347
+
2205
2348
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may define the name on one arm's class.
2206
2349
  def refined_on_some_arm?(members, call_node, scope, lexical_sites)
2207
2350
  members.any? do |member|
@@ -2684,9 +2827,37 @@ module Rigor
2684
2827
 
2685
2828
  # Whether any project or `pre_eval:` file aliases `name`, on either side, which can make it another variable.
2686
2829
  def global_aliased?(scope, name)
2830
+ record_global_census(Inference::GlobalWriteCensus.alias_entry(name))
2687
2831
  Inference::GlobalWriteCensus.aliased?(global_write_census(scope), name)
2688
2832
  end
2689
2833
 
2834
+ # Issue #2158 — a read of the whole-program census records the `global_census:` key of each entry it asks
2835
+ # about ({Inference::GlobalWriteCensus.dependency_key}): a file that starts or stops writing the entry (a `def
2836
+ # write`, an `alias $stdout $x`, a computed `define_method`) re-checks the write. A `pre_eval:` file moves the
2837
+ # snapshot's own fingerprint.
2838
+ def record_global_census(*entries)
2839
+ return unless DependencyRecorder.active?
2840
+
2841
+ entries.each do |entry|
2842
+ DependencyRecorder.read_name(:global_census, Inference::GlobalWriteCensus.dependency_key(entry))
2843
+ end
2844
+ end
2845
+
2846
+ # Whether the census holds any of `entries`, recording only what can flip the answer: the entries present
2847
+ # when it holds one (the answer stays true until every one of them vanishes, and another appearing changes
2848
+ # nothing), every entry when it holds none.
2849
+ #
2850
+ # This rests on an invariant: every census entry can only move a write toward being silenced, and a reported
2851
+ # write is always re-checked through `IncrementalSession#global_write_reporters`, so recording only the
2852
+ # present entries on a yes is sound. A future census entry that makes a write reported would break it, and
2853
+ # its reader would have to record every entry. Recording every entry on a no is defence in depth, since the
2854
+ # write that answer leaves reported re-checks through `global_write_reporters` anyway.
2855
+ def census_holds_any?(census, entries)
2856
+ present = entries.select { |entry| census.include?(entry) }
2857
+ record_global_census(*(present.empty? ? entries : present))
2858
+ !present.empty?
2859
+ end
2860
+
2690
2861
  # A literal is rejected when its class is none the setter takes and, for a setter that also converts
2691
2862
  # (`to_str`, `to_int`) or asks for `write`, the literal's object cannot answer that method.
2692
2863
  def literal_rejected?(class_name, contract, node, scope, lexical_sites)
@@ -2734,11 +2905,15 @@ module Rigor
2734
2905
  end
2735
2906
 
2736
2907
  def program_may_answer?(ancestors, names, census, scope)
2737
- return true if Inference::GlobalWriteCensus.defines_any_of?(census, names)
2908
+ defines = [Inference::GlobalWriteCensus::DEFINES_ANY, *names.map { |name| [:defines, name] }]
2909
+ return true if census_holds_any?(census, defines)
2738
2910
 
2739
2911
  dynamic_mark = Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK
2740
2912
  return true if ancestors.any? { |owner| scope.parameter_envelopes_of(owner).key?(dynamic_mark) }
2741
2913
 
2914
+ # One key for every top-level mixin, a deliberate trade-off: the reader asks about each, so a mixin
2915
+ # appearing or vanishing anywhere re-checks every write that reached this step.
2916
+ record_global_census(%i[main_mixin *])
2742
2917
  main_mixins = Inference::GlobalWriteCensus.main_mixins(census)
2743
2918
  main_mixins.any? { |mixin| rbs_mixin_may_answer?(mixin, names, scope) } ||
2744
2919
  mixin_may_answer?(ancestors, names, scope)
@@ -2780,7 +2955,7 @@ module Rigor
2780
2955
  # active refinement on Ruby 4.0.5; a refined `respond_to_missing?` or `respond_to?`, and every implicit
2781
2956
  # conversion, do not, so a refinement that adds only those leaves the write reported.
2782
2957
  def refined_writer_in_effect?(census, node, scope, lexical_sites)
2783
- Inference::GlobalWriteCensus.refines_write?(census) &&
2958
+ census_holds_any?(census, [Inference::GlobalWriteCensus::REFINES_ANY, %i[refines write]]) &&
2784
2959
  (lexical_sites.nil? || !scope.declared_refinements.empty? ||
2785
2960
  lexical_sites.refinements.any_at?(node.location.start_offset))
2786
2961
  end
@@ -2865,6 +3040,13 @@ module Rigor
2865
3040
  # - Issue #1568 — a module the class PREPENDS sits ahead of it in Ruby's order, so a definition there
2866
3041
  # is what the call reaches; the class's own private `def` answers only when nothing prepended
2867
3042
  # defines the name ({#reached_definer_visibility}).
3043
+ # - Issue #1984 — a class-object receiver (`Type::Singleton`, `K.x`) is checked against the singleton side:
3044
+ # the definition the call reaches on `K`'s singleton chain (its own singleton methods, its extended
3045
+ # modules' instance methods, then the superclass's singleton chain) must be recorded `:private`
3046
+ # (`private_class_method`, `class << self` with `private`, a private method of an extended module).
3047
+ # - Issue #1984 — Ruby hands a private call to `method_missing`, so a `method_missing` or
3048
+ # `respond_to_missing?` a project entry on the receiver's chain records, on the side the call reaches,
3049
+ # declines the rule ({#missing_hook_on_chain?}).
2868
3050
  def visibility_mismatch_diagnostic(path, call_node, scope_index, lexical_sites = nil)
2869
3051
  return nil unless explicit_non_self_receiver?(call_node.receiver)
2870
3052
 
@@ -2876,18 +3058,128 @@ module Rigor
2876
3058
  return nil if inferred_param_receiver?(call_node, scope)
2877
3059
 
2878
3060
  receiver_type = scope.type_of(call_node.receiver)
2879
- return nil unless receiver_type.is_a?(Type::Nominal)
3061
+ case receiver_type
3062
+ when Type::Nominal then instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
3063
+ when Type::Singleton then singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
3064
+ end
3065
+ end
2880
3066
 
3067
+ def instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2881
3068
  visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
2882
3069
  return nil unless visibility == :private
2883
3070
 
2884
3071
  reached = reached_definer_visibility(scope, receiver_type.class_name, call_node.name)
2885
3072
  return nil if reached == :undecided
2886
3073
  return nil unless reached.nil? || reached == :private
3074
+ return nil if missing_hook_on_chain?(scope, receiver_type.class_name, :instance)
2887
3075
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may redefine the method, public.
2888
3076
  return nil if refined_method_in_effect?(receiver_type.class_name, call_node, scope, :instance, lexical_sites)
2889
3077
 
2890
- build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
3078
+ build_visibility_mismatch_diagnostic(path, call_node, receiver_type.class_name)
3079
+ end
3080
+
3081
+ # Issue #1984 — the singleton side reads only the reached definer: a private singleton method may be inherited
3082
+ # (`private_class_method :new` on a superclass) or come from an extended module, so there is no own-table
3083
+ # gate as on the instance side. It reports only:
3084
+ #
3085
+ # - a constant receiver (`K.x`, `A::K.x`): `self.class.x` or a local may hold a subclass that makes the name
3086
+ # public again (`public_class_method :new`, a template-method `def self.x`);
3087
+ # - a call inside a `def` ({#load_time_call?}): code that runs while the files load (a class body's
3088
+ # `ALL = [K.new]` before its `private_class_method :new`) may run before the call is private;
3089
+ # - no class on the chain may have had every singleton name made public (`K.public_class_method(*names)`,
3090
+ # `K.singleton_class.class_eval { … }`; {#singleton_wildcard_contested?}).
3091
+ def singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
3092
+ class_name = receiver_type.class_name
3093
+ return nil unless call_node.receiver.is_a?(Prism::ConstantReadNode) ||
3094
+ call_node.receiver.is_a?(Prism::ConstantPathNode)
3095
+ return nil unless private_singleton_candidate?(scope, call_node.name)
3096
+ return nil if load_time_call?(scope, call_node)
3097
+ return nil unless reached_definer_visibility(scope, class_name, call_node.name, :singleton) == :private
3098
+ return nil if missing_hook_on_chain?(scope, class_name, :singleton)
3099
+ return nil if singleton_wildcard_contested?(scope, class_name)
3100
+ return nil if refined_method_in_effect?(class_name, call_node, scope, :singleton, lexical_sites)
3101
+
3102
+ build_visibility_mismatch_diagnostic(path, call_node, "singleton(#{class_name})")
3103
+ end
3104
+
3105
+ PRIVATE_NAMES_MEMO = :rigor_check_rules_private_singleton_names
3106
+ private_constant :PRIVATE_NAMES_MEMO
3107
+
3108
+ # Issue #1984 — the table-only gate the singleton side asks before it reads a chain, as the instance side asks
3109
+ # its own table first: whether any class records `name` private, on its singleton side or on its instance
3110
+ # side (an extended module's private method is private on the extender's singleton). Without it every
3111
+ # `Widget.new` would read, and file the dependency edges of, the whole singleton chain. The set is memoised per
3112
+ # discovery index.
3113
+ def private_singleton_candidate?(scope, name)
3114
+ DependencyRecorder.read_visibility("*", name) if DependencyRecorder.active?
3115
+ discovery = scope.discovery
3116
+ slot = Thread.current[PRIVATE_NAMES_MEMO]
3117
+ unless slot && slot[0].equal?(discovery)
3118
+ slot = [discovery, private_recorded_names(discovery.discovered_method_visibilities)]
3119
+ Thread.current[PRIVATE_NAMES_MEMO] = slot
3120
+ end
3121
+ slot[1].include?(name.to_sym)
3122
+ end
3123
+
3124
+ def private_recorded_names(tables)
3125
+ tables.each_value.with_object(Set.new) do |table, names|
3126
+ table.each { |slot, visibility| names << (slot.is_a?(Array) ? slot.last : slot) if visibility == :private }
3127
+ end
3128
+ end
3129
+
3130
+ # Issue #1984 — whether the call is outside every `def` the file's deferred ranges name. A `def` is named
3131
+ # wherever it is written in a class body, at the top level or in a `class_eval` block, so a call in it is not
3132
+ # load time and may be reported. The top level, class bodies, and blocks and lambdas that are not such a `def`
3133
+ # (a `map { … }` or `define_method` block, a `-> { }`) are load time and decline: such code may run while the
3134
+ # files load, before a later `private_class_method` or in a load order where the private declaration has not
3135
+ # run yet.
3136
+ def load_time_call?(scope, call_node)
3137
+ rows = scope.discovered_deferred_ranges[scope.source_path]
3138
+ return true if rows.nil?
3139
+
3140
+ offset = call_node.location.start_offset
3141
+ # A row with no side (a `def` in a block, a `define_method` name) is a block's range or no range at all.
3142
+ rows.none? { |(start, finish, _name, kind, _owner)| !kind.nil? && start <= offset && offset < finish }
3143
+ end
3144
+
3145
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
3146
+ private_constant :SINGLETON_WILDCARD_SLOT
3147
+
3148
+ # Issue #1984 — whether a project class on `class_name`'s singleton chain has its wildcard singleton slot
3149
+ # contested: a call the walk saw may have made any of its singleton methods public.
3150
+ def singleton_wildcard_contested?(scope, class_name)
3151
+ discovery = scope.discovery
3152
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3153
+ chain.entries.any? do |entry|
3154
+ DependencyRecorder.read_visibility(entry.name, SINGLETON_WILDCARD_SLOT.last) unless entry.external?
3155
+ !entry.external? && discovery.contested?(:discovered_method_visibilities,
3156
+ [entry.name, SINGLETON_WILDCARD_SLOT])
3157
+ end
3158
+ end
3159
+
3160
+ VISIBILITY_MISSING_HOOKS = %i[method_missing respond_to_missing?].freeze
3161
+ private_constant :VISIBILITY_MISSING_HOOKS
3162
+
3163
+ # Issue #1984 — whether a project entry on `class_name`'s chain for `side` records a `method_missing` or
3164
+ # `respond_to_missing?` on the side that entry stands for (a singleton entry's singleton methods, a mixin's
3165
+ # instance methods), or the chain cannot be read whole. Ruby sends a private call to `method_missing`, so
3166
+ # such a hook may answer it.
3167
+ def missing_hook_on_chain?(scope, class_name, side)
3168
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, :methods)
3169
+ return true if chain.truncated?
3170
+
3171
+ chain.entries.any? do |entry|
3172
+ next false if entry.external?
3173
+
3174
+ kind = entry.side == :singleton ? :singleton : :instance
3175
+ VISIBILITY_MISSING_HOOKS.any? { |hook| records_hook?(scope, entry.name, hook, kind) }
3176
+ end
3177
+ end
3178
+
3179
+ def records_hook?(scope, owner, hook, kind)
3180
+ return true if scope.discovered_method?(owner, hook, kind)
3181
+
3182
+ !(kind == :singleton ? scope.singleton_def_for(owner, hook) : scope.user_def_for(owner, hook)).nil?
2891
3183
  end
2892
3184
 
2893
3185
  # Issue #1568, ADR-119 C1c — the recorded visibility of the definition a call on `class_name` reaches: the
@@ -2899,8 +3191,8 @@ module Rigor
2899
3191
  # producers contest is not alone (`DefinerResolution` asks the next candidate too), so it is `:undecided`
2900
3192
  # there without a read of the table here. nil is nothing recording the name at all, and the class's own
2901
3193
  # visibility stands. An ancestor the project does not declare is passed over, as the method lookup passes it.
2902
- def reached_definer_visibility(scope, class_name, method_name)
2903
- case Inference::DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :visibility)
3194
+ def reached_definer_visibility(scope, class_name, method_name, side = :instance)
3195
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, side, question: :visibility)
2904
3196
  in Inference::DefinerResolution::Known(answer:)
2905
3197
  answer
2906
3198
  in Inference::DefinerResolution::UNKNOWN
@@ -2917,14 +3209,14 @@ module Rigor
2917
3209
  true
2918
3210
  end
2919
3211
 
2920
- def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
3212
+ def build_visibility_mismatch_diagnostic(path, call_node, receiver_name)
2921
3213
  Diagnostic.from_message_loc(
2922
3214
  call_node,
2923
3215
  rule: RULE_VISIBILITY_MISMATCH,
2924
3216
  path: path,
2925
- message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
3217
+ message: "private method `#{call_node.name}' called on #{receiver_name} receiver",
2926
3218
  severity: :error,
2927
- receiver_type: receiver_type.class_name,
3219
+ receiver_type: receiver_name,
2928
3220
  method_name: call_node.name.to_s
2929
3221
  )
2930
3222
  end
@@ -3992,6 +4284,9 @@ module Rigor
3992
4284
 
3993
4285
  override_visibility = scope.discovered_method_visibility(class_name, method_name)
3994
4286
  return nil if override_visibility.nil?
4287
+ # Issue #2013 — a contested slot (a later `define_method` or alias gave the name another visibility, or a
4288
+ # write the walk cannot place) proves no reduction.
4289
+ return nil if scope.discovery.contested?(:discovered_method_visibilities, [class_name, method_name])
3995
4290
 
3996
4291
  parent = nearest_ancestor_visibility(scope, class_name, method_name)
3997
4292
  return nil if parent.nil?
@@ -4001,6 +4296,9 @@ module Rigor
4001
4296
  # prove a reduction, stay silent.
4002
4297
  return nil if parent_visibility.nil?
4003
4298
  return nil unless visibility_reduced?(parent_visibility, override_visibility)
4299
+ # Issue #1569 — a module function's instance copy is private because `module_function` made it so; the
4300
+ # author's intent is the public singleton copy, so that reduction is not reported.
4301
+ return nil if scope.module_function_def?(class_name, def_node)
4004
4302
 
4005
4303
  build_override_visibility_diagnostic(
4006
4304
  path, def_node, parent_class, parent_visibility, override_visibility