rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -49,9 +49,14 @@ module Rigor
49
49
  # @param default_scope [Rigor::Scope] the scope used for the root,
50
50
  # and the fallback returned for any Prism node not contained in
51
51
  # `root`'s subtree.
52
+ # @param converged_loop_recording [Boolean] display-path flag —
53
+ # when true the evaluator re-records fixpoint-tracked loop
54
+ # bodies from their CONVERGED bindings so per-line probes
55
+ # (`rigor annotate`) reflect the post-writeback state, not the
56
+ # cap-N intermediate constants. Off for the check path.
52
57
  # @return [Hash{Prism::Node => Rigor::Scope}] identity-comparing
53
58
  # table whose default value is `default_scope`.
54
- def index(root, default_scope:) # rubocop:disable Metrics/AbcSize
59
+ def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
55
60
  # Slice A-declarations. Build the declaration overrides
56
61
  # first so every scope handed to the StatementEvaluator
57
62
  # already carries the table; structural sharing through
@@ -146,7 +151,8 @@ module Rigor
146
151
  # entry is the one that reflects all flow-derived
147
152
  # rebinds, so it MUST overwrite the first.
148
153
  on_enter = ->(node, scope) { table[node] = scope }
149
- StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)
154
+ StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
155
+ converged_loop_recording: converged_loop_recording).evaluate(root)
150
156
 
151
157
  propagate(root, table, seeded_scope)
152
158
  table
@@ -164,6 +170,9 @@ module Rigor
164
170
  def_nodes = default_scope.discovered_def_nodes.merge(
165
171
  build_discovered_def_nodes(root)
166
172
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
173
+ singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge(
174
+ build_discovered_singleton_def_nodes(root)
175
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
167
176
  superclasses = default_scope.discovered_superclasses.merge(
168
177
  build_discovered_superclasses(root)
169
178
  )
@@ -185,6 +194,7 @@ module Rigor
185
194
  seeded_scope.with_discovery(
186
195
  seeded_scope.discovery.with(
187
196
  discovered_def_nodes: def_nodes,
197
+ discovered_singleton_def_nodes: singleton_def_nodes,
188
198
  discovered_superclasses: superclasses,
189
199
  discovered_includes: includes,
190
200
  discovered_method_visibilities: method_visibilities,
@@ -210,8 +220,15 @@ module Rigor
210
220
  mutated_ivars = {}
211
221
  read_before_write = {}
212
222
  init_writes = {}
223
+ # WD3 — per-class summary of `{class_name => {method_name =>
224
+ # Set<ivar names definitely assigned non-nil on every
225
+ # completing path>}}`, consulted by `dead_transient_nil_writes`
226
+ # so a ctor that reassigns `@x` indirectly through an
227
+ # unconditional same-class method call (`mask!`) credits the
228
+ # overwrite. Built once per program here, memoised by class.
229
+ method_assign_effects = build_method_assign_effects(root)
213
230
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
214
- read_before_write, init_writes)
231
+ read_before_write, init_writes, method_assign_effects)
215
232
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
216
233
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
217
234
  accumulator.transform_values(&:freeze).freeze
@@ -334,8 +351,8 @@ module Rigor
334
351
  end
335
352
  end
336
353
 
337
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity
338
- read_before_write = nil, init_writes = nil)
354
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists
355
+ read_before_write = nil, init_writes = nil, method_assign_effects = nil)
339
356
  return unless node.is_a?(Prism::Node)
340
357
 
341
358
  case node
@@ -361,13 +378,13 @@ module Rigor
361
378
  # read.
362
379
  collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
363
380
  walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
364
- mutated_ivars, read_before_write, init_writes)
381
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
365
382
  end
366
383
  return
367
384
  end
368
385
  when Prism::DefNode
369
386
  collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
370
- mutated_ivars, read_before_write, init_writes)
387
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
371
388
  return
372
389
  when Prism::CallNode
373
390
  if init_writes && !qualified_prefix.empty? &&
@@ -380,12 +397,12 @@ module Rigor
380
397
 
381
398
  node.compact_child_nodes.each do |child|
382
399
  walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
383
- mutated_ivars, read_before_write, init_writes)
400
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
384
401
  end
385
402
  end
386
403
 
387
- def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
388
- read_before_write = nil, init_writes = nil)
404
+ def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
405
+ read_before_write = nil, init_writes = nil, method_assign_effects = nil)
389
406
  return if def_node.body.nil? || qualified_prefix.empty?
390
407
 
391
408
  class_name = qualified_prefix.join("::")
@@ -399,7 +416,23 @@ module Rigor
399
416
  end
400
417
  body_scope = default_scope.with_self_type(self_type)
401
418
 
402
- gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
419
+ # C2 transient `@x = nil` dead-write elimination. When a
420
+ # method body opens with an unconditional `@x = nil`
421
+ # (defensive init) and then *definitely* reassigns `@x` to a
422
+ # non-nil value on every completing path (a later
423
+ # unconditional statement-level write, OR an `if/else` whose
424
+ # both branches write `@x`), the opening nil is dead — it can
425
+ # never be observed at method exit. Recording it anyway folds
426
+ # a spurious `nil` constituent into the flow-insensitive
427
+ # class-ivar union, which then poisons reads in OTHER methods
428
+ # (e.g. ipaddr `IN4MASK ^ @mask_addr` rejects the resulting
429
+ # `Integer | nil`). The set holds the `object_id`s of the
430
+ # transient write nodes to skip; soundness is post-domination
431
+ # at the top statement level, so dropping the nil never hides
432
+ # a real runtime-nil read.
433
+ dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects)
434
+ gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
435
+ EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
403
436
 
