rigortype 0.1.18 → 0.2.0

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 (210) 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 +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -4,9 +4,11 @@ require "prism"
4
4
 
5
5
  require_relative "../scope"
6
6
  require_relative "../type"
7
+ require_relative "../source/constant_path"
7
8
  require_relative "mutation_widening"
8
9
  require_relative "narrowing"
9
10
  require_relative "statement_evaluator"
11
+ require_relative "struct_fold_safety"
10
12
 
11
13
  module Rigor
12
14
  module Inference
@@ -49,9 +51,14 @@ module Rigor
49
51
  # @param default_scope [Rigor::Scope] the scope used for the root,
50
52
  # and the fallback returned for any Prism node not contained in
51
53
  # `root`'s subtree.
54
+ # @param converged_loop_recording [Boolean] display-path flag —
55
+ # when true the evaluator re-records fixpoint-tracked loop
56
+ # bodies from their CONVERGED bindings so per-line probes
57
+ # (`rigor annotate`) reflect the post-writeback state, not the
58
+ # cap-N intermediate constants. Off for the check path.
52
59
  # @return [Hash{Prism::Node => Rigor::Scope}] identity-comparing
53
60
  # table whose default value is `default_scope`.
54
- def index(root, default_scope:) # rubocop:disable Metrics/AbcSize
61
+ def index(root, default_scope:, converged_loop_recording: false) # rubocop:disable Metrics/AbcSize
55
62
  # Slice A-declarations. Build the declaration overrides
56
63
  # first so every scope handed to the StatementEvaluator
57
64
  # already carries the table; structural sharing through
@@ -109,12 +116,8 @@ module Rigor
109
116
  # recognised `define_method` calls and records the
110
117
  # introduced method names. `rigor check` consults the
111
118
  # table to suppress false positives for methods the
112
- # user has defined but no RBS sig describes.
113
- # Merged UNDER any cross-file pre-pass seed (like the def-node
114
- # / include tables below) so a method `def`/`attr_reader`-
115
- # declared in one file suppresses a false `undefined-method`
116
- # for a call in another — `rigor check` seeds the project-wide
117
- # table via `Runner#seed_project_scope`.
119
+ # user has defined but no RBS sig describes. Merged
120
+ # UNDER the cross-file pre-pass seed; details: merge_project_method_indexes.
118
121
  discovered_methods = deep_merge_class_methods(
119
122
  default_scope.discovered_methods, build_discovered_methods(root)
120
123
  )
@@ -145,13 +148,29 @@ module Rigor
145
148
  # `table[node]` to type predicates; the second pass's
146
149
  # entry is the one that reflects all flow-derived
147
150
  # rebinds, so it MUST overwrite the first.
151
+ # ADR-48 Struct slice 3 — install the top-level fold-safe-local set so
152
+ # a member read off a mutation-free top-level struct binding folds.
153
+ seeded_scope = seed_struct_fold_safe(seeded_scope, root)
154
+
148
155
  on_enter = ->(node, scope) { table[node] = scope }
149
- StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter).evaluate(root)
156
+ StatementEvaluator.new(scope: seeded_scope, on_enter: on_enter,
157
+ converged_loop_recording: converged_loop_recording).evaluate(root)
150
158
 
151
159
  propagate(root, table, seeded_scope)
152
160
  table
153
161
  end
154
162
 
163
+ # ADR-48 Struct slice 3 — installs the top-level fold-safe-local set
164
+ # ({Inference::StructFoldSafety}). Struct member layouts of constant
165
+ # receivers are resolved through the side-table the seeded scope carries.
166
+ def seed_struct_fold_safe(seeded_scope, root)
167
+ seeded_scope.with_struct_fold_safe(
168
+ StructFoldSafety.fold_safe_locals(
169
+ root, ->(name) { seeded_scope.struct_member_layout(name)&.[](:members) }
170
+ )
171
+ )
172
+ end
173
+
155
174
  # v0.0.2 #5 + ADR-24 slice 2 — seeds the three
156
175
  # project-method indexes onto `seeded_scope`: the
157
176
  # per-instance-method def-node table, the class ->
@@ -164,6 +183,9 @@ module Rigor
164
183
  def_nodes = default_scope.discovered_def_nodes.merge(
165
184
  build_discovered_def_nodes(root)
166
185
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
186
+ singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge(
187
+ build_discovered_singleton_def_nodes(root)
188
+ ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
167
189
  superclasses = default_scope.discovered_superclasses.merge(
168
190
  build_discovered_superclasses(root)
169
191
  )
@@ -176,23 +198,34 @@ module Rigor
176
198
  method_visibilities = default_scope.discovered_method_visibilities.merge(
177
199
  build_discovered_method_visibilities(root)
178
200
  ) { |_class, cross_file, per_file| cross_file.merge(per_file) }
179
- # ADR-48 — per-file Data member layouts merged OVER the cross-file
180
- # seed (same-file declaration is authoritative for its own classes).
181
- data_member_layouts = default_scope.data_member_layouts.merge(
182
- build_data_member_layouts(root)
183
- )
201
+ # ADR-48 — per-file Data + Struct member layouts merged OVER the
202
+ # cross-file seed (same-file declaration is authoritative).
203
+ data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, root)
184
204
 
185
205
  seeded_scope.with_discovery(
186
206
  seeded_scope.discovery.with(
187
207
  discovered_def_nodes: def_nodes,
208
+ discovered_singleton_def_nodes: singleton_def_nodes,
188
209
  discovered_superclasses: superclasses,
189
210
  discovered_includes: includes,
190
211
  discovered_method_visibilities: method_visibilities,
191
- data_member_layouts: data_member_layouts
212
+ data_member_layouts: data_member_layouts,
213
+ struct_member_layouts: struct_member_layouts
192
214
  )
193
215
  )
194
216
  end
195
217
 
218
+ # ADR-48 — the per-file Data + Struct member-layout tables, each merged
219
+ # OVER the cross-file seed so a same-file declaration wins for its own
220
+ # classes. Returned as a pair to keep {#merge_project_method_indexes}
221
+ # under the method-size budget.
222
+ def merge_member_layouts(default_scope, root)
223
+ [
224
+ default_scope.data_member_layouts.merge(build_data_member_layouts(root)),
225
+ default_scope.struct_member_layouts.merge(build_struct_member_layouts(root))
226
+ ]
227
+ end
228
+
196
229
  # Slice 7 phase 2. Builds the class-level ivar accumulator
197
230
  # by walking every `Prism::ClassNode` / `Prism::ModuleNode`
198
231
  # body, descending into each nested `Prism::DefNode`, and
@@ -210,8 +243,15 @@ module Rigor
210
243
  mutated_ivars = {}
211
244
  read_before_write = {}
212
245
  init_writes = {}
246
+ # WD3 — per-class summary of `{class_name => {method_name =>
247
+ # Set<ivar names definitely assigned non-nil on every
248
+ # completing path>}}`, consulted by `dead_transient_nil_writes`
249
+ # so a ctor that reassigns `@x` indirectly through an
250
+ # unconditional same-class method call (`mask!`) credits the
251
+ # overwrite. Built once per program here, memoised by class.
252
+ method_assign_effects = build_method_assign_effects(root)
213
253
  walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
214
- read_before_write, init_writes)
254
+ read_before_write, init_writes, method_assign_effects)
215
255
  widen_mutated_ivar_entries!(accumulator, mutated_ivars)
216
256
  contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
217
257
  accumulator.transform_values(&:freeze).freeze
@@ -334,13 +374,13 @@ module Rigor
334
374
  end
335
375
  end
