rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -20,10 +20,18 @@ module Rigor
20
20
  #
21
21
  # Issue #1740 — {OwnMethod} shares {.levels} with the walk below and asks the opposite question for the two
22
22
  # argument checks: does the receiver's own method answer ahead of every refinement in effect?
23
+ #
24
+ # Issue #1752 — a class object is walked through its own lookup ({ClassObject}): its singleton class and each
25
+ # superclass's, then `Class`, `Module`, `Object`, `Kernel` and `BasicObject`, so a refinement of `Object` answers
26
+ # `Integer.oo` while `Integer.sqrt` keeps answering ahead of one.
23
27
  module RefinedDispatch
24
28
  # The refinement that answers: `module_name` refines `refined_class` (a class or module on the walk).
25
29
  Winner = Data.define(:module_name, :refined_class)
26
30
 
31
+ # Issue #1752 — the singleton class `#<Class:of>` as an entry of a class object's walk ({ClassObject}): it
32
+ # defines the name through `of`'s own singleton methods, and no refinement table key names it.
33
+ SingletonClass = Data.define(:of)
34
+
27
35
  # The answer when the in-effect list carries {InEffectRefinements::UNKNOWN} and some refinement defines the
28
36
  # name on a class the walk reaches: any of them may answer.
29
37
  UNKNOWN = :unknown
@@ -37,14 +45,16 @@ module Rigor
37
45
  def wildcard = Scope::DiscoveryIndex::REFINEMENT_WILDCARD
38
46
 
39
47
  # A {Winner}, {UNKNOWN}, or nil when no in-effect refinement answers `method_name` on an instance of
40
- # `class_name` (the class's own lookup does). `list` is the call site's in-effect refinements.
48
+ # `class_name` (the class's own lookup does), or with `kind: :singleton` on the class object `class_name`
49
+ # itself (issue #1752). `list` is the call site's in-effect refinements.
41
50
  #
42
51
  # ADR-121 WD7 — a row the walk could not read never yields a {Winner}: an in-effect module's row for the name
43
52
  # whose class the walk could not name reaches every receiver, and a names-wildcard row of a level the walk
44
53
  # reaches before a definer may define the name, so both answer {UNKNOWN}. So does a winner whose module shares
45
54
  # its last segment with another listed module (A1): which of a `using`'s declared candidates Ruby finds can
46
- # depend on load order.
47
- def winner(scope, class_name, method_name, list)
55
+ # depend on load order. A class object's walk always ends at `Object`, so where it cannot be read a row that
56
+ # may sit on it answers {UNKNOWN} too.
57
+ def winner(scope, class_name, method_name, list, kind: :instance)
48
58
  refinements = scope.discovered_refinements
49
59
  return UNKNOWN if class_unknown_row?(scope, refinements, method_name, list)
50
60
 
@@ -53,13 +63,26 @@ module Rigor
53
63
  unknown = list.include?(InEffectRefinements::UNKNOWN)
54
64
  return nil if targets.nil? && unread.nil? && !unknown
55
65
 
56
- levels = levels(scope, class_name)
66
+ levels = levels(scope, class_name, kind: kind)
67
+ return unread_class_object(scope, refinements, method_name, list) if levels.nil? && kind == :singleton
57
68
  return nil if levels.nil?
58
69
  return UNKNOWN if unknown && unknown_reaches?(scope, refinements, method_name, levels)
59
70
 
60
71
  walk_targets(scope, levels, method_name, [targets, unread], list)
61
72
  end
62
73
 
74
+ # {UNKNOWN} when some row for `method_name` (or a names-wildcard row) that `list` puts in effect, or any row
75
+ # under the unknown marker, names a class or module a class object may reach ({ClassObject.reachable?}).
76
+ def unread_class_object(scope, refinements, method_name, list)
77
+ unknown = list.include?(InEffectRefinements::UNKNOWN)
78
+ reaches = refinements.any? do |refined, methods|
79
+ rows = [methods[method_name], methods[wildcard]].compact
80
+ !rows.empty? && (unknown || rows.any? { |modules| in_effect?(modules, list) }) &&
81
+ ClassObject.reachable?(scope, refined)
82
+ end
83
+ reaches ? UNKNOWN : nil
84
+ end
85
+
63
86
  def walk_targets(scope, levels, method_name, (targets, unread), list)