404
437
  # B2.3 — collect per-method evidence for the read-before-
405
438
  # write nil contribution. The accumulator-level decision
@@ -589,13 +622,29 @@ module Rigor
589
622
  private_constant :EMPTY_GUARDED_IVARS
590
623
 
591
624
  def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
592
- mutated_ivars = nil)
625
+ mutated_ivars = nil, dead_writes = nil)
593
626
  return unless node.is_a?(Prism::Node)
594
627
 
595
- if node.is_a?(Prism::InstanceVariableWriteNode)
628
+ if node.is_a?(Prism::InstanceVariableWriteNode) &&
629
+ !(dead_writes && dead_writes.include?(node.object_id))
596
630
  record_ivar_write(node, scope, class_name, accumulator,
597
631
  guarded: guarded_ivars.include?(node.name))
598
632
  end
633
+
634
+ # N1 — parallel / multiple assignment (`old, @cb = @cb, block`,
635
+ # `@i, @o, @e, @thr = Open3.popen3(cmd)`). A direct
636
+ # `InstanceVariableWriteNode` is the only write form this
637
+ # collector handled, so an ivar appearing as a `MultiWriteNode`
638
+ # target was silently dropped from the class-ivar union — leaving
639
+ # it to seed as pure `nil` (from a sibling `@cb = nil` ctor write,
640
+ # or absent entirely) and false-fire `if @cb` always-falsey /
641
+ # `@thr.alive?` undefined-for-nil. Record each ivar target with
642
+ # its tuple-position RHS type where the RHS is array/tuple-shaped,
643
+ # else the unanalyzable floor (the same `Dynamic[top]` a single
644
+ # write to an unknown RHS records — an unanalyzable multi-write
645
+ # means unknown, not nil).
646
+ record_multi_write_ivars(node, scope, class_name, accumulator)
647
+
599
648
  record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
600
649
 
601
650
  # Don't recurse into nested defs, classes, or modules; their
@@ -603,12 +652,13 @@ module Rigor
603
652
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
604
653
 
605
654
  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
606
- walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
655
+ walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
656
+ mutated_ivars, dead_writes)
607
657
  return
608
658
  end
609
659
 
610
660
  node.compact_child_nodes.each do |c|
611
- gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
661
+ gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
612
662
  end
613
663
  end
614
664
 
@@ -646,16 +696,22 @@ module Rigor
646
696
  # reads of `@x` would then surface a nil-receiver FP. The
647
697
  # ELSE branch is left ungarded so those reads continue to type
648
698
  # as they did before this fix.
649
- def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil)
699
+ def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
700
+ mutated_ivars = nil, dead_writes = nil)
650
701
  then_guards = then_body_guarded_ivars(node)
651
702
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
652
703
 
653
- gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
704
+ gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
705
+ mutated_ivars, dead_writes)
654
706
  if node.statements
655
- gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars)
707
+ gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
708
+ mutated_ivars, dead_writes)
656
709
  end
657
710
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
658
- gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars, mutated_ivars) if branch
711
+ return unless branch
712
+
713
+ gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
714
+ mutated_ivars, dead_writes)
659
715
  end
660
716
 
661
717
  # Returns the set of ivar names that, in the THEN body of this
@@ -723,6 +779,332 @@ module Rigor
723
779
  end
724
780
  end
725
781
 