336
376
 
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)
377
+ def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists
378
+ read_before_write = nil, init_writes = nil, method_assign_effects = nil)
339
379
  return unless node.is_a?(Prism::Node)
340
380
 
341
381
  case node
342
382
  when Prism::ClassNode, Prism::ModuleNode
343
- name = qualified_name_for(node.constant_path)
383
+ name = Source::ConstantPath.qualified_name(node.constant_path)
344
384
  if name
345
385
  child_prefix = qualified_prefix + [name]
346
386
  if node.body
@@ -361,13 +401,13 @@ module Rigor
361
401
  # read.
362
402
  collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
363
403
  walk_class_ivars(node.body, child_prefix, default_scope, accumulator,
364
- mutated_ivars, read_before_write, init_writes)
404
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
365
405
  end
366
406
  return
367
407
  end
368
408
  when Prism::DefNode
369
409
  collect_def_ivar_writes(node, qualified_prefix, default_scope, accumulator,
370
- mutated_ivars, read_before_write, init_writes)
410
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
371
411
  return
372
412
  when Prism::CallNode
373
413
  if init_writes && !qualified_prefix.empty? &&
@@ -380,12 +420,12 @@ module Rigor
380
420
 
381
421
  node.compact_child_nodes.each do |child|
382
422
  walk_class_ivars(child, qualified_prefix, default_scope, accumulator,
383
- mutated_ivars, read_before_write, init_writes)
423
+ mutated_ivars, read_before_write, init_writes, method_assign_effects)
384
424
  end
385
425
  end
386
426
 
387
- def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars,
388
- read_before_write = nil, init_writes = nil)
427
+ def collect_def_ivar_writes(def_node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
428
+ read_before_write = nil, init_writes = nil, method_assign_effects = nil)
389
429
  return if def_node.body.nil? || qualified_prefix.empty?
390
430
 
391
431
  class_name = qualified_prefix.join("::")
@@ -399,7 +439,23 @@ module Rigor
399
439
  end
400
440
  body_scope = default_scope.with_self_type(self_type)
401
441
 
402
- gather_ivar_writes(def_node.body, body_scope, class_name, accumulator, EMPTY_GUARDED_IVARS, mutated_ivars)
442
+ # C2 transient `@x = nil` dead-write elimination. When a
443
+ # method body opens with an unconditional `@x = nil`
444
+ # (defensive init) and then *definitely* reassigns `@x` to a
445
+ # non-nil value on every completing path (a later
446
+ # unconditional statement-level write, OR an `if/else` whose
447
+ # both branches write `@x`), the opening nil is dead — it can
448
+ # never be observed at method exit. Recording it anyway folds
449
+ # a spurious `nil` constituent into the flow-insensitive
450
+ # class-ivar union, which then poisons reads in OTHER methods
451
+ # (e.g. ipaddr `IN4MASK ^ @mask_addr` rejects the resulting
452
+ # `Integer | nil`). The set holds the `object_id`s of the
453
+ # transient write nodes to skip; soundness is post-domination
454
+ # at the top statement level, so dropping the nil never hides
455
+ # a real runtime-nil read.
456
+ dead_writes = dead_transient_nil_writes(def_node.body, class_name, method_assign_effects)
457
+ gather_ivar_writes(def_node.body, body_scope, class_name, accumulator,
458
+ EMPTY_GUARDED_IVARS, mutated_ivars, dead_writes)
403
459
 
404
460
  # B2.3 — collect per-method evidence for the read-before-
405
461
  # write nil contribution. The accumulator-level decision
@@ -589,13 +645,29 @@ module Rigor
589
645
  private_constant :EMPTY_GUARDED_IVARS
590
646
 
591
647
  def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
592
- mutated_ivars = nil)
648
+ mutated_ivars = nil, dead_writes = nil)
593
649
  return unless node.is_a?(Prism::Node)
594
650
 
595
- if node.is_a?(Prism::InstanceVariableWriteNode)
651
+ if node.is_a?(Prism::InstanceVariableWriteNode) &&
652
+ !(dead_writes && dead_writes.include?(node.object_id))
596
653
  record_ivar_write(node, scope, class_name, accumulator,
597
654
  guarded: guarded_ivars.include?(node.name))
598
655
  end
656
+
657
+ # N1 — parallel / multiple assignment (`old, @cb = @cb, block`,
658
+ # `@i, @o, @e, @thr = Open3.popen3(cmd)`). A direct
659
+ # `InstanceVariableWriteNode` is the only write form this
660
+ # collector handled, so an ivar appearing as a `MultiWriteNode`
661
+ # target was silently dropped from the class-ivar union — leaving
662
+ # it to seed as pure `nil` (from a sibling `@cb = nil` ctor write,
663
+ # or absent entirely) and false-fire `if @cb` always-falsey /
664
+ # `@thr.alive?` undefined-for-nil. Record each ivar target with
665
+ # its tuple-position RHS type where the RHS is array/tuple-shaped,
666
+ # else the unanalyzable floor (the same `Dynamic[top]` a single
667
+ # write to an unknown RHS records — an unanalyzable multi-write
668
+ # means unknown, not nil).
669
+ record_multi_write_ivars(node, scope, class_name, accumulator)
670
+
599
671
  record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars && node.is_a?(Prism::CallNode)
600
672
 
601
673
  # Don't recurse into nested defs, classes, or modules; their
@@ -603,12 +675,13 @@ module Rigor
603
675
  return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
604
676
 
605
677
  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)
678
+ walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
679
+ mutated_ivars, dead_writes)
607
680
  return
608
681
  end
609
682
 
610
683
  node.compact_child_nodes.each do |c|
611
- gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
684
+ gather_ivar_writes(c, scope, class_name, accumulator, guarded_ivars, mutated_ivars, dead_writes)
612
685
  end
613
686
  end
614
687
 
@@ -646,16 +719,22 @@ module Rigor
646
719
  # reads of `@x` would then surface a nil-receiver FP. The
647
720
  # ELSE branch is left ungarded so those reads continue to type
648
721
  # as they did before this fix.
649
- def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars, mutated_ivars = nil)
722
+ def walk_conditional_ivar_writes(node, scope, class_name, accumulator, guarded_ivars,
723
+ mutated_ivars = nil, dead_writes = nil)
650
724
  then_guards = then_body_guarded_ivars(node)
651
725
  then_guarded = then_guards.empty? ? guarded_ivars : (guarded_ivars | then_guards)
652
726
 
653
- gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars, mutated_ivars)
727
+ gather_ivar_writes(node.predicate, scope, class_name, accumulator, guarded_ivars,
728
+ mutated_ivars, dead_writes)
654
729
  if node.statements
655
- gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded, mutated_ivars)
730
+ gather_ivar_writes(node.statements, scope, class_name, accumulator, then_guarded,
731
+ mutated_ivars, dead_writes)
656
732
  end
657
733
  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
734
+ return unless branch
735
+
736
+ gather_ivar_writes(branch, scope, class_name, accumulator, guarded_ivars,
737
+ mutated_ivars, dead_writes)
659
738
  end
660
739
 
661
740
  # Returns the set of ivar names that, in the THEN body of this
@@ -723,6 +802,332 @@ module Rigor
723
802
  end
724
803
  end
725
804
 