64
87
  return nil if targets.nil? && unread.nil?
65
88
 
@@ -165,20 +188,40 @@ module Rigor
165
188
  # refinement first, as any level is; past that, the mixin may define the name, so a refinement further up the
166
189
  # walk may or may not win and the answer is {UNKNOWN}, never the replaced method's. ADR-121 WD7: a level an
167
190
  # in-effect module has a names-wildcard row for (`unread`) may define the name too, so it answers {UNKNOWN}.
191
+ #
192
+ # Issue #1752 — on a class object's walk a singleton level that only an entry no table names may answer
193
+ # (`:continue_unsure`) makes a refinement reached past it {UNKNOWN}: that entry may define the name.
168
194
  def walk(scope, levels, method_name, targets, unread)
169
- levels.each_with_index do |(level_class, entries, mixed), index|
170
- return UNKNOWN if level_class && unread.key?(level_class)
171
-
172
- refining = level_class && targets[level_class]
173
- return Winner.new(module_name: refining, refined_class: level_class) if refining
174
- return targeted_after?(levels, index, targets, unread) ? UNKNOWN : nil if mixed
195
+ unsure = false
196
+ levels.each_index do |index|
197
+ answer = level_answer(scope, levels, index, method_name, [targets, unread])
198
+ next unsure = true if answer == :continue_unsure
199
+ next if answer == :continue
175
200
 
176
- answer = walk_entries(scope, entries, level_class, method_name, targets, unread)
177
- return answer unless answer == :continue
201
+ return unsure && answer.is_a?(Winner) ? UNKNOWN : answer
178
202
  end
179
203
  nil
180
204
  end
181
205
 
206
+ def level_answer(scope, levels, index, method_name, (targets, unread))
207
+ level_class, entries, mixed, every = levels[index]
208
+ return UNKNOWN if level_class && unread.key?(level_class)
209
+
210
+ refining = level_class && targets[level_class]
211
+ return Winner.new(module_name: refining, refined_class: level_class) if refining
212
+ return targeted_after?(levels, index, targets, unread) ? UNKNOWN : nil if mixed
213
+
214
+ walk_level(scope, [entries, every], level_class, method_name, [targets, unread])
215
+ end
216
+
217
+ def walk_level(scope, (entries, every), level_class, method_name, (targets, unread))
218
+ if level_class.is_a?(SingletonClass)
219
+ return ClassObject.walk_level(scope, every || entries, method_name, targets, unread)
220
+ end
221
+
222
+ walk_entries(scope, entries, level_class, method_name, targets, unread)
223
+ end
224
+
182
225
  # A level's prepended modules, its own method and its included modules, in that order: `:continue` when none
183
226
  # answers, so the walk moves on.
184
227
  def walk_entries(scope, entries, level_class, method_name, targets, unread)
@@ -205,7 +248,10 @@ module Rigor
205
248
  # name. `every entry` (project levels only) is the level's entries in lookup order with each one that
206
249
  # neither the project's resolver nor RBS can name kept as its candidate names (an Array) where `entries`
207
250
  # leaves it out: the typer never reads it, and {OwnMethod} declines where a refinement may name one.
208
- def levels(scope, class_name)
251
+ # `kind: :singleton` answers the class object `class_name` instead ({ClassObject.levels}, issue #1752).
252
+ def levels(scope, class_name, kind: :instance)
253
+ return ClassObject.levels(scope, class_name) if kind == :singleton
254
+
209
255
  environment = scope.environment
210
256
  return rbs_levels(scope, class_name) if environment&.class_known?(class_name)
211
257
 
@@ -257,8 +303,9 @@ module Rigor
257
303
 
258
304
  # Does `name` define `method_name` on its instance side itself: a project `def` (its dependency edge recorded
259
305
  # through `Scope#user_def_for`), another project definer (`attr_*`, `define_method`), or an RBS declaration
260
- # written on `name` rather than on an ancestor?
306
+ # written on `name` rather than on an ancestor? A {SingletonClass} asks its own singleton methods.
261
307
  def defines?(scope, name, method_name)