782
+ # C2 — returns a Set of `object_id`s for transient `@x = nil`
783
+ # writes that a later statement in the same method body
784
+ # *definitely* overwrites with a non-nil value on every
785
+ # completing path. Such a nil can never be the ivar's value at
786
+ # method exit, so it must not contribute a `nil` constituent to
787
+ # the (flow-insensitive) class-ivar union.
788
+ #
789
+ # Scope is deliberately narrow and post-domination-sound:
790
+ # - only the top-level statement sequence of the body is
791
+ # considered (no writes hidden inside loops / rescue / nested
792
+ # conditionals count as the "definite" overwrite, except the
793
+ # one structured `if/else` form below);
794
+ # - the killing statement is either an unconditional
795
+ # statement-level `@x = <non-nil>`, OR an `if/else` (with a
796
+ # real `else`) where BOTH branches' final top-level write to
797
+ # `@x` is non-nil. Both shapes overwrite `@x` on every path;
798
+ # - only `@x = nil` literal writes are ever marked dead — a
799
+ # non-nil transient is left untouched (it is already
800
+ # precision-additive in the union).
801
+ # WD3 — ADR-41-style hard cap on how deep the same-class-call
802
+ # definite-assignment crediting recurses (the ctor calls
803
+ # `mask!`, which could itself call another same-class helper).
804
+ # Cycle-guarded independently; the cap bounds even acyclic
805
+ # chains.
806
+ SAME_CLASS_CALL_DEPTH_CAP = 3
807
+ private_constant :SAME_CLASS_CALL_DEPTH_CAP
808
+
809
+ # WD3 — builds the per-class definite-assignment summary
810
+ # `{class_name => {method_name => Set<ivar names assigned
811
+ # non-nil on every completing path>}}`. Used so a ctor's
812
+ # `dead_transient_nil_writes` can credit an indirect overwrite
813
+ # through an unconditionally-called same-class method (ipaddr's
814
+ # `initialize` reassigns `@mask_addr` via `mask!`).
815
+ #
816
+ # Each method's set is computed by the same suffix
817
+ # definite-assignment analysis used for the ctor seed, run from
818
+ # the method body's first statement for every ivar the method
819
+ # writes anywhere. Same-class calls inside a method are credited
820
+ # transitively (depth-capped, cycle-guarded) so the resulting
821
+ # FLAT table is correct at depth 0 for the ctor lookup.
822
+ def build_method_assign_effects(root)
823
+ defs = collect_class_method_defs(root)
824
+ effects = {}
825
+ memo = {}.compare_by_identity
826
+ defs.each do |class_name, methods|
827
+ methods.each do |method_name, def_node|
828
+ assigns = method_definite_assigns(class_name, method_name, def_node, defs, effects, memo, 0)
829
+ (effects[class_name] ||= {})[method_name] = assigns unless assigns.empty?
830
+ end
831
+ end
832
+ effects.freeze
833
+ end
834
+
835
+ # Collects `{class_name => {method_name => DefNode}}` for every
836
+ # instance-method def in the program. Singleton defs (`def
837
+ # self.x`) are excluded — the ctor-call crediting only follows
838
+ # instance-method calls on `self`. Last def wins on redefinition.
839
+ def collect_class_method_defs(root, prefix = [], acc = {})
840
+ return acc unless root.is_a?(Prism::Node)
841
+
842
+ case root
843
+ when Prism::ClassNode, Prism::ModuleNode
844
+ name = qualified_name_for(root.constant_path)
845
+ if name && root.body
846
+ child = prefix + [name]
847
+ collect_class_method_defs(root.body, child, acc)
848
+ end
849
+ return acc
850
+ when Prism::DefNode
851
+ (acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver
852
+ return acc
853
+ end
854
+
855
+ root.compact_child_nodes.each { |c| collect_class_method_defs(c, prefix, acc) }
856
+ acc
857
+ end
858
+
859
+ # Computes the definite-assignment set for one method, memoised
860
+ # per def node. The `memo` cycle-guards: a method re-entered
861
+ # while its own summary is in progress contributes nothing
862
+ # (sound under-approximation), so mutual recursion terminates.
863
+ def method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth)
864
+ return Set.new if def_node.body.nil?
865
+ return memo[def_node] if memo.key?(def_node)
866
+ return Set.new if depth >= SAME_CLASS_CALL_DEPTH_CAP
867
+
868
+ memo[def_node] = Set.new # in-progress sentinel (cycle guard)
869
+ statements = top_level_statements(def_node.body)
870
+ candidates = ivar_write_targets(def_node.body)
871
+ # A transient `@x = nil` opener whose own method reassigns it
872
+ # later must still count `@x` as assigned for callers, so the
873
+ # crediting is computed at the BUILD-time depth.
874
+ resolver = MethodEffectResolver.new(self, class_name, defs, effects, memo, depth)
875
+ assigns = Set.new
876
+ candidates.each do |ivar|
877
+ assigns << ivar if suffix_definitely_assigns_with_resolver?(statements, 0, ivar, class_name, resolver, depth)
878
+ end
879
+ memo[def_node] = assigns
880
+ end
881
+
882
+ # Every ivar this body assigns a non-nil value to ANYWHERE (the
883
+ # candidate set for the method's definite-assignment scan).
884
+ def ivar_write_targets(node, acc = Set.new)
885
+ return acc unless node.is_a?(Prism::Node)
886
+
887
+ acc << node.name if node.is_a?(Prism::InstanceVariableWriteNode) && !nil_literal_value?(node.value)
888
+ node.compact_child_nodes.each { |c| ivar_write_targets(c, acc) }
889
+ acc
890
+ end
891
+
892
+ # Build-time variant of `suffix_definitely_assigns?` that resolves
893
+ # same-class calls through the lazy `resolver` (which recurses
894
+ # into `method_definite_assigns` for not-yet-computed callees)
895
+ # rather than the finished flat table.
896
+ def suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth)
897
+ statements[from..].each do |stmt|
898
+ outcome = statement_assignment_outcome(stmt, target, class_name, resolver, depth, nil)
899
+ return true if outcome == :assigned
900
+ return false if outcome == :terminates_unassigned
901
+ end
902
+ false
903
+ end
904
+
905
+ # Adapts `effects.dig(class, method)` for build-time crediting:
906
+ # when the callee summary is not yet in the flat table, compute
907
+ # it on demand (depth+1) via `method_definite_assigns`.
908
+ class MethodEffectResolver
909
+ def initialize(indexer, class_name, defs, effects, memo, depth)
910
+ @indexer = indexer
911
+ @class_name = class_name
912
+ @defs = defs
913
+ @effects = effects
914
+ @memo = memo
915
+ @depth = depth
916
+ end
917
+
918
+ def dig(class_name, method_name)
919
+ existing = @effects.dig(class_name, method_name)
920
+ return existing if existing
921
+
922
+ def_node = @defs.dig(class_name, method_name)
923
+ return nil if def_node.nil?
924
+
925
+ @indexer.send(:method_definite_assigns, class_name, method_name, def_node, @defs, @effects, @memo,
926
+ @depth + 1)
927
+ end
928
+ end
929
+
930
+ def dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil)
931
+ statements = top_level_statements(body)
932
+ return nil if statements.length < 2
933
+
934
+ dead = nil
935
+
936
+ statements.each_with_index do |stmt, i|
937
+ next unless stmt.is_a?(Prism::InstanceVariableWriteNode) && nil_literal_value?(stmt.value)
938
+
939
+ # The opening `@x = nil` is dead when every completing path
940
+ # of the SUFFIX after it (normal end OR early `return`,
941
+ # never a `raise`-terminated path) definitely reassigns
942
+ # `@x` non-nil. The suffix analysis credits an
943
+ # unconditionally-called same-class method's own definite
944
+ # assignments via `method_assign_effects`.
945
+ if suffix_definitely_assigns?(statements, i + 1, stmt.name, class_name, method_assign_effects)
946
+ (dead ||= Set.new) << stmt.object_id
947
+ end
948
+ end
949
+
950
+ dead
951
+ end
952
+
953
+ def top_level_statements(body)
954
+ return [] if body.nil?
955
+ return body.body if body.is_a?(Prism::StatementsNode)
956
+
957
+ [body]
958
+ end
959
+
960
+ def nil_literal_value?(node)
961
+ node.is_a?(Prism::NilNode)
962
+ end
963
+
964
+ # True when, starting from `statements[from]`, EVERY path that
965
+ # completes the method (falls off the end OR hits an early
966
+ # `return`) definitely assigns `target` a non-nil value first.
967
+ # Paths terminated by `raise` are not completing paths and are
968
+ # ignored (they never observe the ivar at method exit). A path
969
+ # that can fall through `statements` without assigning fails.
970
+ def suffix_definitely_assigns?(statements, from, target, class_name, effects)
971
+ statements[from..].each do |stmt|
972
+ outcome = statement_assignment_outcome(stmt, target, class_name, effects, 0, nil)
973
+ # The statement assigned on every continuing path -> the
974
+ # suffix is satisfied no matter what follows.
975
+ return true if outcome == :assigned
976
+ # The statement terminates control here (return/raise) and
977
+ # the value it carried did not assign on every path -> some
978
+ # completing path reached exit without the assignment.
979
+ return false if outcome == :terminates_unassigned
980
+ # Otherwise (:falls_through_unassigned) keep scanning the
981
+ # remaining statements.
982
+ end
983
+ # Fell off the end with no definite assignment.
984
+ false
985
+ end
986
+
987
+ # Classifies a single statement's effect on `target`:
988
+ # :assigned — every path through the statement
989
+ # that continues OR returns assigns
990
+ # `target` non-nil (suffix is done);
991
+ # :terminates_unassigned — the statement ends the method
992
+ # (return/raise) on some path
993
+ # without a definite assignment, so
994
+ # a completing path escaped;
995
+ # :falls_through_unassigned — control may continue past it
996
+ # without the assignment (keep
997
+ # scanning the suffix).
998
+ def statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
999
+ case stmt
1000
+ when Prism::InstanceVariableWriteNode
1001
+ return :falls_through_unassigned if stmt.name != target
1002
+
1003
+ nil_literal_value?(stmt.value) ? :falls_through_unassigned : :assigned
1004
+ when Prism::CallNode
1005
+ if unconditional_call_assigns?(stmt, target, class_name, effects, depth, visiting)
1006
+ :assigned
1007
+ else
1008
+ :falls_through_unassigned
1009
+ end
1010
+ when Prism::IfNode, Prism::UnlessNode
1011
+ conditional_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1012
+ when Prism::CaseNode
1013
+ case_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1014
+ when Prism::ReturnNode
1015
+ :terminates_unassigned
1016
+ else
1017
+ # Any other statement — including a bare `raise`/`fail`,
1018
+ # which terminates without a completing path that observes
1019
+ # the seed nil — is neutral: control either continues or the
1020
+ # path never reaches method exit. Keep scanning the suffix.
1021
+ :falls_through_unassigned
1022
+ end
1023
+ end
1024
+
1025
+ # True when a branch body (a StatementsNode / single node)
1026
+ # definitely assigns `target` non-nil on every path that
1027
+ # completes the method through it, OR terminates every path by
1028
+ # raise (vacuously safe — no completing path observes the seed
1029
+ # nil). Returns false if any path can complete/return without the
1030
+ # assignment.
1031
+ def branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting)
1032
+ stmts = top_level_statements(branch)
1033
+ return false if stmts.empty?
1034
+
1035
+ stmts.each do |stmt|
1036
+ outcome = statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1037
+ return true if outcome == :assigned
1038
+ return false if outcome == :terminates_unassigned
1039
+ end
1040
+ # Reached the end of the branch without a definite assignment;
1041
+ # safe only if the branch's last statement always raises (no
1042
+ # completing path falls out of it).
1043
+ always_raises?(stmts.last)
1044
+ end
1045
+
1046
+ # `if`/`unless` is a definite assignment of `target` only when
1047
+ # BOTH the then and else arms definitely assign (or raise-out).
1048
+ # A missing else arm means the fall-through path skips the
1049
+ # assignment -> not definite. Modifier-form `if`/`unless` (no
1050
+ # else, single predicate'd statement) likewise.
1051
+ def conditional_assignment_outcome(node, target, class_name, effects, depth, visiting)
1052
+ else_branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
1053
+ return :falls_through_unassigned unless else_branch.is_a?(Prism::ElseNode)
1054
+ return :falls_through_unassigned unless node.statements
1055
+
1056
+ then_ok = branch_definitely_assigns?(node.statements, target, class_name, effects, depth, visiting)
1057
+ else_ok = branch_definitely_assigns?(else_branch.statements, target, class_name, effects, depth, visiting)
1058
+ then_ok && else_ok ? :assigned : :falls_through_unassigned
1059
+ end
1060
+
1061
+ # `case` is a definite assignment only when there is a real
1062
+ # `else` clause AND every `when`/`in` body plus the else body
1063
+ # definitely assigns (or raises-out). A missing else lets an
1064
+ # unmatched subject fall through unassigned.
1065
+ def case_assignment_outcome(node, target, class_name, effects, depth, visiting)
1066
+ else_clause = node.else_clause
1067
+ return :falls_through_unassigned unless else_clause.is_a?(Prism::ElseNode)
1068
+
1069
+ branches = node.conditions.map { |c| c.respond_to?(:statements) ? c.statements : nil }
1070
+ branches << else_clause.statements
1071
+ all_ok = branches.all? do |b|
1072
+ branch_definitely_assigns?(b, target, class_name, effects, depth, visiting)
1073
+ end
1074
+ all_ok ? :assigned : :falls_through_unassigned
1075
+ end
1076
+
1077
+ # True when `node` (a single statement or its last statement) is
1078
+ # an unconditional `raise`/`fail` call that always terminates the
1079
+ # path — used to treat raise-terminated branches as
1080
+ # non-completing (they never observe the seed nil).
1081
+ def always_raises?(node)
1082
+ node = top_level_statements(node).last if node.is_a?(Prism::StatementsNode)
1083
+ return false unless node.is_a?(Prism::CallNode)
1084
+ return false unless node.receiver.nil?
1085
+
1086
+ %i[raise fail].include?(node.name)
1087
+ end
1088
+
1089
+ # True when `call` is an unconditional, statement-level,
1090
+ # implicit-self (or `self.`) call to a SAME-CLASS method whose
1091
+ # definite-assignment summary includes `target`. Calls through a
1092
+ # block, on another receiver, or to an unresolved name contribute
1093
+ # nothing (the seed nil stays).
1094
+ def unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting)
1095
+ return false if effects.nil? || class_name.nil?
1096
+ return false if depth >= SAME_CLASS_CALL_DEPTH_CAP
1097
+ return false unless call.is_a?(Prism::CallNode)
1098
+ return false unless call.block.nil?
1099
+ # Implicit self (`mask!(x)`) or explicit `self.mask!(x)` only.
1100
+ return false unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
1101
+
1102
+ assigns = effects.dig(class_name, call.name)
1103
+ return false if assigns.nil?
1104
+
1105
+ assigns.include?(target)
1106
+ end
1107
+
726
1108
  def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