805
+ # C2 — returns a Set of `object_id`s for transient `@x = nil`
806
+ # writes that a later statement in the same method body
807
+ # *definitely* overwrites with a non-nil value on every
808
+ # completing path. Such a nil can never be the ivar's value at
809
+ # method exit, so it must not contribute a `nil` constituent to
810
+ # the (flow-insensitive) class-ivar union.
811
+ #
812
+ # Scope is deliberately narrow and post-domination-sound:
813
+ # - only the top-level statement sequence of the body is
814
+ # considered (no writes hidden inside loops / rescue / nested
815
+ # conditionals count as the "definite" overwrite, except the
816
+ # one structured `if/else` form below);
817
+ # - the killing statement is either an unconditional
818
+ # statement-level `@x = <non-nil>`, OR an `if/else` (with a
819
+ # real `else`) where BOTH branches' final top-level write to
820
+ # `@x` is non-nil. Both shapes overwrite `@x` on every path;
821
+ # - only `@x = nil` literal writes are ever marked dead — a
822
+ # non-nil transient is left untouched (it is already
823
+ # precision-additive in the union).
824
+ # WD3 — ADR-41-style hard cap on how deep the same-class-call
825
+ # definite-assignment crediting recurses (the ctor calls
826
+ # `mask!`, which could itself call another same-class helper).
827
+ # Cycle-guarded independently; the cap bounds even acyclic
828
+ # chains.
829
+ SAME_CLASS_CALL_DEPTH_CAP = 3
830
+ private_constant :SAME_CLASS_CALL_DEPTH_CAP
831
+
832
+ # WD3 — builds the per-class definite-assignment summary
833
+ # `{class_name => {method_name => Set<ivar names assigned
834
+ # non-nil on every completing path>}}`. Used so a ctor's
835
+ # `dead_transient_nil_writes` can credit an indirect overwrite
836
+ # through an unconditionally-called same-class method (ipaddr's
837
+ # `initialize` reassigns `@mask_addr` via `mask!`).
838
+ #
839
+ # Each method's set is computed by the same suffix
840
+ # definite-assignment analysis used for the ctor seed, run from
841
+ # the method body's first statement for every ivar the method
842
+ # writes anywhere. Same-class calls inside a method are credited
843
+ # transitively (depth-capped, cycle-guarded) so the resulting
844
+ # FLAT table is correct at depth 0 for the ctor lookup.
845
+ def build_method_assign_effects(root)
846
+ defs = collect_class_method_defs(root)
847
+ effects = {}
848
+ memo = {}.compare_by_identity
849
+ defs.each do |class_name, methods|
850
+ methods.each do |method_name, def_node|
851
+ assigns = method_definite_assigns(class_name, method_name, def_node, defs, effects, memo, 0)
852
+ (effects[class_name] ||= {})[method_name] = assigns unless assigns.empty?
853
+ end
854
+ end
855
+ effects.freeze
856
+ end
857
+
858
+ # Collects `{class_name => {method_name => DefNode}}` for every
859
+ # instance-method def in the program. Singleton defs (`def
860
+ # self.x`) are excluded — the ctor-call crediting only follows
861
+ # instance-method calls on `self`. Last def wins on redefinition.
862
+ def collect_class_method_defs(root, prefix = [], acc = {})
863
+ return acc unless root.is_a?(Prism::Node)
864
+
865
+ case root
866
+ when Prism::ClassNode, Prism::ModuleNode
867
+ name = Source::ConstantPath.qualified_name(root.constant_path)
868
+ if name && root.body
869
+ child = prefix + [name]
870
+ collect_class_method_defs(root.body, child, acc)
871
+ end
872
+ return acc
873
+ when Prism::DefNode
874
+ (acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver
875
+ return acc
876
+ end
877
+
878
+ root.compact_child_nodes.each { |c| collect_class_method_defs(c, prefix, acc) }
879
+ acc
880
+ end
881
+
882
+ # Computes the definite-assignment set for one method, memoised
883
+ # per def node. The `memo` cycle-guards: a method re-entered
884
+ # while its own summary is in progress contributes nothing
885
+ # (sound under-approximation), so mutual recursion terminates.
886
+ def method_definite_assigns(class_name, _method_name, def_node, defs, effects, memo, depth)
887
+ return Set.new if def_node.body.nil?
888
+ return memo[def_node] if memo.key?(def_node)
889
+ return Set.new if depth >= SAME_CLASS_CALL_DEPTH_CAP
890
+
891
+ memo[def_node] = Set.new # in-progress sentinel (cycle guard)
892
+ statements = top_level_statements(def_node.body)
893
+ candidates = ivar_write_targets(def_node.body)
894
+ # A transient `@x = nil` opener whose own method reassigns it
895
+ # later must still count `@x` as assigned for callers, so the
896
+ # crediting is computed at the BUILD-time depth.
897
+ resolver = MethodEffectResolver.new(self, class_name, defs, effects, memo, depth)
898
+ assigns = Set.new
899
+ candidates.each do |ivar|
900
+ assigns << ivar if suffix_definitely_assigns_with_resolver?(statements, 0, ivar, class_name, resolver, depth)
901
+ end
902
+ memo[def_node] = assigns
903
+ end
904
+
905
+ # Every ivar this body assigns a non-nil value to ANYWHERE (the
906
+ # candidate set for the method's definite-assignment scan).
907
+ def ivar_write_targets(node, acc = Set.new)
908
+ return acc unless node.is_a?(Prism::Node)
909
+
910
+ acc << node.name if node.is_a?(Prism::InstanceVariableWriteNode) && !nil_literal_value?(node.value)
911
+ node.compact_child_nodes.each { |c| ivar_write_targets(c, acc) }
912
+ acc
913
+ end
914
+
915
+ # Build-time variant of `suffix_definitely_assigns?` that resolves
916
+ # same-class calls through the lazy `resolver` (which recurses
917
+ # into `method_definite_assigns` for not-yet-computed callees)
918
+ # rather than the finished flat table.
919
+ def suffix_definitely_assigns_with_resolver?(statements, from, target, class_name, resolver, depth)
920
+ statements[from..].each do |stmt|
921
+ outcome = statement_assignment_outcome(stmt, target, class_name, resolver, depth, nil)
922
+ return true if outcome == :assigned
923
+ return false if outcome == :terminates_unassigned
924
+ end
925
+ false
926
+ end
927
+
928
+ # Adapts `effects.dig(class, method)` for build-time crediting:
929
+ # when the callee summary is not yet in the flat table, compute
930
+ # it on demand (depth+1) via `method_definite_assigns`.
931
+ class MethodEffectResolver
932
+ def initialize(indexer, class_name, defs, effects, memo, depth)
933
+ @indexer = indexer
934
+ @class_name = class_name
935
+ @defs = defs
936
+ @effects = effects
937
+ @memo = memo
938
+ @depth = depth
939
+ end
940
+
941
+ def dig(class_name, method_name)
942
+ existing = @effects.dig(class_name, method_name)
943
+ return existing if existing
944
+
945
+ def_node = @defs.dig(class_name, method_name)
946
+ return nil if def_node.nil?
947
+
948
+ @indexer.send(:method_definite_assigns, class_name, method_name, def_node, @defs, @effects, @memo,
949
+ @depth + 1)
950
+ end
951
+ end
952
+
953
+ def dead_transient_nil_writes(body, class_name = nil, method_assign_effects = nil)
954
+ statements = top_level_statements(body)
955
+ return nil if statements.length < 2
956
+
957
+ dead = nil
958
+
959
+ statements.each_with_index do |stmt, i|
960
+ next unless stmt.is_a?(Prism::InstanceVariableWriteNode) && nil_literal_value?(stmt.value)
961
+
962
+ # The opening `@x = nil` is dead when every completing path
963
+ # of the SUFFIX after it (normal end OR early `return`,
964
+ # never a `raise`-terminated path) definitely reassigns
965
+ # `@x` non-nil. The suffix analysis credits an
966
+ # unconditionally-called same-class method's own definite
967
+ # assignments via `method_assign_effects`.
968
+ if suffix_definitely_assigns?(statements, i + 1, stmt.name, class_name, method_assign_effects)
969
+ (dead ||= Set.new) << stmt.object_id
970
+ end
971
+ end
972
+
973
+ dead
974
+ end
975
+
976
+ def top_level_statements(body)
977
+ return [] if body.nil?
978
+ return body.body if body.is_a?(Prism::StatementsNode)
979
+
980
+ [body]
981
+ end
982
+
983
+ def nil_literal_value?(node)
984
+ node.is_a?(Prism::NilNode)
985
+ end
986
+
987
+ # True when, starting from `statements[from]`, EVERY path that
988
+ # completes the method (falls off the end OR hits an early
989
+ # `return`) definitely assigns `target` a non-nil value first.
990
+ # Paths terminated by `raise` are not completing paths and are
991
+ # ignored (they never observe the ivar at method exit). A path
992
+ # that can fall through `statements` without assigning fails.
993
+ def suffix_definitely_assigns?(statements, from, target, class_name, effects)
994
+ statements[from..].each do |stmt|
995
+ outcome = statement_assignment_outcome(stmt, target, class_name, effects, 0, nil)
996
+ # The statement assigned on every continuing path -> the
997
+ # suffix is satisfied no matter what follows.
998
+ return true if outcome == :assigned
999
+ # The statement terminates control here (return/raise) and
1000
+ # the value it carried did not assign on every path -> some
1001
+ # completing path reached exit without the assignment.
1002
+ return false if outcome == :terminates_unassigned
1003
+ # Otherwise (:falls_through_unassigned) keep scanning the
1004
+ # remaining statements.
1005
+ end
1006
+ # Fell off the end with no definite assignment.
1007
+ false
1008
+ end
1009
+
1010
+ # Classifies a single statement's effect on `target`:
1011
+ # :assigned — every path through the statement
1012
+ # that continues OR returns assigns
1013
+ # `target` non-nil (suffix is done);
1014
+ # :terminates_unassigned — the statement ends the method
1015
+ # (return/raise) on some path
1016
+ # without a definite assignment, so
1017
+ # a completing path escaped;
1018
+ # :falls_through_unassigned — control may continue past it
1019
+ # without the assignment (keep
1020
+ # scanning the suffix).
1021
+ def statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1022
+ case stmt
1023
+ when Prism::InstanceVariableWriteNode
1024
+ return :falls_through_unassigned if stmt.name != target
1025
+
1026
+ nil_literal_value?(stmt.value) ? :falls_through_unassigned : :assigned
1027
+ when Prism::CallNode
1028
+ if unconditional_call_assigns?(stmt, target, class_name, effects, depth, visiting)
1029
+ :assigned
1030
+ else
1031
+ :falls_through_unassigned
1032
+ end
1033
+ when Prism::IfNode, Prism::UnlessNode
1034
+ conditional_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1035
+ when Prism::CaseNode
1036
+ case_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1037
+ when Prism::ReturnNode
1038
+ :terminates_unassigned
1039
+ else
1040
+ # Any other statement — including a bare `raise`/`fail`,
1041
+ # which terminates without a completing path that observes
1042
+ # the seed nil — is neutral: control either continues or the
1043
+ # path never reaches method exit. Keep scanning the suffix.
1044
+ :falls_through_unassigned
1045
+ end
1046
+ end
1047
+
1048
+ # True when a branch body (a StatementsNode / single node)
1049
+ # definitely assigns `target` non-nil on every path that
1050
+ # completes the method through it, OR terminates every path by
1051
+ # raise (vacuously safe — no completing path observes the seed
1052
+ # nil). Returns false if any path can complete/return without the
1053
+ # assignment.
1054
+ def branch_definitely_assigns?(branch, target, class_name, effects, depth, visiting)
1055
+ stmts = top_level_statements(branch)
1056
+ return false if stmts.empty?
1057
+
1058
+ stmts.each do |stmt|
1059
+ outcome = statement_assignment_outcome(stmt, target, class_name, effects, depth, visiting)
1060
+ return true if outcome == :assigned
1061
+ return false if outcome == :terminates_unassigned
1062
+ end
1063
+ # Reached the end of the branch without a definite assignment;
1064
+ # safe only if the branch's last statement always raises (no
1065
+ # completing path falls out of it).
1066
+ always_raises?(stmts.last)
1067
+ end
1068
+
1069
+ # `if`/`unless` is a definite assignment of `target` only when
1070
+ # BOTH the then and else arms definitely assign (or raise-out).
1071
+ # A missing else arm means the fall-through path skips the
1072
+ # assignment -> not definite. Modifier-form `if`/`unless` (no
1073
+ # else, single predicate'd statement) likewise.
1074
+ def conditional_assignment_outcome(node, target, class_name, effects, depth, visiting)
1075
+ else_branch = node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause
1076
+ return :falls_through_unassigned unless else_branch.is_a?(Prism::ElseNode)
1077
+ return :falls_through_unassigned unless node.statements
1078
+
1079
+ then_ok = branch_definitely_assigns?(node.statements, target, class_name, effects, depth, visiting)
1080
+ else_ok = branch_definitely_assigns?(else_branch.statements, target, class_name, effects, depth, visiting)
1081
+ then_ok && else_ok ? :assigned : :falls_through_unassigned
1082
+ end
1083
+
1084
+ # `case` is a definite assignment only when there is a real
1085
+ # `else` clause AND every `when`/`in` body plus the else body
1086
+ # definitely assigns (or raises-out). A missing else lets an
1087
+ # unmatched subject fall through unassigned.
1088
+ def case_assignment_outcome(node, target, class_name, effects, depth, visiting)
1089
+ else_clause = node.else_clause
1090
+ return :falls_through_unassigned unless else_clause.is_a?(Prism::ElseNode)
1091
+
1092
+ branches = node.conditions.map { |c| c.respond_to?(:statements) ? c.statements : nil }
1093
+ branches << else_clause.statements
1094
+ all_ok = branches.all? do |b|
1095
+ branch_definitely_assigns?(b, target, class_name, effects, depth, visiting)
1096
+ end
1097
+ all_ok ? :assigned : :falls_through_unassigned
1098
+ end
1099
+
1100
+ # True when `node` (a single statement or its last statement) is
1101
+ # an unconditional `raise`/`fail` call that always terminates the
1102
+ # path — used to treat raise-terminated branches as
1103
+ # non-completing (they never observe the seed nil).
1104
+ def always_raises?(node)
1105
+ node = top_level_statements(node).last if node.is_a?(Prism::StatementsNode)
1106
+ return false unless node.is_a?(Prism::CallNode)
1107
+ return false unless node.receiver.nil?
1108
+
1109
+ %i[raise fail].include?(node.name)
1110
+ end
1111
+
1112
+ # True when `call` is an unconditional, statement-level,
1113
+ # implicit-self (or `self.`) call to a SAME-CLASS method whose
1114
+ # definite-assignment summary includes `target`. Calls through a
1115
+ # block, on another receiver, or to an unresolved name contribute
1116
+ # nothing (the seed nil stays).
1117
+ def unconditional_call_assigns?(call, target, class_name, effects, depth, _visiting)
1118
+ return false if effects.nil? || class_name.nil?
1119
+ return false if depth >= SAME_CLASS_CALL_DEPTH_CAP
1120
+ return false unless call.is_a?(Prism::CallNode)
1121
+ return false unless call.block.nil?
1122
+ # Implicit self (`mask!(x)`) or explicit `self.mask!(x)` only.
1123
+ return false unless call.receiver.nil? || call.receiver.is_a?(Prism::SelfNode)
1124
+
1125
+ assigns = effects.dig(class_name, call.name)
1126
+ return false if assigns.nil?
1127
+
1128
+ assigns.include?(target)
1129
+ end
1130
+
726
1131
  def record_ivar_write(node, scope, class_name, accumulator, guarded: false)
727
1132
  rvalue_type = scope.type_of(node.value)
728
1133
 
@@ -747,10 +1152,104 @@ module Rigor
747
1152
  return if guarded && falsey_constant?(rvalue_type)
748
1153
 
749
1154
  rvalue_type = Type::Combinator.union(rvalue_type, Type::Combinator.constant_of(nil)) if guarded
1155
+ accumulate_ivar_type(accumulator, class_name, node.name, rvalue_type)
1156
+ end
1157
+
1158
+ # Unions `type` into the class-ivar accumulator for `(class_name,
1159
+ # ivar_name)`. Shared by the single-write and multi-write
1160
+ # (parallel-assignment) collectors.
1161
+ def accumulate_ivar_type(accumulator, class_name, ivar_name, type)
750
1162
  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
1163
+ existing = accumulator[class_name][ivar_name]
1164
+ accumulator[class_name][ivar_name] =
1165
+ existing ? Type::Combinator.union(existing, type) : type
1166
+ end
1167
+
1168
+ # N1 — records each `InstanceVariableTargetNode` of a
1169
+ # `MultiWriteNode` (parallel / multiple assignment) into the
1170
+ # class-ivar union, with the best cheap per-slot type. When the RHS
1171
+ # is array/tuple-shaped (`Type::Tuple`) the ivar at position `i`
1172
+ # records the type of element `i`; otherwise — an unanalyzable RHS
1173
+ # such as `Open3.popen3(cmd)` typing to `Dynamic[top]` — every ivar
1174
+ # slot records that unanalyzable floor (NOT `nil`: a multi-write we
1175
+ # cannot decompose means the value is *unknown*, and `Dynamic[top]`
1176
+ # is the sound union constituent, mirroring what a single write to
1177
+ # an unknown RHS records). Nested targets (`(@a, @b), @c = …`)
1178
+ # recurse with the slot's type as the new RHS type.
1179
+ def record_multi_write_ivars(node, scope, class_name, accumulator)
1180
+ return unless node.is_a?(Prism::MultiWriteNode)
1181
+
1182
+ rhs_type = scope.type_of(node.value)
1183
+ record_multi_target_ivars(node, rhs_type, class_name, accumulator)
1184
+ end
1185
+
1186
+ # Walks a `MultiWriteNode` / `MultiTargetNode` target tree against
1187
+ # `rhs_type`, recording ivar targets per slot. Mirrors
1188
+ # `MultiTargetBinder`'s tuple decomposition but for ivar (rather
1189
+ # than local-variable) targets.
1190
+ def record_multi_target_ivars(node, rhs_type, class_name, accumulator)
1191
+ lefts = node.lefts || []
1192
+ rest = node.rest
1193
+ rights = node.rights || []
1194
+ fronts, rest_type, backs =
1195
+ decompose_multi_write_rhs(rhs_type, lefts.size, rights.size, rest_present: !rest.nil?)
1196
+
1197
+ lefts.each_with_index { |t, i| record_multi_ivar_target(t, fronts[i], class_name, accumulator) }
1198
+ record_multi_ivar_rest(rest, rest_type, class_name, accumulator) if rest
1199
+ rights.each_with_index { |t, i| record_multi_ivar_target(t, backs[i], class_name, accumulator) }
1200
+ end
1201
+
1202
+ def decompose_multi_write_rhs(rhs_type, front_count, back_count, rest_present:)
1203
+ if rhs_type.is_a?(Type::Tuple)
1204
+ elements = rhs_type.elements
1205
+ fronts = Array.new(front_count) { |i| multi_write_slot_type(elements, i) }
1206
+ if rest_present
1207
+ middle_end = [elements.size - back_count, front_count].max
1208
+ backs = Array.new(back_count) { |i| multi_write_slot_type(elements, middle_end + i) }
1209
+ [fronts, Type::Combinator.untyped, backs]
1210
+ else
1211
+ backs = Array.new(back_count) { |i| multi_write_slot_type(elements, front_count + i) }
1212
+ [fronts, nil, backs]
1213
+ end
1214
+ else
1215
+ # Unanalyzable / non-tuple RHS: every slot is the unknown floor.
1216
+ floor = Type::Combinator.untyped
1217
+ [Array.new(front_count) { floor }, rest_present ? floor : nil, Array.new(back_count) { floor }]
1218
+ end
1219
+ end
1220
+
1221
+ # The per-slot type for index `i` of a tuple RHS. A missing slot
1222
+ # (over-destructure) is `nil` at runtime; a present slot keeps its
1223
+ # type. Unlike the local-variable binder we do NOT soften an
1224
+ # optional slot here — a class-ivar seed deliberately preserves a
1225
+ # genuine `T | nil`, and any spurious nil is removed by the
1226
+ # flow-side narrowing, not by dropping it at collection time.
1227
+ def multi_write_slot_type(elements, index)
1228
+ element = elements[index]
1229
+ return Type::Combinator.constant_of(nil) if element.nil?
1230
+
1231
+ element
1232
+ end
1233
+
1234
+ def record_multi_ivar_target(target, type, class_name, accumulator)
1235
+ case target
1236
+ when Prism::InstanceVariableTargetNode
1237
+ accumulate_ivar_type(accumulator, class_name, target.name, type)
1238
+ when Prism::MultiTargetNode
1239
+ record_multi_target_ivars(target, type, class_name, accumulator)
1240
+ end
1241
+ end
1242
+
1243
+ def record_multi_ivar_rest(splat_node, _type, class_name, accumulator)
1244
+ return unless splat_node.is_a?(Prism::SplatNode)
1245
+
1246
+ expression = splat_node.expression
1247
+ return unless expression.is_a?(Prism::InstanceVariableTargetNode)
1248
+
1249
+ # A splat collects the middle slots into an Array; the precise
1250
+ # element type is not worth recovering here. Record the
1251
+ # unanalyzable floor (an Array of unknown), never nil.
1252
+ accumulate_ivar_type(accumulator, class_name, expression.name, Type::Combinator.untyped)
754
1253
  end
755
1254
 
756
1255
  def falsey_constant?(type)
@@ -775,7 +1274,7 @@ module Rigor
775
1274
 
776
1275
  case node
777
1276
  when Prism::ClassNode, Prism::ModuleNode
778
- name = qualified_name_for(node.constant_path)
1277
+ name = Source::ConstantPath.qualified_name(node.constant_path)
779
1278
  if name
780
1279
  child_prefix = qualified_prefix + [name]
781
1280
  walk_class_cvars(node.body, child_prefix, default_scope, accumulator) if node.body
@@ -861,7 +1360,7 @@ module Rigor
861
1360
 
862
1361
  case node
863
1362
  when Prism::ClassNode, Prism::ModuleNode
864
- name = qualified_name_for(node.constant_path)
1363
+ name = Source::ConstantPath.qualified_name(node.constant_path)
865
1364
  if name
866
1365
  child_prefix = qualified_prefix + [name]
867
1366
  walk_constant_writes(node.body, child_prefix, default_scope, accumulator) if node.body
@@ -871,7 +1370,7 @@ module Rigor
871
1370
  record_constant_write(node, qualified_prefix, default_scope, accumulator, node.name.to_s)
872
1371
  return
873
1372
  when Prism::ConstantPathWriteNode
874
- full = qualified_name_for(node.target)
1373
+ full = Source::ConstantPath.qualified_name(node.target)
875
1374
  record_constant_write(node, [], default_scope, accumulator, full) if full
876
1375
  return
877
1376
  end
@@ -937,7 +1436,7 @@ module Rigor
937
1436
 
938
1437
  case node
939
1438
  when Prism::ClassNode, Prism::ModuleNode
940
- name = qualified_name_for(node.constant_path)
1439
+ name = Source::ConstantPath.qualified_name(node.constant_path)
941
1440
  if name
942
1441
  child_prefix = qualified_prefix + [name]
943
1442
  record_meta_superclass_members(node, child_prefix, accumulator) if node.is_a?(Prism::ClassNode)
@@ -990,7 +1489,7 @@ module Rigor
990
1489
  when Prism::SelfNode
991
1490
  qualified_prefix
992
1491
  when Prism::ConstantReadNode, Prism::ConstantPathNode
993
- rendered = qualified_name_for(node.expression)
1492
+ rendered = Source::ConstantPath.qualified_name(node.expression)
994
1493
  return nil unless rendered
995
1494
 
996
1495
  if !qualified_prefix.empty? && qualified_prefix.last == rendered
@@ -1097,7 +1596,7 @@ module Rigor
1097
1596
  when Prism::ConstantReadNode
1098
1597
  receiver.name.to_s == qualified_prefix.last
1099
1598
  when Prism::ConstantPathNode
1100
- rendered = render_constant_path(receiver)
1599
+ rendered = Source::ConstantPath.render(receiver)
1101
1600
  return false unless rendered
1102
1601
 
1103
1602
  path = rendered.split("::")
@@ -1129,7 +1628,7 @@ module Rigor
1129
1628
 
1130
1629
  case node
1131
1630
  when Prism::ClassNode, Prism::ModuleNode
1132
- name = qualified_name_for(node.constant_path)
1631
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1133
1632
  if name
1134
1633
  child_prefix = qualified_prefix + [name]
1135
1634
  walk_def_nodes(node.body, child_prefix, false, accumulator) if node.body
@@ -1175,6 +1674,146 @@ module Rigor
1175
1674
  accumulator[class_name][def_node.name] = def_node
1176
1675
  end
1177
1676
 
1677
+ # Module-singleton call resolution (ADR-57 follow-up) — the
1678
+ # SINGLETON-side mirror of `build_discovered_def_nodes`. Records the
1679
+ # `Prism::DefNode` for every singleton-side method (`def self.x`,
1680
+ # `def Foo.x`, a `class << self` body, and a `module_function`
1681
+ # method) keyed by qualified class/module name → method → node, so
1682
+ # `ExpressionTyper` can re-type the body when a `Singleton[Foo]`
1683
+ # receiver dispatches `Foo.x`. The instance-side table is kept
1684
+ # singleton-free on purpose (its ancestor walk binds `self` as
1685
+ # `Nominal`), so the two never overlap except for `module_function`
1686
+ # defs, which are genuinely callable on both sides and so appear in
1687
+ # both tables. Top-level singleton defs (`def self.x` outside any
1688
+ # class — `self` is `main`) are not recorded; they have no constant
1689
+ # receiver to dispatch through.
1690
+ def build_discovered_singleton_def_nodes(root)
1691
+ accumulator = {}
1692
+ walk_singleton_def_nodes(root, [], false, accumulator)
1693
+ accumulator.transform_values(&:freeze).freeze
1694
+ end
1695
+
1696
+ # Walks every node, entering class/module/singleton-class bodies via
1697
+ # {#walk_singleton_body} so a bare `module_function` toggle threads
1698
+ # correctly across the body's *sibling* statements (a child-by-child
1699
+ # recursion would reset it). At the top level / inside an arbitrary
1700
+ # node there is no `module_function` state to carry, so descent is a
1701
+ # plain per-child walk.
1702
+ def walk_singleton_def_nodes(node, qualified_prefix, in_singleton_class, accumulator)
1703
+ return unless node.is_a?(Prism::Node)
1704
+
1705
+ case node
1706
+ when Prism::ClassNode, Prism::ModuleNode
1707
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1708
+ if name
1709
+ walk_singleton_body(node.body, qualified_prefix + [name], false, accumulator) if node.body
1710
+ return
1711
+ end
1712
+ when Prism::SingletonClassNode
1713
+ if node.body
1714
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
1715
+ if singleton_prefix
1716
+ walk_singleton_body(node.body, singleton_prefix, true, accumulator)
1717
+ return
1718
+ end
1719
+ end
1720
+ when Prism::ConstantWriteNode
1721
+ if meta_new_block_body(node)
1722
+ child_prefix = qualified_prefix + [node.name.to_s]
1723
+ walk_singleton_body(meta_new_block_body(node), child_prefix, false, accumulator)
1724
+ return
1725
+ end
1726
+ when Prism::DefNode
1727
+ record_singleton_def_node(node, qualified_prefix, in_singleton_class, false, accumulator)
1728
+ return
1729
+ end
1730
+
1731
+ node.compact_child_nodes.each do |child|
1732
+ walk_singleton_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
1733
+ end
1734
+ end
1735
+
1736
+ # Walks a class/module/singleton-class body's direct statements in
1737
+ # source order, threading the bare-`module_function` toggle: once a
1738
+ # bare `module_function` is seen, every subsequent `def` in the body
1739
+ # registers as a singleton method. Nested classes/modules/defs and
1740
+ # `module_function :a, :b` named forms recurse / record through the
1741
+ # general walker so the toggle stays scoped to its own body.
1742
+ def walk_singleton_body(body, qualified_prefix, in_singleton_class, accumulator)
1743
+ module_function_on = false
1744
+ statements_of(body).each do |stmt|
1745
+ if stmt.is_a?(Prism::CallNode) && module_function_toggle?(stmt)
1746
+ if bare_module_function?(stmt)
1747
+ module_function_on = true
1748
+ else
1749
+ record_module_function_names(stmt, qualified_prefix, body, accumulator)
1750
+ end
1751
+ next
1752
+ end
1753
+ if stmt.is_a?(Prism::DefNode)
1754
+ record_singleton_def_node(stmt, qualified_prefix, in_singleton_class, module_function_on, accumulator)
1755
+ next
1756
+ end
1757
+ walk_singleton_def_nodes(stmt, qualified_prefix, in_singleton_class, accumulator)
1758
+ end
1759
+ end
1760
+
1761
+ # Direct statement children of a class/module body node (a
1762
+ # `Prism::StatementsNode`, a `Prism::BeginNode` wrapping one, or a
1763
+ # lone statement). Returns an empty list for an empty body.
1764
+ def statements_of(body)
1765
+ case body
1766
+ when Prism::StatementsNode then body.body
1767
+ when Prism::BeginNode then statements_of(body.statements)
1768
+ when nil then []
1769
+ else [body]
1770
+ end
1771
+ end
1772
+
1773
+ def record_singleton_def_node(def_node, qualified_prefix, in_singleton_class, module_function_on, accumulator)
1774
+ singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class) || module_function_on
1775
+ return unless singleton
1776
+ return if qualified_prefix.empty?
1777
+
1778
+ class_name = qualified_prefix.join("::")
1779
+ (accumulator[class_name] ||= {})[def_node.name] = def_node
1780
+ end
1781
+
1782
+ # A bare `module_function` (no arguments) flips every following `def`
1783
+ # in the module body to module-function (instance + singleton) mode.
1784
+ def module_function_toggle?(node)
1785
+ node.name == :module_function && node.receiver.nil?
1786
+ end
1787
+
1788
+ def bare_module_function?(node)
1789
+ node.arguments.nil? || node.arguments.arguments.empty?
1790
+ end
1791
+
1792
+ # `module_function :a, :b` retro-marks named siblings (defined
1793
+ # earlier OR later in the same body) as module-functions. Resolves
1794
+ # each symbol-literal argument against the body's own `def`s and
1795
+ # registers the matching `DefNode` on the module's singleton side.
1796
+ # Non-symbol arguments and names with no matching `def` are skipped
1797
+ # (a miss degrades to today's `Dynamic`, never a false resolution).
1798
+ def record_module_function_names(node, qualified_prefix, body, accumulator)
1799
+ return if qualified_prefix.empty?
1800
+
1801
+ defs_by_name = statements_of(body).each_with_object({}) do |stmt, acc|
1802
+ acc[stmt.name] = stmt if stmt.is_a?(Prism::DefNode) && stmt.receiver.nil?
1803
+ end
1804
+ class_name = qualified_prefix.join("::")
1805
+ node.arguments&.arguments&.each do |arg|
1806
+ name = symbol_argument_name(arg)
1807
+ def_node = name && defs_by_name[name]
1808
+ (accumulator[class_name] ||= {})[name] = def_node if def_node
1809
+ end
1810
+ end
1811
+
1812
+ # The Symbol value of a `:name` / `"name"` literal argument, or nil.
1813
+ def symbol_argument_name(arg)
1814
+ arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
1815
+ end
1816
+
1178
1817
  # ADR-24 slice 2 — per-class table mapping a fully
