rigortype 0.4.4 → 0.4.5

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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  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 +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -1
@@ -101,6 +101,19 @@ module Rigor
101
101
  # {#decline_unless_recorded_nowhere}): it settles by definitions, as an `ABSENT` read does.
102
102
  return settle_by_walk if @unknown
103
103
 
104
+ # Issue #1833 — a level the `:arity` resolver took to be the project's on a mark alone (a namespace
105
+ # candidate a marks-only file rewrites) stands only while that mark does, and no definition appearing or
106
+ # vanishing can overturn it. Only such levels file the edge: the walk's other mark reads would re-check
107
+ # the call whenever an unrelated namespace reuses an ancestor's last segment.
108
+ @levels.each do |level|
109
+ ([level.class_name] + level.modules).each do |name|
110
+ next if @scope.known_user_class?(name)
111
+ next unless Scope::DiscoveryIndex.declares_surface?(@scope.discovered_parameter_envelopes[name])
112
+
113
+ Analysis::DependencyRecorder.read_last_segment(:mark, name)
114
+ end
115
+ end
116
+
104
117
  @owner_entries.each do |name, kind|
105
118
  if kind == :singleton
106
119
  @scope.user_singleton_def_site_for(name, @method_name)
@@ -121,7 +134,8 @@ module Rigor
121
134
  # Stage 2, for the class {#owner_envelope} last answered.
122
135
  def authoritative?(class_name)
123
136
  !load_order_dependent?(class_name) && !object_extension_may_shadow? &&
124
- @levels.all? { |level| clean_level?(level) && public_at?(level.class_name) } &&
137
+ @levels.all? { |level| clean_level?(level) && (@kind == :singleton || public_at?(level.class_name)) } &&
138
+ !(@kind == :singleton && singleton_private?(class_name)) &&
125
139
  chain_free_of_hooks?(class_name) &&
126
140
  subclasses_agree?(class_name, @envelope)
127
141
  end
@@ -245,8 +259,10 @@ module Rigor
245
259
  end
246
260
 
247
261
  # A module some method body hands to `obj.extend` sits ahead of the object's class, so if it defines
248
- # the name at all, that object answers with the module's definition.
262
+ # the name at all, that object answers with the module's definition. Every bucket is read, so the answer
263
+ # records `mark:*`, which an object-extended mark appearing or vanishing anywhere fires (issue #1833).
249
264
  def object_extension_may_shadow?
265
+ Analysis::DependencyRecorder.read_name(:mark, "*") if Analysis::DependencyRecorder.active?
250
266
  @scope.discovered_parameter_envelopes.any? do |_name, bucket|
251
267
  bucket.key?(Scope::DiscoveryIndex::ENVELOPE_OBJECT_EXTENDED_MARK) && bucket.key?([:instance, @method_name])
252
268
  end
@@ -291,7 +307,9 @@ module Rigor
291
307
  end
292
308
 
293
309
  # A bucket of class marks alone (issue #1820: `x.singleton_class.prepend(Comparable)`) does not make a name
294
- # the project's.
310
+ # the project's. The bucket is read directly rather than through `parameter_envelopes_of`: a `mark:<last>`
311
+ # edge recorded here fires on marks a correctly-fitting call does not depend on, so the dependency on
312
+ # a mark that could flip this resolution is kept at `settle_by_definitions` instead (issue #1833).
295
313
  def project_class?(name)
296
314
  @scope.known_user_class?(name) ||
297
315
  Scope::DiscoveryIndex.declares_surface?(@scope.discovered_parameter_envelopes[name])
@@ -348,6 +366,17 @@ module Rigor
348
366
  false
349
367
  end
350
368
 
369
+ # Issue #2059 — a call Ruby rejects as private on the singleton side (`private_class_method :two`, a private
370
+ # method of a module that extends itself) is `def.method-visibility-mismatch`'s, which reads the same definer;
371
+ # the arity is not checked. A read that declines leaves the arity to the other conditions.
372
+ def singleton_private?(class_name)
373
+ case Inference::DefinerResolution.resolve(@scope, class_name, @method_name, :singleton, question: :visibility)
374
+ in Inference::DefinerResolution::Known(answer:, owner: _owner) then answer == :private
375
+ in Inference::DefinerResolution::UNKNOWN then false
376
+ in Inference::DefinerResolution::ABSENT then false # rubocop:disable Lint/DuplicateBranch
377
+ end
378
+ end
379
+
351
380
  def public_at?(class_name)