727
1109
  rvalue_type = scope.type_of(node.value)
728
1110
 
@@ -747,10 +1129,104 @@ module Rigor
747
1129
  return if guarded && falsey_constant?(rvalue_type)
748
1130
 
749
1131
  rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
1132
+ accumulate_ivar_type(accumulator, class_name, node.name, rvalue_type)
1133
+ end
1134
+
1135
+ # Unions `type` into the class-ivar accumulator for `(class_name,
1136
+ # ivar_name)`. Shared by the single-write and multi-write
1137
+ # (parallel-assignment) collectors.
1138
+ def accumulate_ivar_type(accumulator, class_name, ivar_name, type)
750
1139
  accumulator[class_name] ||= {}
751
- existing = accumulator[class_name][node.name]
752
- accumulator[class_name][node.name] =
753
- existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
1140
+ existing = accumulator[class_name][ivar_name]
1141
+ accumulator[class_name][ivar_name] =
1142
+ existing ? Type::Combinator.union(existing, type) : type
1143
+ end
1144
+
1145
+ # N1 — records each `InstanceVariableTargetNode` of a
1146
+ # `MultiWriteNode` (parallel / multiple assignment) into the
1147
+ # class-ivar union, with the best cheap per-slot type. When the RHS
1148
+ # is array/tuple-shaped (`Type::Tuple`) the ivar at position `i`
1149
+ # records the type of element `i`; otherwise — an unanalyzable RHS
1150
+ # such as `Open3.popen3(cmd)` typing to `Dynamic[top]` — every ivar
1151
+ # slot records that unanalyzable floor (NOT `nil`: a multi-write we
1152
+ # cannot decompose means the value is *unknown*, and `Dynamic[top]`
1153
+ # is the sound union constituent, mirroring what a single write to
1154
+ # an unknown RHS records). Nested targets (`(@a, @b), @c = …`)
1155
+ # recurse with the slot's type as the new RHS type.
1156
+ def record_multi_write_ivars(node, scope, class_name, accumulator)
1157
+ return unless node.is_a?(Prism::MultiWriteNode)
1158
+
1159
+ rhs_type = scope.type_of(node.value)
1160
+ record_multi_target_ivars(node, rhs_type, class_name, accumulator)
1161
+ end
1162
+
1163
+ # Walks a `MultiWriteNode` / `MultiTargetNode` target tree against
1164
+ # `rhs_type`, recording ivar targets per slot. Mirrors
1165
+ # `MultiTargetBinder`'s tuple decomposition but for ivar (rather
1166
+ # than local-variable) targets.
1167
+ def record_multi_target_ivars(node, rhs_type, class_name, accumulator)
1168
+ lefts = node.lefts || []
1169
+ rest = node.rest
1170
+ rights = node.rights || []
1171
+ fronts, rest_type, backs =
1172
+ decompose_multi_write_rhs(rhs_type, lefts.size, rights.size, rest_present: !rest.nil?)
1173
+
1174
+ lefts.each_with_index { |t, i| record_multi_ivar_target(t, fronts[i], class_name, accumulator) }
1175
+ record_multi_ivar_rest(rest, rest_type, class_name, accumulator) if rest
1176
+ rights.each_with_index { |t, i| record_multi_ivar_target(t, backs[i], class_name, accumulator) }
1177
+ end
1178
+
1179
+ def decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:)
1180
+ if rhs_type.is_a?(Type::Tuple)
1181
+ elements = rhs_type.elements
1182
+ fronts = Array.new(front_count) { |i| multi_write_slot_type(elements, i) }
1183
+ if rest_present
1184
+ middle_end = [elements.size - back_count, front_count].max
1185
+ backs = Array.new(back_count) { |i| multi_write_slot_type(elements, middle_end + i) }
1186
+ [fronts, Type::Combinator.untyped, backs]
1187
+ else
1188
+ backs = Array.new(back_count) { |i| multi_write_slot_type(elements, front_count + i) }
1189
+ [fronts, nil, backs]
1190
+ end
1191
+ else
1192
+ # Unanalyzable / non-tuple RHS: every slot is the unknown floor.
1193
+ floor = Type::Combinator.untyped
1194
+ [Array.new(front_count) { floor }, rest_present ? floor : nil, Array.new(back_count) { floor }]
1195
+ end
1196
+ end
1197
+
1198
+ # The per-slot type for index `i` of a tuple RHS. A missing slot
1199
+ # (over-destructure) is `nil` at runtime; a present slot keeps its
1200
+ # type. Unlike the local-variable binder we do NOT soften an
1201
+ # optional slot here — a class-ivar seed deliberately preserves a
1202
+ # genuine `T | nil`, and any spurious nil is removed by the
1203
+ # flow-side narrowing, not by dropping it at collection time.
1204
+ def multi_write_slot_type(elements, index)
1205
+ element = elements[index]
1206
+ return Type::Combinator.constant_of(nil) if element.nil?
1207
+
1208
+ element
1209
+ end
1210
+
1211
+ def record_multi_ivar_target(target, type, class_name, accumulator)
1212
+ case target
1213
+ when Prism::InstanceVariableTargetNode
1214
+ accumulate_ivar_type(accumulator, class_name, target.name, type)
1215
+ when Prism::MultiTargetNode
1216
+ record_multi_target_ivars(target, type, class_name, accumulator)
1217
+ end
1218
+ end
1219
+
1220
+ def record_multi_ivar_rest(splat_node, _type, class_name, accumulator)
1221
+ return unless splat_node.is_a?(Prism::SplatNode)
1222
+
1223
+ expression = splat_node.expression
1224
+ return unless expression.is_a?(Prism::InstanceVariableTargetNode)
1225
+
1226
+ # A splat collects the middle slots into an Array; the precise
1227
+ # element type is not worth recovering here. Record the
1228
+ # unanalyzable floor (an Array of unknown), never nil.
1229
+ accumulate_ivar_type(accumulator, class_name, expression.name, Type::Combinator.untyped)
754
1230
  end