1179
1818
  # qualified user class to its superclass name AS WRITTEN
1180
1819
  # at the `class Foo < Bar` declaration. Only constant
@@ -1194,16 +1833,16 @@ module Rigor
1194
1833
 
1195
1834
  case node
1196
1835
  when Prism::ClassNode
1197
- name = qualified_name_for(node.constant_path)
1836
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1198
1837
  if name
1199
1838
  full = (qualified_prefix + [name]).join("::")
1200
- superclass = node.superclass && qualified_name_for(node.superclass)
1839
+ superclass = node.superclass && Source::ConstantPath.qualified_name(node.superclass)
1201
1840
  accumulator[full] = superclass if superclass
1202
1841
  walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
1203
1842
  return
1204
1843
  end
1205
1844
  when Prism::ModuleNode
1206
- name = qualified_name_for(node.constant_path)
1845
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1207
1846
  if name
1208
1847
  walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
1209
1848
  return
@@ -1235,14 +1874,14 @@ module Rigor
1235
1874
 
1236
1875
  case node
1237
1876
  when Prism::ClassNode
1238
- name = qualified_name_for(node.constant_path)
1877
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1239
1878
  if name
1240
1879
  record_data_member_layout(accumulator, qualified_prefix + [name], node.superclass)
1241
1880
  walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1242