308
+ return ClassObject.defines?(scope, name.of, method_name) if name.is_a?(SingletonClass)
262
309
  return true if scope.user_def_for(name, method_name)
263
310
  return true if scope.discovered_method?(name, method_name, :instance)
264
311
 
@@ -269,4 +316,5 @@ module Rigor
269
316
  end
270
317
  end
271
318
 
319
+ require_relative "refined_dispatch/class_object"
272
320
  require_relative "refined_dispatch/own_method"
@@ -20,6 +20,8 @@ module Rigor
20
20
  # `cache = {}; [1, 2].find { |e| (cache[:first] ||= e) == 2 }` answered its own `2`, where Ruby keeps the
21
21
  # first iteration's `1`, folded `find` to `2` and reported `found == 2` always-truthy. The pass marks the
22
22
  # sites this module returns (`Scope#with_repeated_or_writes`), and the reading is withheld at a marked site.
23
+ # `StatementEvaluator` types a repeating body from one entry scope too — its statement pass and every ADR-56
24
+ # write-back pass — and marks the generic pass's sites there (issue #1336).
23
25
  #
24
26
  # The mark is per site, not per receiver, because nothing about the receiver's type can say that its slot
25
27
  # was filled: `{}` widens to `Hash[Dynamic[top], Dynamic[top]]`, a bare `Hash.new` is not widened at all, and
@@ -101,13 +101,17 @@ module Rigor
101
101
  # bodies from their CONVERGED bindings so per-line probes
102
102
  # (`rigor annotate`) reflect the post-writeback state, not the
103
103
  # cap-N intermediate constants. Off for the check path.
104
+ # @param widen_clauses — check-path flag (issue #1231): a rescue or
105
+ # `ensure` clause records the variables the `begin` body writes as
106
+ # `Dynamic[top]`. Off for every other reader, so parameter inference
107
+ # and sig-gen observe the clause's plain entry.
104
108
  # @return identity-comparing
105
109
  # table whose default value is `default_scope`.
106
110
  # The no-op re-anchoring table (#722 residue 2): shared so the overwhelmingly common
107
111
  # nothing-to-re-anchor walk allocates none.
108
112
  EMPTY_RENAMES = {}.freeze
109
113
 
110
- def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
114
+ def index(root, default_scope:, converged_loop_recording: false, widen_clauses: false) # rubocop:disable Metrics/AbcSize
111
115
  # Slice A-declarations. Build the declaration overrides first so every scope handed to the StatementEvaluator
112
116
  # already carries the table; structural sharing through `Scope#with_local` / `#with_fact` / `#with_self_type`
113
117
  # propagates it across every derived scope.
@@ -194,7 +198,8 @@ module Rigor
194
198
 
195
199
  on_enter = ->(node, scope) { table[node] = scope }
196
200
  StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
197
- converged_loop_recording: converged_loop_recording).evaluate(root)
201
+ converged_loop_recording: converged_loop_recording,
202
+ widen_clauses: widen_clauses).evaluate(root)
198
203
 
199
204
  propagate(root, table, seeded_scope)
200
205
  table
@@ -3990,6 +3995,7 @@ module Rigor
3990
3995
  if methods_acc.mentions_refine? && methods_acc.refine_in?(location.start_offset, location.end_offset)
3991
3996
  node.rigor_each_child { |child| census_scan(child, qualified_prefix, methods_acc) }
3992
3997
  end
3998
+ record_foreign_singleton_def(node, qualified_prefix, owner_prefix, methods_acc.at(node))
3993
3999
  unless def_owner_prefix&.empty? || defs_singleton == :unnameable ||
3994
4000
  (singleton_cref && owner_prefix.empty?)
3995
4001
  singleton_def = in_singleton_class || defs_singleton
@@ -4729,6 +4735,50 @@ module Rigor
4729
4735
  acc[:deferred_ranges][path] = build_deferred_ranges(root)
4730
4736
  record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes,
4731
4737
  acc[:compact_headers])
