rigortype 0.1.17 → 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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  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 +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. 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
@@ -68,16 +73,17 @@ module Rigor
68
73
  # collision — same-file declarations are the most
69
74
  # specific authority.
70
75
  merged_classes = default_scope.discovered_classes.merge(discovered_classes)
71
- seeded_scope = default_scope
72
- .with_declared_types(declared_types)
73
- .with_discovered_classes(merged_classes)
76
+ seeded_scope = default_scope.with_discovery(
77
+ default_scope.discovery.with(declared_types: declared_types,
78
+ discovered_classes: merged_classes)
79
+ )
74
80
 
75
81
  # Slice 7 phase 2. Pre-pass over every class/module body
76
82
  # to collect the per-class ivar accumulator. Seeded after
77
83
  # declared_types so the rvalue typer in the pre-pass can
78
84
  # see declaration overrides.
79
85
  class_ivars = build_class_ivar_index(root, seeded_scope)
80
- seeded_scope = seeded_scope.with_class_ivars(class_ivars)
86
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_ivars: class_ivars))
81
87
 
82
88
  # Slice 7 phase 6. Same pre-pass shape for cvars (per
83
89
  # class) and globals (program-wide). Globals are also
@@ -86,9 +92,9 @@ module Rigor
86
92
  # not enter a method body) observe the precise type
87
93
  # without consulting the accumulator on every lookup.
88
94
  class_cvars = build_class_cvar_index(root, seeded_scope)
89
- seeded_scope = seeded_scope.with_class_cvars(class_cvars)
95
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
90
96
  program_globals = build_program_global_index(root, seeded_scope)
91
- seeded_scope = seeded_scope.with_program_globals(program_globals)
97
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
92
98
  program_globals.each { |name, type| seeded_scope = seeded_scope.with_global(name, type) }
93
99
 
94
100
  # Slice 7 phase 9. In-source constant value tracking.
@@ -99,7 +105,9 @@ module Rigor
99
105
  # references resolve correctly. Multiple writes to the
100
106
  # same qualified name union via `Type::Combinator.union`.
101
107
  in_source_constants = build_in_source_constants(root, seeded_scope)
102
- seeded_scope = seeded_scope.with_in_source_constants(in_source_constants)
108
+ seeded_scope = seeded_scope.with_discovery(
109
+ seeded_scope.discovery.with(in_source_constants: in_source_constants)
110
+ )
103
111
 
104
112
  # Slice 7 phase 12. In-source method discovery. Walks
105
113
  # every class/module body for `Prism::DefNode` and
@@ -115,7 +123,7 @@ module Rigor
115
123
  discovered_methods = deep_merge_class_methods(
116
124
  default_scope.discovered_methods, build_discovered_methods(root)
117
125
  )
118
- seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
126
+ seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(discovered_methods: discovered_methods))
119
127
 
120
128
  # v0.0.2 #5 + ADR-24 slice 2 — record per-instance-method
121
129
  # def nodes, the class -> superclass map, and the
@@ -143,7 +151,8 @@ module Rigor
143
151
  # entry is the one that reflects all flow-derived
144
152
  # rebinds, so it MUST overwrite the first.
145
153
  on_enter = ->(node, scope) { table[node] = scope }
146
- 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)
147
156
 
148
157
  propagate(root, table, seeded_scope)
149
158
  table
@@ -161,6 +170,9 @@ module Rigor
161
170
  def_nodes = default_scope.discovered_def_nodes.merge(
162
171
  build_discovered_def_nodes(root)
163
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) }
164
176
  superclasses = default_scope.discovered_superclasses.merge(
165
177
  build_discovered_superclasses(root)
166
178
  )
@@ -179,12 +191,16 @@ module Rigor
179
191
  build_data_member_layouts(root)
180
192
  )
181
193
 
182
- seeded_scope
183
- .with_discovered_def_nodes(def_nodes)
184
- .with_discovered_superclasses(superclasses)
185
- .with_discovered_includes(includes)
186
- .with_discovered_method_visibilities(method_visibilities)
187
- .with_data_member_layouts(data_member_layouts)
194
+ seeded_scope.with_discovery(
195
+ seeded_scope.discovery.with(
196
+ discovered_def_nodes: def_nodes,
197
+ discovered_singleton_def_nodes: singleton_def_nodes,
198
+ discovered_superclasses: superclasses,
199
+ discovered_includes: includes,
200
+ discovered_method_visibilities: method_visibilities,
201
+ data_member_layouts: data_member_layouts
202
+ )
203
+ )
188
204
  end
189
205
 