755
1231
 
756
1232
  def falsey_constant?(type)
@@ -1175,6 +1651,146 @@ module Rigor
1175
1651
  accumulator[class_name][def_node.name] = def_node
1176
1652
  end
1177
1653
 
1654
+ # Module-singleton call resolution (ADR-57 follow-up) — the
1655
+ # SINGLETON-side mirror of `build_discovered_def_nodes`. Records the
1656
+ # `Prism::DefNode` for every singleton-side method (`def self.x`,
1657
+ # `def Foo.x`, a `class << self` body, and a `module_function`
1658
+ # method) keyed by qualified class/module name → method → node, so
1659
+ # `ExpressionTyper` can re-type the body when a `Singleton[Foo]`
1660
+ # receiver dispatches `Foo.x`. The instance-side table is kept
1661
+ # singleton-free on purpose (its ancestor walk binds `self` as
1662
+ # `Nominal`), so the two never overlap except for `module_function`
1663
+ # defs, which are genuinely callable on both sides and so appear in
1664
+ # both tables. Top-level singleton defs (`def self.x` outside any
1665
+ # class — `self` is `main`) are not recorded; they have no constant
1666
+ # receiver to dispatch through.
1667
+ def build_discovered_singleton_def_nodes(root)
1668
+ accumulator = {}
1669
+ walk_singleton_def_nodes(root, [], false, accumulator)
1670
+ accumulator.transform_values(&:freeze).freeze
1671
+ end
1672
+
1673
+ # Walks every node, entering class/module/singleton-class bodies via
1674
+ # {#walk_singleton_body} so a bare `module_function` toggle threads
1675
+ # correctly across the body's *sibling* statements (a child-by-child
1676
+ # recursion would reset it). At the top level / inside an arbitrary
1677
+ # node there is no `module_function` state to carry, so descent is a
1678
+ # plain per-child walk.
1679
+ def walk_singleton_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
1680
+ return unless node.is_a?(Prism::Node)
1681
+
1682
+ case node
1683
+ when Prism::ClassNode, Prism::ModuleNode
1684
+ name = qualified_name_for(node.constant_path)
1685
+ if name
1686
+ walk_singleton_body(node.body, qualified_prefix + [name], false, accumulator) if node.body
1687
+ return
1688
+ end
1689
+ when Prism::SingletonClassNode
1690
+ if node.body
1691
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
1692
+ if singleton_prefix
1693
+ walk_singleton_body(node.body, singleton_prefix, true, accumulator)
1694
+ return
1695
+ end
1696
+ end
1697
+ when Prism::ConstantWriteNode
1698
+ if meta_new_block_body(node)
1699
+ child_prefix = qualified_prefix + [node.name.to_s]
1700
+ walk_singleton_body(meta_new_block_body(node), child_prefix, false, accumulator)
1701
+ return
1702
+ end
1703
+ when Prism::DefNode
1704
+ record_singleton_def_node(node, qualified_prefix, in_singleton_class, false, accumulator)
1705
+ return
1706
+ end
1707
+
1708
+ node.compact_child_nodes.each do |child|
1709
+ walk_singleton_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
1710
+ end
1711
+ end
1712
+
1713
+ # Walks a class/module/singleton-class body's direct statements in
1714
+ # source order, threading the bare-`module_function` toggle: once a
1715
+ # bare `module_function` is seen, every subsequent `def` in the body
1716
+ # registers as a singleton method. Nested classes/modules/defs and
1717
+ # `module_function :a, :b` named forms recurse / record through the
1718
+ # general walker so the toggle stays scoped to its own body.
1719
+ def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator)
1720
+ module_function_on = false
1721
+ statements_of(body).each do |stmt|
1722
+ if stmt.is_a?(Prism::CallNode) && module_function_toggle?(stmt)
1723
+ if bare_module_function?(stmt)
1724
+ module_function_on = true
1725
+ else
1726
+ record_module_function_names(stmt, qualified_prefix, body, accumulator)
1727
+ end
1728
+ next
1729
+ end
1730
+ if stmt.is_a?(Prism::DefNode)
1731
+ record_singleton_def_node(stmt, qualified_prefix, in_singleton_class, module_function_on, accumulator)
1732
+ next
1733
+ end
1734
+ walk_singleton_def_nodes(stmt, qualified_prefix, in_singleton_class, accumulator)
1735
+ end
1736
+ end
1737
+
1738
+ # Direct statement children of a class/module body node (a
1739
+ # `Prism::StatementsNode`, a `Prism::BeginNode` wrapping one, or a
1740
+ # lone statement). Returns an empty list for an empty body.
1741
+ def statements_of(body)
1742
+ case body
1743
+ when Prism::StatementsNode then body.body
1744
+ when Prism::BeginNode then statements_of(body.statements)
1745
+ when nil then []
1746
+ else [body]
1747
+ end
1748
+ end
1749
+
1750
+ def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator)
1751
+ singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on
1752
+ return unless singleton
1753
+ return if qualified_prefix.empty?
1754
+
1755
+ class_name = qualified_prefix.join("::")
1756
+ (accumulator[class_name] ||= {})[def_node.name] = def_node
1757
+ end
1758
+
1759
+ # A bare `module_function` (no arguments) flips every following `def`
1760
+ # in the module body to module-function (instance + singleton) mode.
1761
+ def module_function_toggle?(node)
1762
+ node.name == :module_function && node.receiver.nil?
1763
+ end
1764
+
1765
+ def bare_module_function?(node)
1766
+ node.arguments.nil? || node.arguments.arguments.empty?
1767
+ end
1768
+
1769
+ # `module_function :a, :b` retro-marks named siblings (defined
1770
+ # earlier OR later in the same body) as module-functions. Resolves
1771
+ # each symbol-literal argument against the body's own `def`s and
1772
+ # registers the matching `DefNode` on the module's singleton side.
1773
+ # Non-symbol arguments and names with no matching `def` are skipped
1774
+ # (a miss degrades to today's `Dynamic`, never a false resolution).
1775
+ def record_module_function_names(node, qualified_prefix, body, accumulator)
1776
+ return if qualified_prefix.empty?
1777
+
1778
+ defs_by_name = statements_of(body).each_with_object({}) do |stmt, acc|
1779
+ acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
1780
+ end
1781
+ class_name = qualified_prefix.join("::")
1782
+ node.arguments&.arguments&.each do |arg|
1783
+ name = symbol_argument_name(arg)
1784
+ def_node = name && defs_by_name[name]
1785
+ (accumulator[class_name] ||= {})[name] = def_node if def_node
1786
+ end
1787
+ end
1788
+
1789
+ # The Symbol value of a `:name` / `"name"` literal argument, or nil.
1790
+ def symbol_argument_name(arg)
1791
+ arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
1792
+ end
1793
+
1178
1794
  # ADR-24 slice 2 — per-class table mapping a fully