1881
  return
1243
1882
  end
1244
1883
  when Prism::ModuleNode
1245
- name = qualified_name_for(node.constant_path)
1884
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1246
1885
  if name
1247
1886
  walk_data_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1248
1887
  return
@@ -1267,6 +1906,74 @@ module Rigor
1267
1906
  accumulator[qualified_parts.join("::")] = members.freeze
1268
1907
  end
1269
1908
 
1909
+ # ADR-48 Struct follow-up — the `Struct.new(...)` sibling of
1910
+ # {#build_data_member_layouts}. A separate, additive table so the
1911
+ # existing `Data.define` value-shape contract (a bare `[Symbol]`) is
1912
+ # untouched: a Struct entry carries `{ members:, keyword_init: }`
1913
+ # because the dispatcher needs the flag to fold the matching `.new`
1914
+ # call form (positional vs keyword) without manufacturing a wrong map.
1915
+ def build_struct_member_layouts(root)
1916
+ accumulator = {}
1917
+ walk_struct_member_layouts(root, [], accumulator)
1918
+ accumulator.freeze
1919
+ end
1920
+
1921
+ def walk_struct_member_layouts(node, qualified_prefix, accumulator)
1922
+ return unless node.is_a?(Prism::Node)
1923
+
1924
+ case node
1925
+ when Prism::ClassNode
1926
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1927
+ if name
1928
+ record_struct_member_layout(accumulator, qualified_prefix + [name], node.superclass)
1929
+ walk_struct_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1930
+ return
1931
+ end
1932
+ when Prism::ModuleNode
1933
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1934
+ if name
1935
+ walk_struct_member_layouts(node.body, qualified_prefix + [name], accumulator) if node.body
1936
+ return
1937
+ end
1938
+ when Prism::ConstantWriteNode
1939
+ record_struct_member_layout(accumulator, qualified_prefix + [node.name.to_s], node.value)
1940
+ end
1941
+
1942
+ node.compact_child_nodes.each do |child|
1943
+ walk_struct_member_layouts(child, qualified_prefix, accumulator)
1944
+ end
1945
+ end
1946
+
1947
+ # Records `qualified -> { members:, keyword_init: }` when `expr` is a
1948
+ # `Struct.new(*Symbol [, keyword_init: <bool>])` call with at least one
1949
+ # literal-Symbol member.
1950
+ def record_struct_member_layout(accumulator, qualified_parts, expr)
1951
+ return unless struct_new_call?(expr)
1952
+
1953
+ members = meta_member_names(expr)
1954
+ return if members.empty?
1955
+
1956
+ accumulator[qualified_parts.join("::")] = {
1957
+ members: members.freeze,
1958
+ keyword_init: struct_new_keyword_init?(expr)
1959
+ }.freeze
1960
+ end
1961
+
1962
+ # True when a `Struct.new` call carries `keyword_init: true` as a
1963
+ # literal in its trailing keyword hash. A non-literal value (or its
1964
+ # absence) reads as `false` — the conservative positional default.
1965
+ def struct_new_keyword_init?(call_node)
1966
+ args = call_node.arguments&.arguments || []
1967
+ last = args.last
1968
+ return false unless last.is_a?(Prism::KeywordHashNode)
1969
+
1970
+ last.elements.any? do |assoc|
1971
+ assoc.is_a?(Prism::AssocNode) &&
1972
+ assoc.key.is_a?(Prism::SymbolNode) && assoc.key.unescaped == "keyword_init" &&
1973
+ assoc.value.is_a?(Prism::TrueNode)
1974
+ end
1975
+ end
1976
+
1270
1977
  MIXIN_CALL_NAMES = %i[include prepend].freeze