190
206
  # Slice 7 phase 2. Builds the class-level ivar accumulator
@@ -204,8 +220,15 @@ module Rigor
204
220
  mutated_ivars = {}
205
221
  read_before_write = {}
206
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)
207
230
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
208
- read_before_write, init_writes)
231
+ read_before_write, init_writes, method_assign_effects)
209
232
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
210
233
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
211
234
  accumulator.transform_values(&:freeze).freeze
@@ -328,8 +351,8 @@ module Rigor
328
351
  end
329
352
  end
330
353
 
331
- def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars,
332
- 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)
333
356
  return unless node.is_a?(Prism::Node)
334
357
 
335
358
  case node
@@ -355,24 +378,31 @@ module Rigor
355
378
  # read.
356
379
  collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
357
380
  walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
358
- mutated_ivars, read_before_write, init_writes)
381
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
359
382
  end
360
383
  return
361
384
  end
362
385
  when Prism::DefNode
363
386
  collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
364
- mutated_ivars, read_before_write, init_writes)
387
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
365
388
  return
389
+ when Prism::CallNode
390
+ if init_writes && !qualified_prefix.empty? &&
391
+ node.block.is_a?(Prism::BlockNode) &&
392
+ block_initializer?(qualified_prefix.join("::"), node.name, default_scope)
393
+ collect_block_ivar_writes(node.block, qualified_prefix, default_scope,
394
+ accumulator, mutated_ivars, init_writes)
395
+ end
366
396
  end
367
397
 
368
398
  node.compact_child_nodes.each do |child|
369
399
  walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
370
- mutated_ivars, read_before_write, init_writes)
400
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
371
401
  end
372
402
  end
373
403
 
374
- def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
375
- 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)
376
406
  return if def_node.body.nil? || qualified_prefix.empty?
377
407
 
378
408
  class_name = qualified_prefix.join("::")
@@ -386,7 +416,23 @@ module Rigor
386
416
  end
387
417
  body_scope = default_scope.with_self_type(self_type)
388
418
 
389
- 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)
390
436
 
391
437
  # B2.3 — collect per-method evidence for the read-before-
392
438
  # write nil contribution. The accumulator-level decision
@@ -399,6 +445,53 @@ module Rigor
399
445
  collect_read_before_write_evidence(def_node, class_name, read_before_write, init_writes, default_scope)
400
446
  end
401
447
 
448
+ # ADR-38 block-form: collects ivar writes from a CallNode's
449
+ # block body (e.g. RSpec `before { @x = … }` / `let(:x) { … }`)
450
+ # and folds them into `init_writes`, suppressing the
451
+ # read-before-write nil contribution the same way a def-form
452
+ # initializer does. The block body is always treated as an
453
+ # initializer (the caller has already verified the method name
454
+ # is declared as a block_method initializer), so there is no
455
+ # read-before-write evidence collection step here.
456
+ def collect_block_ivar_writes(block_node, qualified_prefix, default_scope, accumulator,
457
+ mutated_ivars, init_writes)
458
+ return if block_node.body.nil? || qualified_prefix.empty?
459
+
460
+ class_name = qualified_prefix.join("::")
461
+ self_type = Type::Combinator.nominal_of(class_name)
462
+ body_scope = default_scope.with_self_type(self_type)
463
+
464
+ gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
465
+ EMPTY_GUARDED_IVARS, mutated_ivars)
466
+
467
+ seen_writes = Set.new
468
+ read_first = Set.new
469
+ detect_read_before_write(block_node.body, seen_writes, read_first)
470
+ init_set = (init_writes[class_name] ||= Set.new)
471
+ seen_writes.each { |name| init_set << name }
472
+ end
473
+
474
+ # ADR-38 block-form gate: true when a loaded plugin declares
475
+ # `method_name` a block-form initializer for `class_name` (or
476
+ # an ancestor). Mirrors `additional_initializer?` but queries
477
+ # `covers_block_method?` instead of `covers_method?`.
478
+ def block_initializer?(class_name, method_name, default_scope)
479
+ return false if class_name.nil? || default_scope.nil?
480
+
481
+ environment = default_scope.environment
482
+ registry = environment&.plugin_registry
483
+ return false if registry.nil?
484
+ return false if registry.respond_to?(:empty?) && registry.empty?
485
+ return false unless registry.respond_to?(:additional_initializers)
486
+
487
+ registry.additional_initializers.any? do |entry|
488
+ entry.covers_block_method?(method_name) &&
489
+ class_matches_constraint?(class_name, entry.receiver_constraint, environment)
490
+ end
491
+ rescue StandardError
492
+ false
493
+ end
494
+
402
495
  # Walks the method body in AST (== execution) order