1179
1795
  # qualified user class to its superclass name AS WRITTEN
1180
1796
  # at the `class Foo < Bar` declaration. Only constant
@@ -1635,8 +2251,8 @@ module Rigor
1635
2251
  # @return [Hash{Symbol => Hash}]
1636
2252
  # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
1637
2253
  def discovered_def_index_for_paths(paths, buffer: nil)
1638
- acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {},
1639
- class_sources: {}, data_member_layouts: {} }
2254
+ acc = { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, superclasses: {}, includes: {},
2255
+ method_visibilities: {}, methods: {}, class_sources: {}, data_member_layouts: {} }
1640
2256
  paths.each do |path|
1641
2257
  physical = buffer ? buffer.resolve(path) : path
1642
2258
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -1655,7 +2271,7 @@ module Rigor
1655
2271
  # intact while still letting `attr_reader :x` in one file
1656
2272
  # suppress a false undefined-method for `obj.x` in another.
1657
2273
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
1658
- %i[def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
2274
+ %i[def_nodes singleton_def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
1659
2275
  acc[key].each_value(&:freeze)
1660
2276
  end
1661
2277
  acc.transform_values(&:freeze)
@@ -1678,6 +2294,9 @@ module Rigor
1678
2294
  # visibility declared in a sibling file.
1679
2295
  def accumulate_project_index(acc, path, root)
1680
2296
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
2297
+ build_discovered_singleton_def_nodes(root).each do |class_name, methods|
2298
+ (acc[:singleton_def_nodes][class_name] ||= {}).merge!(methods)
2299
+ end
1681
2300
  superclasses = build_discovered_superclasses(root)
1682
2301
  includes = build_discovered_includes(root)
1683
2302
  acc[:superclasses].merge!(superclasses)
@@ -1754,11 +2373,63 @@ module Rigor
1754
2373
  when Prism::ModuleNode
1755
2374
  name = qualified_name_for(node.constant_path)
1756
2375
  return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
2376
+ when Prism::ConstantWriteNode
2377
+ record_class_new_constant_decl(node, qualified_prefix, accumulator)
1757
2378
  end
1758
2379
 
1759
2380
  node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
1760
2381
  end
1761
2382
 
2383
+ # T1 (template-corpora survey) — record a `Const = Class.new(Super)`
2384
+ # (and the bare `Class.new` / `Module.new`) class-creating constant
2385
+ # in the cross-file discovery table so a reference to `Const` from
2386
+ # ANOTHER file under the same namespace resolves to the project
2387
+ # class instead of falling through to a core same-named class
2388
+ # (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling
2389
+ # file's `rescue SyntaxError => e`, which otherwise resolved to core
2390
+ # `::SyntaxError`). Mirrors the single-file `in_source_constants`
2391
+ # answer, which types `Class.new(Super)` as `Singleton[Super]` (the
2392
+ # constructed class answers method lookups through Super's chain).
2393
+ # The superclass name is resolved lexically against the enclosing
2394
+ # prefix; a bare `Class.new` with no superclass (or `Module.new`)
2395
+ # types as `Singleton[Const]` itself. The block form is left to the
2396
+ # existing `meta_new_block_body` machinery — only the plain
2397
+ # `Class.new(Super)` constant (the namespaced-sibling-error idiom)
2398
+ # is added here.
2399
+ def record_class_new_constant_decl(node, qualified_prefix, accumulator)
2400
+ rvalue = node.value
2401
+ return unless class_new_call?(rvalue) || module_new_call?(rvalue)
2402
+ return if rvalue.block # block form: handled by meta_new_block_body walks
2403
+
2404
+ full = (qualified_prefix + [node.name.to_s]).join("::")
2405
+ super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator)
2406
+ accumulator[full] = Type::Combinator.singleton_of(super_name || full)
2407
+ end
2408
+
2409
+ # Lexically-qualified name of a `Class.new(Super)` superclass
2410
+ # argument, or nil when there is no positional superclass (a bare
2411
+ # `Class.new` / `Module.new`). When the unqualified super name is a
2412
+ # class already discovered under an enclosing-prefix segment, the
2413
+ # qualified form is returned (so `Class.new(Error)` inside `module M`
2414
+ # resolves to `M::Error`); otherwise the literal name is returned
2415
+ # (covering a core / RBS-known superclass spelled bare).
2416
+ def class_new_superclass_name(call_node, qualified_prefix, accumulator)
2417
+ arg = call_node.arguments&.arguments&.first
2418
+ return nil if arg.nil?
2419
+
2420
+ raw = qualified_name_for(arg)
2421
+ return nil if raw.nil?
2422
+
2423
+ prefix = qualified_prefix.dup
2424
+ until prefix.empty?
2425
+ candidate = (prefix + [raw]).join("::")
2426
+ return candidate if accumulator.key?(candidate)
2427
+
2428
+ prefix.pop
2429
+ end
2430
+ raw
2431
+ end
2432
+
1762
2433
  # Walks the program once for `Prism::ModuleNode` and
1763
2434
  # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
1764
2435
  # type for the outermost `constant_path` node of each