4738
+ record_singleton_class_sources(acc[:singleton_class_sources], path, root)
4739
+ end
4740
+
4741
+ # `{name => Set[path]}` for a bundle's name list (a bundle written before the list existed has none).
4742
+ def bundle_path_sets(names, path) = (names || []).to_h { |name| [name, Set[path]] }
4743
+
4744
+ # Issue #1752 — the constants a `class << Const` body opens in this file, and those whose singleton side a call
4745
+ # there may rewrite ({#singleton_side_call?}). Such a body records facts about
4746
+ # `Const`'s singleton class (a `remove_method`, a `def`) without declaring `Const`, so it stays out of
4747
+ # `class_sources` (whose readers take a name there as one the project declares) and joins only the per-file
4748
+ # class-declaration sets the incremental `class:` edges diff (`Runner#class_declarations`).
4749
+ def record_singleton_class_sources(sources, path, root)
4750
+ collect_singleton_class_targets(root, []) { |name| (sources[name] ||= Set.new) << path }
4751
+ end
4752
+
4753
+ def collect_singleton_class_targets(node, lexical_prefix, &)
4754
+ case node
4755
+ when Prism::ClassNode, Prism::ModuleNode
4756
+ prefix = Source::ConstantPath.declaration_prefix(lexical_prefix, node.constant_path)
4757
+ node.body && prefix && collect_singleton_class_targets(node.body, prefix, &)
4758
+ return
4759
+ when Prism::SingletonClassNode
4760
+ rendered = singleton_receiver_constant_name(node.expression)
4761
+ yield eval_constant_receiver_prefix(node, node.expression, rendered, lexical_prefix).join("::") if rendered
4762
+ when Prism::CallNode
4763
+ yield_singleton_side_constant(node, node.receiver, lexical_prefix, &) if singleton_side_call?(node)
4764
+ end
4765
+ node.rigor_each_child { |child| collect_singleton_class_targets(child, lexical_prefix, &) }
4766
+ end
4767
+
4768
+ # A call on a constant that may rewrite its singleton side (`Const.singleton_class`,
4769
+ # `Const.define_singleton_method`, `Const.extend(M)`, `Const.instance_eval { … }`) records facts about `Const`'s
4770
+ # singleton methods the refinement walk reads from a file that need not declare `Const`, so the file joins the
4771
+ # constant's set too. Written inside `Const`'s own body, the name is in the file's set already; a `def Const.x`
4772
+ # files its own `method:` key.
4773
+ def singleton_side_call?(node)
4774
+ node.name == :singleton_class || surface_rewriting_call?(node)
4775
+ end
4776
+
4777
+ def yield_singleton_side_constant(node, receiver, lexical_prefix)
4778
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
4779
+
4780
+ rendered = singleton_receiver_constant_name(receiver)
4781
+ yield eval_constant_receiver_prefix(node, receiver, rendered, lexical_prefix).join("::") if rendered
4732
4782
  end
4733
4783
 
4734
4784
  def build_deferred_ranges(root)
@@ -7160,6 +7210,7 @@ module Rigor
7160
7210
  return record_module_function(node, current_class, accumulator, in_singleton) if node.name == :module_function
7161
7211
 
7162
7212
  taint_opaque_eval(node, current_class, accumulator)
7213
+ return if singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7163
7214
 
7164
7215
  kind, _arguments, via_send = mixin_call_view(node)
7165
7216
  return if kind.nil?
@@ -7172,6 +7223,49 @@ module Rigor
7172
7223
  effects.each { |owner, side| accumulator.taint(owner, side) }
7173
7224
  end
7174
7225
 