1271
1978
 
1272
1979
  # ADR-24 slice 2 — per-class/module table mapping a fully
@@ -1290,7 +1997,7 @@ module Rigor
1290
1997
 
1291
1998
  case node
1292
1999
  when Prism::ClassNode, Prism::ModuleNode
1293
- name = qualified_name_for(node.constant_path)
2000
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1294
2001
  if name
1295
2002
  full = (qualified_prefix + [name]).join("::")
1296
2003
  walk_class_includes(node.body, qualified_prefix + [name], full, accumulator) if node.body
@@ -1310,7 +2017,7 @@ module Rigor
1310
2017
  return unless MIXIN_CALL_NAMES.include?(node.name)
1311
2018
 
1312
2019
  node.arguments&.arguments&.each do |arg|
1313
- mod = qualified_name_for(arg)
2020
+ mod = Source::ConstantPath.qualified_name(arg)
1314
2021
  (accumulator[current_class] ||= []) << mod if mod
1315
2022
  end
1316
2023
  end
@@ -1350,7 +2057,7 @@ module Rigor
1350
2057
 
1351
2058
  case node
1352
2059
  when Prism::ClassNode, Prism::ModuleNode
1353
- name = qualified_name_for(node.constant_path)
2060
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1354
2061
  if name
1355
2062
  child_prefix = qualified_prefix + [name]