352
381
  visibility = @scope.discovered_method_visibility(class_name, @method_name)
353
382
  visibility.nil? || visibility == :public
@@ -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
@@ -124,11 +129,13 @@ module Rigor
124
129
  return if entry_type.nil? || entry_type.is_a?(Type::Bot) || entry_type.is_a?(Type::Dynamic)
125
130
 
126
131
  keyword = node.is_a?(Prism::CaseMatchNode) ? "in" : "when"
132
+ coverage = ClauseCoverage.new(scope, entry_type)
127
133
  node.conditions.each do |clause|
128
134
  case clause
129
- when Prism::WhenNode then collect_when(clause, subject.name)
130
- when Prism::InNode then collect_in(clause, subject.name)
135
+ when Prism::WhenNode then collect_when(clause, subject.name, coverage)
136
+ when Prism::InNode then collect_in(clause, subject.name, coverage)
131
137
  end
138
+ coverage.record(clause)
132
139
  end
133
140
  collect_else(node.else_clause, subject.name, keyword)
134
141
  end
@@ -138,7 +145,7 @@ module Rigor
138
145
  scope&.local(subject_name)
139
146
  end
140
147
 
141
- def collect_when(clause, subject_name)
148
+ def collect_when(clause, subject_name, coverage)
142
149
  return if clause.statements.nil? # empty body → no useful location
143
150
  return unless all_constant_conditions?(clause)
144
151
 
@@ -149,6 +156,7 @@ module Rigor
149
156
  kind = when_clause_kind(clause, subject_name)
150
157
  return if kind == :disjoint &&
151
158
  disjoint_nominal_subject?(@scope_index[clause.conditions.first], subject_name, clause.conditions)
159
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
152
160
 