7226
+ # Issue #1752 — a call that rewrites a singleton class reached through `singleton_class` (`singleton_class.
7227
+ # class_eval { def x }`, `singleton_class.send(:define_method, :x)`, `Const.singleton_class.prepend(M)`,
7228
+ # `Const.singleton_class.send(:include, M)`) puts methods or modules on that singleton class that no edge
7229
+ # records, so its singleton side is unpositioned: the owner is the enclosing class for a bare or `self.`
7230
+ # receiver, else the constant. True when it tainted.
7231
+ def singleton_class_rewrite_tainted?(node, qualified_prefix, current_class, accumulator)
7232
+ receiver = node.receiver
7233
+ return false unless singleton_class_of_receiver?(receiver) && surface_rewriting_call?(node)
7234
+ return false if singleton_class_named_call?(node)
7235
+
7236
+ owner = singleton_class_owner(node, receiver.receiver, qualified_prefix, current_class)
7237
+ return false if owner.nil?
7238
+
7239
+ accumulator.taint(owner, :extend)
7240
+ true
7241
+ end
7242
+
7243
+ # A bare, `self.` or constant receiver's `singleton_class` call.
7244
+ def singleton_class_of_receiver?(receiver)
7245
+ receiver.is_a?(Prism::CallNode) && receiver.name == :singleton_class && receiver.arguments.nil?
7246
+ end
7247
+
7248
+ # An `attr_*` on a singleton class (`singleton_class.attr_accessor :conf`) rewrites only the names it spells, so
7249
+ # it makes those names' singleton envelopes opaque ({#record_singleton_class_names}) and leaves the rest of the
7250
+ # singleton side readable. An `attr_*` with a name it does not spell as a literal (`attr_accessor(*KEYS)`, a
7251
+ # block's variable), and an `alias_method` or `define_method` there, still taint the side: the own-definer read
7252
+ # would otherwise answer the body Ruby replaced (`definer_resolution_witness_spec.rb`).
7253
+ def singleton_class_named_call?(node)
7254
+ arguments = node.arguments&.arguments || []
7255
+ ATTR_MACROS.include?(node.name) && !arguments.empty? &&
7256
+ arguments.all? { |argument| literal_method_name(argument) }
7257
+ end
7258
+
7259
+ def singleton_class_owner(node, object, qualified_prefix, current_class)
7260
+ return current_class if object.nil? || object.is_a?(Prism::SelfNode)
7261
+
7262
+ rendered = Source::ConstantPath.qualified_name_or_nil(object)
7263
+ return nil if rendered.nil?
7264
+ return rendered if Source::ConstantPath.rooted?(object) || qualified_prefix.empty?
7265
+
7266
+ eval_constant_receiver_prefix(node, object, rendered, qualified_prefix).join("::")
7267
+ end
7268
+
7175
7269
  # The bare `module_function` toggle: the module extends itself. A direct statement only.
7176
7270
  def record_module_function(node, current_class, accumulator, in_singleton)
7177
7271
  return unless current_class && node.receiver.nil? && !in_singleton && ModuleFunctionState.extends_self?(node)
@@ -7758,6 +7852,42 @@ module Rigor
7758
7852
  constant_receiver_candidates(receiver, qualified_prefix).each do |name|
7759
7853
  record_surface_mark(tables, name, Scope::DiscoveryIndex::ENVELOPE_DYNAMIC_MARK)
7760
7854
  end
7855
+ elsif singleton_class_of_receiver?(receiver) && singleton_class_named_call?(node)
7856
+ record_singleton_class_names(node, receiver.receiver, qualified_prefix, tables)
7857
+ end
7858
+ end
7859
+
7860
+ # Issue #1752 — the names a {#singleton_class_named_call?} rewrites on its singleton class's owner, each
7861
+ # singleton envelope opaque: the evidence the refinement typer reads as a rewritten singleton name.
7862
+ def record_singleton_class_names(node, object, qualified_prefix, tables)
7863
+ current_class = qualified_prefix.empty? ? nil : qualified_prefix.join("::")
7864
+ owner = singleton_class_owner(node, object, qualified_prefix, current_class)
7865
+ return if owner.nil?
7866
+
7867
+ (node.arguments&.arguments || []).each do |argument|
7868
+ method_name = literal_method_name(argument)
7869
+ next unless method_name
7870
+
7871
+ names = [method_name]
7872
+ names << :"#{method_name}=" if %i[attr_writer attr_accessor].include?(node.name)
7873
+ names.each do |name|
7874
+ record_envelope(tables.envelopes, owner, [:singleton, name], Source::ParameterEnvelope::OPAQUE)
7875
+ contest_envelope(tables, owner, [:singleton, name]) unless tables.certain
7876
+ end
7877
+ end
7878
+ end
7879
+
7880
+ # Issue #1752 — `def Const.x` whose receiver is not the lexical `self` (written at the top level or in another
7881
+ # class's body) defines `Const`'s singleton method, which no table files under `Const`: its singleton name's
7882
+ # envelope turns opaque, the evidence the refinement typer reads as a rewritten singleton name.
7883
+ def record_foreign_singleton_def(node, qualified_prefix, owner_prefix, tables)
7884
+ receiver = node.receiver
7885
+ return unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
7886
+ return if def_receiver_targets_lexical_self?(receiver, owner_prefix)
7887
+
7888
+ constant_receiver_candidates(receiver, qualified_prefix).each do |name|
7889
+ record_envelope(tables.envelopes, name, [:singleton, node.name], Source::ParameterEnvelope::OPAQUE)
7890
+ contest_envelope(tables, name, [:singleton, node.name]) unless tables.certain
7761
7891
  end