1356
2063
  walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
@@ -1488,7 +2195,7 @@ module Rigor
1488
2195
 
1489
2196
  case node
1490
2197
  when Prism::ClassNode, Prism::ModuleNode
1491
- name = qualified_name_for(node.constant_path)
2198
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1492
2199
  if name
1493
2200
  collect_class_alias_map(node.body, qualified_prefix + [name], accumulator) if node.body
1494
2201
  return accumulator
@@ -1635,8 +2342,9 @@ module Rigor
1635
2342
  # @return [Hash{Symbol => Hash}]
1636
2343
  # `{ def_nodes:, def_sources:, superclasses:, includes:, class_sources: }`
1637
2344
  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: {} }
2345
+ acc = { def_nodes: {}, singleton_def_nodes: {}, def_sources: {}, superclasses: {}, includes: {},
2346
+ method_visibilities: {}, methods: {}, class_sources: {}, data_member_layouts: {},
2347
+ struct_member_layouts: {} }
1640
2348
  paths.each do |path|
1641
2349
  physical = buffer ? buffer.resolve(path) : path
1642
2350
  root = Prism.parse(File.read(physical), filepath: path).value
@@ -1655,7 +2363,7 @@ module Rigor
1655
2363
  # intact while still letting `attr_reader :x` in one file