153
161
  @results << Result.new(
154
162
  clause: clause, body: clause.statements, subject_name: subject_name,
@@ -187,7 +195,7 @@ module Rigor
187
195
  # (prior-exhaustion — true for ANY pattern downstream of a covering set). Non-bare patterns are not
188
196
  # narrowed, so their body is `bot` ONLY under prior-exhaustion; they never produce a spurious
189
197
  # `:disjoint`.
190
- def collect_in(clause, subject_name)
198
+ def collect_in(clause, subject_name, coverage)
191
199
  return if clause.statements.nil?
192
200
 
193
201
  scope = @scope_index[clause]
@@ -196,6 +204,7 @@ module Rigor
196
204
 
197
205
  kind = in_clause_kind(clause, subject_name)
198
206
  return if kind == :disjoint && disjoint_nominal_pattern?(clause, subject_name)
207
+ return if kind == :prior_exhaustion && !coverage.proven?(clause)
199
208
 
200
209
  @results << Result.new(
201
210
  clause: clause, body: clause.statements, subject_name: subject_name,
@@ -29,6 +29,7 @@ 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/rbs_receiver_surface"
32
33
  require_relative "check_rules/special_global_setters"
33
34
  require_relative "check_rules/ruby_deprecations"
34
35
 
@@ -801,7 +802,7 @@ module Rigor
801
802
  # rule must not surface a false `undefined-method`
802
803
  # for them.
803
804
  kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
804
- return nil if source_declared_method?(scope, class_name, call_node.name, kind)
805
+ return nil if source_declared_method?(scope, class_name, call_node.name, kind, call_node: call_node)
805
806
 
806
807
  return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
807
808
 
@@ -817,6 +818,8 @@ module Rigor
817
818
  return nil if method_def
818
819
 
819
820
  return nil if last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites)
821
+ return nil if subclass_instance_may_answer?(rendered_receiver, class_name, call_node.name, scope,
822
+ call_node.receiver)
820
823
 
821
824
  definition_site = project_definition_site(scope, class_name, call_node.name, kind)
822
825
  # Issue #735 — the site is evidence, and whose RBS this is decides what it is evidence OF. On a
@@ -951,12 +954,51 @@ module Rigor
951
954
  # (see {#ancestry_declares_method?} for why that placement is load-bearing, not tidiness).
952
955
  def last_resort_surface_answers?(receiver_type, class_name, call_node, scope, kind, lexical_sites = nil)
953
956
  return true if lexically_defined_method?(class_name, call_node, scope, kind, lexical_sites)
957
+ return true if project_patched_through_ancestors?(scope, class_name, call_node.name, kind,
958
+ call_node: call_node)
954
959
  return true if module_mixin_receiver?(receiver_type, scope)
955
960
  return true if mixin_self_class_receiver?(call_node, scope)
956
961
  return true if unknown_mixin_includer?(class_name, scope)
957
962
  return true if object_mixin_may_define?(receiver_type, class_name, call_node.name, scope)
963
+ return true if ancestry_declares_method?(scope, class_name, call_node.name, kind)
958
964
 
959
- ancestry_declares_method?(scope, class_name, call_node.name, kind)
965
+ rbs_ancestor_may_answer?(class_name, call_node.name, scope, kind)
966
+ end
967
+
968
+ # Issue #1722 — a `method_missing`, or a project mixin, on one of the class's RBS ancestors
969
+ # ({RbsReceiverSurface}). Last for {#ancestry_declares_method?}'s reason: it walks the class graph.
970
+ def rbs_ancestor_may_answer?(class_name, method_name, scope, kind)
971
+ if kind == :singleton
972
+ RbsReceiverSurface.singleton_hook?(scope, class_name)
973
+ else
974
+ RbsReceiverSurface.ancestor_may_answer?(scope, class_name, method_name)
975
+ end
976
+ end
977
+
978
+ # Issue #1722 — a receiver typed by its class's name, rather than by a literal, may hold an instance of a
979
+ # subclass (a `#: () -> String` signature admits a `MyStr < String`), so a method a project subclass
980
+ # defines, or a subclass's `method_missing`, may be the one that runs. A value the engine built from a
981
+ # literal is exactly its class and keeps firing: a literal type (`"a"`, `1`, a union of them), a shape
982
+ # carrier (`{a: 1}`, `[1, 2]`, `{}`, `[]`, and what folds to one), or a receiver written as a literal
983
+ # (`"a#{x}b"`, which types as plain `String`). A local holding an interpolated String types as plain
984
+ # `String` with no origin, so it still declines. Instance side only.
985
+ def subclass_instance_may_answer?(receiver_type, class_name, method_name, scope, receiver_node = nil)
986
+ return false if receiver_type.is_a?(Type::Singleton)
987
+ return false if exact_literal_receiver?(receiver_type) || LITERAL_RECEIVER_NODES.include?(receiver_node.class)
988
+
989
+ RbsReceiverSurface.subclass_may_define?(scope, class_name, method_name)
990
+ end
991
+
992
+ EXACT_TYPES = [Type::Constant, Type::Tuple, Type::HashShape].freeze
993
+ LITERAL_RECEIVER_NODES = [
994
+ Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode, Prism::InterpolatedXStringNode,
995
+ Prism::ArrayNode, Prism::HashNode, Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode
996
+ ].to_set.freeze
997
+ private_constant :EXACT_TYPES, :LITERAL_RECEIVER_NODES
998
+
999
+ def exact_literal_receiver?(receiver_type)
1000
+ members = receiver_type.is_a?(Type::Union) ? receiver_type.members : [receiver_type]
1001
+ members.all? { |member| EXACT_TYPES.any? { |kind| member.is_a?(kind) } }
960
1002
  end
961
1003
 
962
1004
  # Issue #1697 — a module the project mixes into `Object` (a top-level `include M`, or `include M` in
@@ -1058,10 +1100,13 @@ module Rigor
1058
1100
  # Both paths sit at the same dispatcher precedence; the
1059
1101
  # check must hold them together so neither rule fires a
1060
1102
  # false positive.
1061
- def source_declared_method?(scope, class_name, method_name, kind)
1103
+ #
1104
+ # `call_node:` (issue #1708), when given, is the call asking: a private or protected `pre_eval:` patch
1105
+ # it may not reach does not count ({#patch_callable?}).
1106
+ def source_declared_method?(scope, class_name, method_name, kind, call_node: nil)
1062
1107
  return true if scope.discovered_method?(class_name, method_name, kind)
1063
1108
 
1064
- project_patched_method?(scope, class_name, method_name, kind)
1109
+ project_patched_method?(scope, class_name, method_name, kind, call_node: call_node)
1065
1110
  end
1066
1111
 
1067
1112
  # Issue #723 — the same question as {#source_declared_method?}, asked through the project's own
@@ -1087,12 +1132,42 @@ module Rigor
1087
1132
  # inference (between plugins and dependency-source).
1088
1133
  # Returns false when the environment carries no registry
1089
1134
  # (legacy path) or the lookup misses.
1090
- def project_patched_method?(scope, class_name, method_name, kind)
1135
+ def project_patched_method?(scope, class_name, method_name, kind, call_node: nil)
1091
1136
  environment = scope.environment
1092
1137
  registry = environment&.project_patched_methods
1093
1138
  return false if registry.nil? || registry.empty?
1094
1139
 
1095
- !registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind).nil?
1140
+ entry = registry.lookup(class_name: class_name.to_s, method_name: method_name.to_sym, kind: kind)
1141
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1142
+ end
1143
+
1144
+ # Issue #1708 — {#project_patched_method?} asked through the receiver's ancestors: a `pre_eval:` patch on
1145
+ # `Object`, `Numeric` or `Enumerable` gives an `Integer` or `Array` the method. Asked only once the
1146
+ # receiver's RBS has come back without the method, so a nearer class's own declaration still decides arity
1147
+ # and argument types; {#source_declared_method?} stays exact for the rules that read it for those.
1148
+ #
1149
+ # `call_node:` as {#source_declared_method?} reads it.
1150
+ def project_patched_through_ancestors?(scope, class_name, method_name, kind, call_node: nil)
1151
+ environment = scope.environment
1152
+ registry = environment&.project_patched_methods
1153
+ return false if registry.nil? || registry.empty?
1154
+
1155
+ entry = registry.lookup_through_ancestors(class_name: class_name.to_s, method_name: method_name.to_sym,
1156
+ kind: kind, rbs_loader: environment.rbs_loader)
1157
+ !entry.nil? && patch_callable?(registry, entry, scope, call_node)
1158
+ end
1159
+
1160
+ # Issue #1708 — whether `call_node` may reach the patch `entry`: Ruby raises NoMethodError for a private
1161
+ # patch called with an explicit receiver other than `self` (`1.kp` on `module Kernel; private def kp`), and
1162
+ # for a protected one called from outside its owner's instances. No call node, and a declined verdict (another
1163
+ # definer the call may reach, {Inference::ProjectPatchedMethods#callable?}), are the permissive answer.
1164
+ def patch_callable?(registry, entry, scope, call_node)
1165
+ return true if call_node.nil?
1166
+
1167
+ receiver = call_node.receiver
1168
+ verdict = registry.callable?(entry, explicit: !receiver.nil? && !receiver.is_a?(Prism::SelfNode),
1169
+ self_type: scope.self_type, rbs_loader: scope.environment.rbs_loader)
1170
+ verdict != false
1096
1171
  end