7762
7892
  end
7763
7893
 
@@ -8804,6 +8934,9 @@ module Rigor
8804
8934
  merge_header_nestings(acc[:header_nestings], file_index[:header_nestings] || {})
8805
8935
  fold_mixin_lists(acc, file_index)
8806
8936
  file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
8937
+ (file_index[:singleton_class_sources] || {}).each do |cn, files|
8938
+ (acc[:singleton_class_sources][cn] ||= Set.new).merge(files)
8939
+ end
8807
8940
  # Issue #722 residue 2 — a pre-#722 seed bundle carries no candidates; the SCHEMA bump makes such a
8808
8941
  # blob a cold rebuild, but default so any in-flight fold stays total.
8809
8942
  acc[:compact_headers].merge!(file_index[:compact_headers] || {})
@@ -8892,6 +9025,7 @@ module Rigor
8892
9025
  # data, so the bundle stays Marshal-clean and a warm file joins the envelopes its cold walk records.
8893
9026
  parameter_envelopes: file_index[:parameter_envelopes],
8894
9027
  class_source_names: file_index[:class_sources].keys,
9028
+ singleton_class_source_names: (file_index[:singleton_class_sources] || {}).keys,
8895
9029
  # Issue #722 residue 2 — plain data, so the bundle stays Marshal-clean and a warm incremental file
8896
9030
  # re-anchors its compact headers the way a cold walk of it does.
8897
9031
  compact_headers: file_index[:compact_headers],
@@ -8916,7 +9050,7 @@ module Rigor
8916
9050
  # ADR-85 WD2 — reconstitutes a cached bundle into a single-file index {#fold_file_index} folds: the
8917
9051
  # def-node triples become {DefHandle}s bound to this file's `path`, and the class-source names become a
8918
9052
  # `{name => Set[path]}` table (the shape `accumulate_project_index` produces).
8919
- def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity -- one read per bundle slot
9053
+ def bundle_to_file_index(bundle, path) # rubocop:disable Metrics/AbcSize -- one read per bundle slot
8920
9054
  {
8921
9055
  def_nodes: bundle_defs_to_handles(bundle[:def_nodes], path),
8922
9056
  singleton_def_nodes: bundle_defs_to_handles(bundle[:singleton_def_nodes], path),
@@ -8937,7 +9071,8 @@ module Rigor
8937
9071
  method_visibilities: bundle[:method_visibilities],
8938
9072
  methods: bundle[:methods],
8939
9073
  parameter_envelopes: bundle[:parameter_envelopes] || {},
8940
- class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
9074
+ class_sources: bundle_path_sets(bundle[:class_source_names], path),
9075
+ singleton_class_sources: bundle_path_sets(bundle[:singleton_class_source_names], path),
8941
9076
  compact_headers: bundle[:compact_headers] || {},
8942
9077
  # Issue #644 — a pre-#644 bundle carries no census; the SCHEMA bump makes such a blob a cold
8943
9078
  # rebuild, but default so any in-flight fold stays total.
@@ -8987,7 +9122,7 @@ module Rigor
8987
9122
  method_visibilities: {},
8988
9123
  methods: {},
8989
9124
  deferred_ranges: {},
8990
- parameter_envelopes: {}, class_sources: {},
9125
+ parameter_envelopes: {}, class_sources: {}, singleton_class_sources: {},
8991
9126
  # Issue #722 residue 2 — compact-header re-anchor candidates, adjudicated in {#finalize_def_index}.
8992
9127
  compact_headers: {},
8993
9128
  constant_writes: {},