403
496
  # tracking ivar names whose first reference is a read.
404
497
  # The set is unioned into the class-wide
@@ -529,13 +622,29 @@ module Rigor
529
622
  private_constant :EMPTY_GUARDED_IVARS
530
623
 
531
624
  def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
532
- mutated_ivars = nil)
625
+ mutated_ivars = nil, dead_writes = nil)
533
626
  return unless node.is_a?(Prism::Node)
534
627
 
535
- if node.is_a?(Prism::InstanceVariableWriteNode)
628
+ if node.is_a?(Prism::InstanceVariableWriteNode) &&
629
+ !(dead_writes && dead_writes.include?(node.object_id))
536
630
  record_ivar_write(node, scope, class_name, accumulator,
537
631
  guarded: guarded_ivars.include?(node.name))
538
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
+
539
648
  record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
540
649
 
541
650
  # Don't recurse into nested defs, classes, or modules; their
@@ -543,12 +652,13 @@ module Rigor
543
652
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
544
653
 
545
654
  if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
546
- 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)
547
657
  return
548
658
  end
549
659
 
550
660
  node.compact_child_nodes.each do |c|
551
- 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)
552
662
  end
553
663
  end
554
664
 
@@ -586,16 +696,22 @@ module Rigor
586
696
  # reads of `@x` would then surface a nil-receiver FP. The
587
697
  # ELSE branch is left ungarded so those reads continue to type
588
698
  # as they did before this fix.
589
- 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)
590
701
  then_guards = then_body_guarded_ivars(node)
591
702
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
592
703
 
593
- 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)
594
706
  if node.statements
595
- 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)
596
709
  end
597
710
  branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
598
- 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)
599
715
  end
600
716
 
601
717
  # Returns the set of ivar names that, in the THEN body of this
@@ -663,6 +779,332 @@ module Rigor
663
779
  end
664
780
  end
665
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
+
666
1108
  def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
667
1109
  rvalue_type = scope.type_of(node.value)
668
1110
 
@@ -687,10 +1129,104 @@ module Rigor
687
1129
  return if guarded && falsey_constant?(rvalue_type)
688
1130
 
689
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)
690
1139
  accumulator[class_name] ||= {}
691
- existing = accumulator[class_name][node.name]
692
- accumulator[class_name][node.name] =
693
- 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)
694
1230
  end
695
1231
 
696
1232
  def falsey_constant?(type)
@@ -1115,6 +1651,146 @@ module Rigor
1115
1651
  accumulator[class_name][def_node.name] = def_node
1116
1652
  end
1117
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
+
1118
1794
  # ADR-24 slice 2 — per-class table mapping a fully
1119
1795
  # qualified user class to its superclass name AS WRITTEN
1120
1796
  # at the `class Foo < Bar` declaration. Only constant
@@ -1575,8 +2251,8 @@ module Rigor
1575
2251
  # @return [Hash{Symbol => Hash}]
1576
2252
  # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
1577
2253
  def discovered_def_index_for_paths(paths, buffer: nil)
1578
- acc = { def_nodes: {}, def_sources: {}, superclasses: {}, includes: {}, method_visibilities: {}, methods: {},
1579
- 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: {} }
1580
2256
  paths.each do |path|
1581
2257
  physical = buffer ? buffer.resolve(path) : path
1582
2258
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -1595,7 +2271,7 @@ module Rigor
1595
2271
  # intact while still letting `attr_reader :x` in one file
1596
2272
  # suppress a false undefined-method for `obj.x` in another.
1597
2273
  acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
1598
- %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|
1599
2275
  acc[key].each_value(&:freeze)
1600
2276
  end
1601
2277
  acc.transform_values(&:freeze)
@@ -1618,6 +2294,9 @@ module Rigor
1618
2294
  # visibility declared in a sibling file.
1619
2295
  def accumulate_project_index(acc, path, root)
1620
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
1621
2300
  superclasses = build_discovered_superclasses(root)
1622
2301
  includes = build_discovered_includes(root)
1623
2302
  acc[:superclasses].merge!(superclasses)
@@ -1694,11 +2373,63 @@ module Rigor
1694
2373
  when Prism::ModuleNode
1695
2374
  name = qualified_name_for(node.constant_path)
1696
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)
1697
2378
  end
1698
2379
 
1699
2380
  node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
1700
2381
  end
1701
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
+
1702
2433
  # Walks the program once for `Prism::ModuleNode` and
1703
2434
  # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
1704
2435
  # type for the outermost `constant_path` node of each