1097
1172
 
1098
1173
  # ADR-34 — `call.unresolved-toplevel`. Fires on an
@@ -1141,6 +1216,8 @@ module Rigor
1141
1216
  return nil if scope.top_level_def_for(name)
1142
1217
  return nil if source_declared_method?(scope, "Object", name, :instance)
1143
1218
  return nil if Rigor::Reflection.instance_method_definition("Object", name, scope: scope)
1219
+ # Issue #1708 — `main` is an Object, so a `pre_eval:` patch on `Kernel` or `BasicObject` defines the name too.
1220
+ return nil if project_patched_through_ancestors?(scope, "Object", name, :instance)
1144
1221
  return nil if main_singleton_method?(name, scope)
1145
1222
  return nil if Inference::ObjectMixins.may_define?(scope, name, receiver: :implicit)
1146
1223
  # `Target.class_eval { def added = 1; def use_added = added }` files the defs on Target
@@ -2143,6 +2220,7 @@ module Rigor
2143
2220
  return true unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
2144
2221
  return true unless definition_available?(member, class_name, scope)
2145
2222
  return true if lookup_method(member, class_name, method_name, scope)
2223
+ return true if project_patched_through_ancestors?(scope, class_name, method_name, :instance)
2146
2224
 
2147
2225
  # Issue #723 — the arm's own RBS does not have it; its project ancestry still might. Last, for