1656
2364
  # suppress a false undefined-method for `obj.x` in another.
1657
2365
  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|
2366
+ %i[def_nodes singleton_def_nodes def_sources includes method_visibilities methods class_sources].each do |key|
1659
2367
  acc[key].each_value(&:freeze)
1660
2368
  end
1661
2369
  acc.transform_values(&:freeze)
@@ -1678,6 +2386,9 @@ module Rigor
1678
2386
  # visibility declared in a sibling file.
1679
2387
  def accumulate_project_index(acc, path, root)
1680
2388
  merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, root)
2389
+ build_discovered_singleton_def_nodes(root).each do |class_name, methods|
2390
+ (acc[:singleton_def_nodes][class_name] ||= {}).merge!(methods)
2391
+ end
1681
2392
  superclasses = build_discovered_superclasses(root)
1682
2393
  includes = build_discovered_includes(root)
1683
2394
  acc[:superclasses].merge!(superclasses)
@@ -1687,6 +2398,7 @@ module Rigor
1687
2398
  record_class_sources(acc[:class_sources], path, root, superclasses, includes)
1688
2399
  merge_class_keyed_index_tables(acc, root)
1689
2400
  acc[:data_member_layouts].merge!(build_data_member_layouts(root))
2401
+ acc[:struct_member_layouts].merge!(build_struct_member_layouts(root))
1690
2402
  end
1691
2403
 
1692
2404
  # Folds the per-class method-visibility and method-existence tables of
@@ -1745,20 +2457,72 @@ module Rigor
1745
2457
 
1746
2458
  case node
1747
2459
  when Prism::ClassNode
1748
- name = qualified_name_for(node.constant_path)
2460
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1749
2461
  if name
1750
2462
  full = (qualified_prefix + [name]).join("::")
1751
2463
  accumulator[full] = Type::Combinator.singleton_of(full)
1752
2464
  return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
1753
2465
  end
1754
2466
  when Prism::ModuleNode
1755
- name = qualified_name_for(node.constant_path)
2467
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1756
2468
  return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
2469
+ when Prism::ConstantWriteNode
2470
+ record_class_new_constant_decl(node, qualified_prefix, accumulator)
1757
2471
  end
1758
2472
 
1759
2473
  node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
1760
2474
  end
1761
2475
 
2476
+ # T1 (template-corpora survey) — record a `Const = Class.new(Super)`
2477
+ # (and the bare `Class.new` / `Module.new`) class-creating constant
2478
+ # in the cross-file discovery table so a reference to `Const` from
2479
+ # ANOTHER file under the same namespace resolves to the project
2480
+ # class instead of falling through to a core same-named class
2481
+ # (`Liquid::SyntaxError = Class.new(Error)` referenced in a sibling
2482
+ # file's `rescue SyntaxError => e`, which otherwise resolved to core
2483
+ # `::SyntaxError`). Mirrors the single-file `in_source_constants`
2484
+ # answer, which types `Class.new(Super)` as `Singleton[Super]` (the
2485
+ # constructed class answers method lookups through Super's chain).
2486
+ # The superclass name is resolved lexically against the enclosing
2487
+ # prefix; a bare `Class.new` with no superclass (or `Module.new`)
2488
+ # types as `Singleton[Const]` itself. The block form is left to the
2489
+ # existing `meta_new_block_body` machinery — only the plain
2490
+ # `Class.new(Super)` constant (the namespaced-sibling-error idiom)
2491
+ # is added here.
2492
+ def record_class_new_constant_decl(node, qualified_prefix, accumulator)
2493
+ rvalue = node.value
2494
+ return unless class_new_call?(rvalue) || module_new_call?(rvalue)
2495
+ return if rvalue.block # block form: handled by meta_new_block_body walks
2496
+
2497
+ full = (qualified_prefix + [node.name.to_s]).join("::")
2498
+ super_name = class_new_superclass_name(rvalue, qualified_prefix, accumulator)
2499
+ accumulator[full] = Type::Combinator.singleton_of(super_name || full)
2500
+ end
2501
+
2502
+ # Lexically-qualified name of a `Class.new(Super)` superclass
2503
+ # argument, or nil when there is no positional superclass (a bare
2504
+ # `Class.new` / `Module.new`). When the unqualified super name is a
2505
+ # class already discovered under an enclosing-prefix segment, the
2506
+ # qualified form is returned (so `Class.new(Error)` inside `module M`
2507
+ # resolves to `M::Error`); otherwise the literal name is returned
2508
+ # (covering a core / RBS-known superclass spelled bare).
2509
+ def class_new_superclass_name(call_node, qualified_prefix, accumulator)
2510
+ arg = call_node.arguments&.arguments&.first
2511
+ return nil if arg.nil?
2512
+
2513
+ raw = Source::ConstantPath.qualified_name(arg)
2514
+ return nil if raw.nil?
2515
+
2516
+ prefix = qualified_prefix.dup
2517
+ until prefix.empty?
2518
+ candidate = (prefix + [raw]).join("::")
2519
+ return candidate if accumulator.key?(candidate)
2520
+
2521
+ prefix.pop
2522
+ end
2523
+ raw
2524
+ end
2525
+
1762
2526
  # Walks the program once for `Prism::ModuleNode` and
1763
2527
  # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
1764
2528
  # type for the outermost `constant_path` node of each
@@ -1791,7 +2555,7 @@ module Rigor
1791
2555
  end
1792
2556
 
1793
2557
  def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
1794
- name = qualified_name_for(node.constant_path)
2558
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1795
2559
  return false unless name
1796
2560
 
1797
2561
  full = (qualified_prefix + [name]).join("::")
@@ -1903,25 +2667,6 @@ module Rigor
1903
2667
  end
1904
2668
  end
1905
2669
 
1906
- def qualified_name_for(constant_path_node)
1907
- case constant_path_node
1908
- when Prism::ConstantReadNode
1909
- constant_path_node.name.to_s
1910
- when Prism::ConstantPathNode
1911
- render_constant_path(constant_path_node)
1912
- end
1913
- end
1914
-
1915
- def render_constant_path(node)
1916
- prefix =
1917
- case node.parent
1918
- when Prism::ConstantReadNode then "#{node.parent.name}::"
1919
- when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
1920
- else ""
1921
- end
1922
- "#{prefix}#{node.name}"
1923
- end
1924
-
1925
2670
  # Walks `node`'s subtree DFS and fills in scope entries for every
1926
2671
  # Prism node the StatementEvaluator did not visit (i.e. expression-
1927
2672
  # interior nodes like the receiver/args of a CallNode). Those