2148
2226
  # the reason {#ancestry_declares_method?} carries: this is the probe that walks the class graph.
@@ -2196,12 +2274,30 @@ module Rigor
2196
2274
  # `compose_codepoints` typed `Hash | Hash` for an `Array`, flagging
2197
2275
  # `.pack`). Require at least two distinct arm classes.
2198
2276
  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)
2277
+ return nil if union_may_answer?(members, call_node, scope, lexical_sites)
2201
2278
 
2202
2279
  build_undefined_method_diagnostic(path, call_node, receiver_type)
2203
2280
  end
2204
2281
 
2282
+ # Whether some arm of a non-nil union of classes may answer the call, which keeps the union rule silent.
2283
+ def union_may_answer?(members, call_node, scope, lexical_sites)
2284
+ return true if members.any? { |member| method_present_anywhere?(member, call_node.name, scope) }
2285
+ return true if members.any? { |member| arm_surface_may_answer?(member, call_node.name, scope) }
2286
+ # Issue #1722 — `def o.m`, `o.extend(M)` and their kin give the one object the method, whatever its class.
2287
+ return true if lexical_sites&.singleton_local_def?(call_node)
2288
+
2289
+ refined_on_some_arm?(members, call_node, scope, lexical_sites)
2290
+ end
2291
+
2292
+ # Issue #1722 — the scalar rule's RBS-ancestor and subclass declines, asked of one arm. Kept out of
2293
+ # {#method_present_anywhere?}, which `call.possible-nil-receiver` also asks: there a "present" answer is
2294
+ # what lets the rule fire, and a `method_missing` on `Object` answers on the `nil` arm too.
2295
+ def arm_surface_may_answer?(member, method_name, scope)
2296
+ class_name = concrete_class_name(member)
2297
+ rbs_ancestor_may_answer?(class_name, method_name, scope, :instance) ||
2298
+ subclass_instance_may_answer?(member, class_name, method_name, scope)
2299
+ end
2300
+
2205
2301
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may define the name on one arm's class.
2206
2302
  def refined_on_some_arm?(members, call_node, scope, lexical_sites)
2207
2303
  members.any? do |member|
@@ -2865,6 +2961,13 @@ module Rigor
2865
2961
  # - Issue #1568 — a module the class PREPENDS sits ahead of it in Ruby's order, so a definition there
2866
2962
  # is what the call reaches; the class's own private `def` answers only when nothing prepended
2867
2963
  # defines the name ({#reached_definer_visibility}).
2964
+ # - Issue #1984 — a class-object receiver (`Type::Singleton`, `K.x`) is checked against the singleton side:
2965
+ # the definition the call reaches on `K`'s singleton chain (its own singleton methods, its extended
2966
+ # modules' instance methods, then the superclass's singleton chain) must be recorded `:private`
2967
+ # (`private_class_method`, `class << self` with `private`, a private method of an extended module).
2968
+ # - Issue #1984 — Ruby hands a private call to `method_missing`, so a `method_missing` or
2969
+ # `respond_to_missing?` a project entry on the receiver's chain records, on the side the call reaches,
2970
+ # declines the rule ({#missing_hook_on_chain?}).
2868
2971
  def visibility_mismatch_diagnostic(path, call_node, scope_index, lexical_sites = nil)
2869
2972
  return nil unless explicit_non_self_receiver?(call_node.receiver)
2870
2973
 
@@ -2876,18 +2979,125 @@ module Rigor
2876
2979
  return nil if inferred_param_receiver?(call_node, scope)
2877
2980
 
2878
2981
  receiver_type = scope.type_of(call_node.receiver)
2879
- return nil unless receiver_type.is_a?(Type::Nominal)
2982
+ case receiver_type
2983
+ when Type::Nominal then instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2984
+ when Type::Singleton then singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2985
+ end
2986
+ end
2880
2987
 
2988
+ def instance_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
2881
2989
  visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
2882
2990
  return nil unless visibility == :private
2883
2991
 
2884
2992
  reached = reached_definer_visibility(scope, receiver_type.class_name, call_node.name)
2885
2993
  return nil if reached == :undecided
2886
2994
  return nil unless reached.nil? || reached == :private
2995
+ return nil if missing_hook_on_chain?(scope, receiver_type.class_name, :instance)
2887
2996
  # ADR-121 WD7 — a refinement in effect (or one Rigor cannot read) may redefine the method, public.
2888
2997
  return nil if refined_method_in_effect?(receiver_type.class_name, call_node, scope, :instance, lexical_sites)
2889
2998
 
2890
- build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
2999
+ build_visibility_mismatch_diagnostic(path, call_node, receiver_type.class_name)
3000
+ end
3001
+
3002
+ # Issue #1984 — the singleton side reads only the reached definer: a private singleton method may be inherited
3003
+ # (`private_class_method :new` on a superclass) or come from an extended module, so there is no own-table
3004
+ # gate as on the instance side. It reports only:
3005
+ #
3006
+ # - a constant receiver (`K.x`, `A::K.x`): `self.class.x` or a local may hold a subclass that makes the name
3007
+ # public again (`public_class_method :new`, a template-method `def self.x`);
3008
+ # - a call inside a `def` ({#load_time_call?}): code that runs while the files load (a class body's
3009
+ # `ALL = [K.new]` before its `private_class_method :new`) may run before the call is private;
3010
+ # - no class on the chain may have had every singleton name made public (`K.public_class_method(*names)`,
3011
+ # `K.singleton_class.class_eval { … }`; {#singleton_wildcard_contested?}).
3012
+ def singleton_visibility_mismatch(path, call_node, scope, receiver_type, lexical_sites)
3013
+ class_name = receiver_type.class_name
3014
+ return nil unless call_node.receiver.is_a?(Prism::ConstantReadNode) ||
3015
+ call_node.receiver.is_a?(Prism::ConstantPathNode)
3016
+ return nil unless private_singleton_candidate?(scope, call_node.name)
3017
+ return nil if load_time_call?(scope, call_node)
3018
+ return nil unless reached_definer_visibility(scope, class_name, call_node.name, :singleton) == :private
3019
+ return nil if missing_hook_on_chain?(scope, class_name, :singleton)
3020
+ return nil if singleton_wildcard_contested?(scope, class_name)
3021
+ return nil if refined_method_in_effect?(class_name, call_node, scope, :singleton, lexical_sites)
3022
+
3023
+ build_visibility_mismatch_diagnostic(path, call_node, "singleton(#{class_name})")
3024
+ end
3025
+
3026
+ PRIVATE_NAMES_MEMO = :rigor_check_rules_private_singleton_names
3027
+ private_constant :PRIVATE_NAMES_MEMO
3028
+
3029
+ # Issue #1984 — the table-only gate the singleton side asks before it reads a chain, as the instance side asks
3030
+ # its own table first: whether any class records `name` private, on its singleton side or on its instance
3031
+ # side (an extended module's private method is private on the extender's singleton). Without it every
3032
+ # `Widget.new` would read, and file the dependency edges of, the whole singleton chain. The set is memoised per
3033
+ # discovery index.
3034
+ def private_singleton_candidate?(scope, name)
3035
+ discovery = scope.discovery
3036
+ slot = Thread.current[PRIVATE_NAMES_MEMO]
3037
+ unless slot && slot[0].equal?(discovery)
3038
+ slot = [discovery, private_recorded_names(discovery.discovered_method_visibilities)]
3039
+ Thread.current[PRIVATE_NAMES_MEMO] = slot
3040
+ end
3041
+ slot[1].include?(name.to_sym)
3042
+ end
3043
+
3044
+ def private_recorded_names(tables)
3045
+ tables.each_value.with_object(Set.new) do |table, names|
3046
+ table.each { |slot, visibility| names << (slot.is_a?(Array) ? slot.last : slot) if visibility == :private }
3047
+ end
3048
+ end
3049
+
3050
+ # Issue #1984 — whether the call is outside every `def` the file's deferred ranges name. A `def` is named
3051
+ # 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
3052
+ # load time and may be reported. The top level, class bodies, and blocks and lambdas that are not such a `def`
3053
+ # (a `map { … }` or `define_method` block, a `-> { }`) are load time and decline: such code may run while the
3054
+ # files load, before a later `private_class_method` or in a load order where the private declaration has not
3055
+ # run yet.
3056
+ def load_time_call?(scope, call_node)
3057
+ rows = scope.discovered_deferred_ranges[scope.source_path]
3058
+ return true if rows.nil?
3059
+
3060
+ offset = call_node.location.start_offset
3061
+ rows.none? { |(start, finish, name, _kind, _owner)| !name.nil? && start <= offset && offset < finish }
3062
+ end
3063
+
3064
+ SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
3065
+ private_constant :SINGLETON_WILDCARD_SLOT
3066
+
3067
+ # Issue #1984 — whether a project class on `class_name`'s singleton chain has its wildcard singleton slot
3068
+ # contested: a call the walk saw may have made any of its singleton methods public.
3069
+ def singleton_wildcard_contested?(scope, class_name)
3070
+ discovery = scope.discovery
3071
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3072
+ chain.entries.any? do |entry|
3073
+ !entry.external? && discovery.contested?(:discovered_method_visibilities,
3074
+ [entry.name, SINGLETON_WILDCARD_SLOT])
3075
+ end
3076
+ end
3077
+
3078
+ VISIBILITY_MISSING_HOOKS = %i[method_missing respond_to_missing?].freeze
3079
+ private_constant :VISIBILITY_MISSING_HOOKS
3080
+
3081
+ # Issue #1984 — whether a project entry on `class_name`'s chain for `side` records a `method_missing` or
3082
+ # `respond_to_missing?` on the side that entry stands for (a singleton entry's singleton methods, a mixin's
3083
+ # instance methods), or the chain cannot be read whole. Ruby sends a private call to `method_missing`, so
3084
+ # such a hook may answer it.
3085
+ def missing_hook_on_chain?(scope, class_name, side)
3086
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, side, :methods)
3087
+ return true if chain.truncated?
3088
+
3089
+ chain.entries.any? do |entry|
3090
+ next false if entry.external?
3091
+
3092
+ kind = entry.side == :singleton ? :singleton : :instance
3093
+ VISIBILITY_MISSING_HOOKS.any? { |hook| records_hook?(scope, entry.name, hook, kind) }
3094
+ end
3095
+ end
3096
+
3097
+ def records_hook?(scope, owner, hook, kind)
3098
+ return true if scope.discovered_method?(owner, hook, kind)
3099
+
3100
+ !(kind == :singleton ? scope.singleton_def_for(owner, hook) : scope.user_def_for(owner, hook)).nil?
2891
3101
  end
2892
3102
 
2893
3103
  # Issue #1568, ADR-119 C1c — the recorded visibility of the definition a call on `class_name` reaches: the
@@ -2899,8 +3109,8 @@ module Rigor
2899
3109
  # producers contest is not alone (`DefinerResolution` asks the next candidate too), so it is `:undecided`
2900
3110
  # there without a read of the table here. nil is nothing recording the name at all, and the class's own
2901
3111
  # 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)
3112
+ def reached_definer_visibility(scope, class_name, method_name, side = :instance)
3113
+ case Inference::DefinerResolution.resolve(scope, class_name, method_name, side, question: :visibility)
2904
3114
  in Inference::DefinerResolution::Known(answer:)
2905
3115
  answer
2906
3116
  in Inference::DefinerResolution::UNKNOWN
@@ -2917,14 +3127,14 @@ module Rigor
2917
3127
  true
2918
3128
  end
2919
3129
 
2920
- def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
3130
+ def build_visibility_mismatch_diagnostic(path, call_node, receiver_name)
2921
3131
  Diagnostic.from_message_loc(
2922
3132
  call_node,
2923
3133
  rule: RULE_VISIBILITY_MISMATCH,
2924
3134
  path: path,
2925
- message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
3135
+ message: "private method `#{call_node.name}' called on #{receiver_name} receiver",
2926
3136
  severity: :error,
2927
- receiver_type: receiver_type.class_name,
3137
+ receiver_type: receiver_name,
2928
3138
  method_name: call_node.name.to_s
2929
3139
  )
2930
3140
  end
@@ -4001,6 +4211,9 @@ module Rigor
4001
4211
  # prove a reduction, stay silent.
4002
4212
  return nil if parent_visibility.nil?
4003
4213
  return nil unless visibility_reduced?(parent_visibility, override_visibility)
4214
+ # Issue #1569 — a module function's instance copy is private because `module_function` made it so; the
4215
+ # author's intent is the public singleton copy, so that reduction is not reported.
4216
+ return nil if scope.module_function_def?(class_name, def_node)
4004
4217
 
4005
4218
  build_override_visibility_diagnostic(
4006
4219
  path, def_node, parent_class, parent_visibility, override_visibility
@@ -6,6 +6,7 @@ require_relative "return_type_heuristic"
6
6
  require_relative "../../source/constant_path"
7
7
  require_relative "../../source/node_children"
8
8
  require_relative "../../inference/scope_indexer"
9
+ require_relative "../../rooted_glob"
9
10
 
10
11
  module Rigor
11
12
  module Analysis
@@ -127,7 +128,7 @@ module Rigor
127
128
  def walk_root(root_dir, harvest, budget) # rubocop:disable Naming/PredicateMethod
128
129
  return false unless File.directory?(root_dir)
129
130
 
130
- Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
131
+ RootedGlob.glob(root_dir, "**/*.rb").each do |path|
131
132
  harvest_file(path, harvest, budget)
132
133
  return true if harvest.catalog.size >= budget
133
134
  end
@@ -194,6 +194,55 @@ module Rigor
194
194
  moved.freeze
195
195
  end
196
196
 
197
+ # Issue #1833 — the `[class name, mark]` pairs that appeared in or vanished from the class marks of the files an
198
+ # edit touched: a Symbol key of a `discovered_parameter_envelopes` bucket (the class-wide marks of
199
+ # `Scope::DiscoveryIndex`) or a `[ENVELOPE_REMOVED_KIND, name]` entry. A file may mark a class it does not declare
200
+ # (`x.singleton_class.prepend(Comparable)`, `o.extend(Helper)` in a method body), so neither the class-declaration
201
+ # diff nor a reader's edge to the declaring files sees such a mark appear or vanish.
202
+ #
203
+ # `before` and `after` are lists of envelope tables (`{class name => bucket}`): the touched files' seed-bundle
204
+ # tables before the edit, and the merged table of the re-parsed files after it. The diff is of their unions, which
205
+ # moves whenever the project's union of marks moves (the untouched files contribute the same marks to both).
206
+ # Returns a frozen Set of frozen `[class name, mark]` pairs.
207
+ def changed_class_marks(before, after)
208
+ (class_marks(before) ^ class_marks(after)).freeze
209
+ end
210
+
211
+ def class_marks(tables)
212
+ marks = Set.new
213
+ tables.each do |table|
214
+ table&.each do |class_name, bucket|
215
+ bucket.each_key { |key| marks << [class_name, key].freeze if class_mark?(key) }
216
+ end
217
+ end
218
+ marks
219
+ end
220
+
221
+ def class_mark?(key)
222
+ key.is_a?(Symbol) || (key.is_a?(Array) && key.first == Scope::DiscoveryIndex::ENVELOPE_REMOVED_KIND)
223
+ end
224
+
225
+ # Issue #1661 — the names of the classes and modules whose unpositioned mixin edges
226
+ # (`Scope::DiscoveryIndex#unpositioned_mixins`, `"*"` included) appeared or vanished in the files an edit
227
+ # touched. A file can taint a module it does not otherwise change the chain of (`module Comparable;
228
+ # include(*MODS); end`, `Comparable.include(x) if c`), and an external chain entry has no declaring file
229
+ # for a reader's edge to name. `before` and `after` are lists of `{class => {side => [names]}}` tables,
230
+ # diffed as unions like {changed_class_marks}. Returns a frozen Set of class names.
231
+ def changed_unpositioned_owners(before, after)
232
+ moved = unpositioned_edges(before) ^ unpositioned_edges(after)
233
+ moved.to_set { |(owner, _side, _name)| owner }.freeze
234
+ end
235
+
236
+ def unpositioned_edges(tables)
237
+ edges = Set.new
238
+ tables.each do |table|
239
+ table&.each do |owner, sides|
240
+ sides.each { |side, names| names.each { |name| edges << [owner, side, name].freeze } }
241
+ end
242
+ end
243
+ edges
244
+ end
245
+
197
246
  # Issue #644 — the qualified constant names whose PUBLICATION could have moved in this edit: the names
198
247
  # whose per-file census descriptor differs between the before- and after-state of any file in `paths`.
199
248
  # The value-constant twin of {appeared_classes}, and deliberately NOT its analogue — a class either